Bläddra i källkod

feat(extraction): add ArkTS language support with ArkUI dispatch bridges (#396, #512, #890 via #648) (#1186)

Adds ArkTS (.ets, HarmonyOS/OpenHarmony) as a first-class language:
full TypeScript-grade extraction via the harmony-contrib tree-sitter
grammar (MIT, vendored byte-identical from the tree-sitter-arkts 0.2.0
npm tarball), plus the ArkUI constructs that make HarmonyOS apps
traceable:

- @Component/@ComponentV2 structs with decorators from both grammar
  positions; members extract as class members with qualified names.
- build() component trees: child instantiation edges via
  arkui_component_expression, no synthesizer needed.
- Attribute chains emitted dot-prefixed and resolved ONLY against
  @Extend/@Styles/@AnimatableExtend/@Builder helpers (unique-or-drop) —
  bare-name fallthrough produced 36,840 wrong edges (17% of calls) on
  the OpenHarmony samples monorepo. All four grammar chain shapes
  handled, including the detached-chain forms.
- .onClick(this.handler) method-reference bindings.
- ohpm workspace modules: bare imports follow oh-package.json5 file:
  deps (ambiguous names dropped), honoring each module's main entry —
  which also lets .ts consumers resolve .ets modules.
- ArkUI dynamic-dispatch bridges, all provenance:'heuristic' with
  wiring-site metadata: assignment-gated state->build() re-render
  (V1 @State family + V2 @Local/@Provider/@Consumer),
  @ohos.events.emitter emit->subscriber pairing on static event keys
  (numeric ids same-file, named constants same-module, fan-out capped),
  and router.pushUrl literal urls -> the target page's @Entry struct.
- $r/$rawfile resource intrinsics treated as built-ins; arkts joins the
  web language family, value-reference edges, re-export chase, and the
  other TS-applicable gates.

Also ships a language-agnostic index-completeness guard: indexAll
stamps index_state (indexing -> complete/partial/failed), reconciles
discovered vs accounted files (a loaded run silently dropped 37 files),
and codegraph status surfaces truncated/partial indexes in human and
--json output.

Validated on HarmoneyOpenEye (82 files), CoolMallArkTS (528, modular
ohpm + ArkUI V2), and openharmony/applications_app_samples (11,693
files, 202,890 nodes stable across re-index, attribute false-positive
audit 36,840 -> 588 residual all-plausible). Supersedes PRs #656 and
#988 with credit — both informed this implementation.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry 2 månader sedan
förälder
incheckning
99152212a9

+ 23 - 0
.claude/skills/agent-eval/corpus.json

@@ -561,5 +561,28 @@
       "files": "~1800",
       "question": "In the eks/cluster component, how does the cluster IAM role get created and reach the EKS cluster resource, and which outputs expose cluster identity to other components?"
     }
+  ],
+  "ArkTS": [
+    {
+      "name": "HarmoneyOpenEye",
+      "repo": "https://github.com/WinWang/HarmoneyOpenEye",
+      "size": "Small",
+      "files": "~82",
+      "question": "How does the home page get its feed data from the network layer, and how does that data end up rendered as the list on screen? Trace the flow from the HTTP request through the view model into the home page UI."
+    },
+    {
+      "name": "CoolMallArkTS",
+      "repo": "https://github.com/Joker-x-dev/CoolMallArkTS",
+      "size": "Medium",
+      "files": "~528",
+      "question": "When the user adds a product to the cart from the goods detail page, how does the item travel from the UI action to persistent storage? Trace the flow across the feature and core modules."
+    },
+    {
+      "name": "applications_app_samples",
+      "repo": "https://github.com/openharmony/applications_app_samples",
+      "size": "Large",
+      "files": "~9500",
+      "question": "In the OrangeShopping sample app, how does the product detail page's bottom bar (add to cart / buy) lead to the order placement flow? Trace from the bottom navigation component to where the order is created."
+    }
   ]
 }

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 3 - 0
CHANGELOG.md


+ 2 - 1
README.md

@@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll
 | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
 | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
 | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
-| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
+| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
 | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
 | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
 | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
@@ -692,6 +692,7 @@ is written):
 |----------|-----------|--------|
 | TypeScript | `.ts`, `.tsx` | Full support |
 | JavaScript | `.js`, `.jsx`, `.mjs` | Full support |
+| ArkTS (HarmonyOS) | `.ets` | Full support (everything TypeScript has, plus `@Component`/`@ComponentV2` structs with their ArkUI decorators (`@State`/`@Prop`/`@Link`/`@Local`/`@Builder`/…), `build()` view trees — parent→child component edges, chained-attribute links to `@Extend`/`@Styles` functions, `.onClick(this.handler)` event bindings — dynamic-dispatch bridges for state→`build()` re-renders, `@ohos.events.emitter` emit→subscriber pairs (static event keys only), and `router.pushUrl` literal urls → the target page struct; ohpm workspace modules resolve bare `import { X } from "data"` through `oh-package.json5` `file:` dependencies, honoring each module's `main` entry) |
 | Python | `.py` | Full support |
 | Go | `.go` | Full support |
 | Rust | `.rs` | Full support |

+ 428 - 0
__tests__/arkts-resolution.test.ts

@@ -0,0 +1,428 @@
+/**
+ * ArkTS end-to-end resolution tests.
+ *
+ * Pins the precision contract for build()-DSL attribute chains: a chained
+ * `.attr(...)` resolves ONLY to a decorator-marked attribute helper
+ * (`@Extend`/`@Styles`/…) — a framework attribute like `.width(...)` must
+ * NEVER link to an arbitrary same-named symbol elsewhere in the project
+ * (measured on the OpenHarmony samples monorepo, that fallthrough produced
+ * 36k wrong edges — single properties with thousands of false callers).
+ *
+ * Also pins the ohpm workspace bridge: a bare `import { X } from "data"`
+ * follows the oh-package.json5 `file:` dependency to the member module.
+ */
+import { describe, it, expect, beforeAll, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+});
+
+describe('ArkTS attribute-chain resolution precision', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('links .titleStyle() to the @Extend helper but never .width() to a decoy symbol', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkts-'));
+    fs.mkdirSync(path.join(tmpDir, 'pages'));
+    fs.mkdirSync(path.join(tmpDir, 'decoy'));
+
+    // A decoy: symbols named after framework attributes, in another file.
+    fs.writeFileSync(
+      path.join(tmpDir, 'decoy/Decoy.ets'),
+      'export class Decoy {\n' +
+        '  width: number = 0;\n' +
+        '}\n' +
+        'export function height(v: number): number {\n' +
+        '  return v * 2;\n' +
+        '}\n'
+    );
+
+    fs.writeFileSync(
+      path.join(tmpDir, 'pages/Home.ets'),
+      '@Extend(Text) function titleStyle(size: number) {\n' +
+        '  .fontSize(size)\n' +
+        '}\n' +
+        '\n' +
+        '@Component\n' +
+        'struct Home {\n' +
+        '  build() {\n' +
+        '    Column() {\n' +
+        '      Text("hello")\n' +
+        '        .titleStyle(24)\n' +
+        '        .width(100)\n' +
+        '    }\n' +
+        '    .height(50)\n' +
+        '  }\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const fns = cg.getNodesByKind('function');
+    const titleStyle = fns.find((n) => n.name === 'titleStyle');
+    expect(titleStyle).toBeDefined();
+    expect(titleStyle?.decorators).toContain('Extend');
+
+    const structs = cg.getNodesByKind('struct');
+    const home = structs.find((n) => n.name === 'Home');
+    expect(home).toBeDefined();
+
+    // build -> titleStyle via the decorator-gated attribute strategy.
+    const methods = cg.getNodesByKind('method');
+    const build = methods.find((n) => n.qualifiedName === 'Home::build');
+    expect(build).toBeDefined();
+    const buildCallees = cg.getOutgoingEdges(build!.id).map((e) => e.target);
+    expect(buildCallees).toContain(titleStyle!.id);
+
+    // The decoys named after framework attributes must have NO callers.
+    const decoyWidth = cg
+      .getNodesByKind('property')
+      .find((n) => n.name === 'width' && n.filePath.includes('Decoy'));
+    expect(decoyWidth).toBeDefined();
+    expect(cg.getIncomingEdges(decoyWidth!.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
+
+    const decoyHeight = fns.find((n) => n.name === 'height' && n.filePath.includes('Decoy'));
+    expect(decoyHeight).toBeDefined();
+    expect(cg.getIncomingEdges(decoyHeight!.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
+  });
+});
+
+describe('ArkTS ohpm workspace import resolution', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('resolves a bare workspace import through oh-package.json5 file: deps', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-'));
+    fs.mkdirSync(path.join(tmpDir, 'core/data/src/main/ets'), { recursive: true });
+    fs.mkdirSync(path.join(tmpDir, 'feature/goods/src/main/ets'), { recursive: true });
+
+    // Member module "data" with an Index.ets barrel (ohpm entry convention).
+    fs.writeFileSync(
+      path.join(tmpDir, 'core/data/oh-package.json5'),
+      '{\n  // ohpm module manifest\n  "name": "data",\n  "main": "Index.ets",\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'core/data/Index.ets'),
+      "export { CartRepository } from './src/main/ets/CartRepository';\n"
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'core/data/src/main/ets/CartRepository.ets'),
+      'export class CartRepository {\n' +
+        '  addToCart(id: string): void {\n' +
+        '    console.log(id);\n' +
+        '  }\n' +
+        '}\n'
+    );
+
+    // Consumer module declares the sibling via a file: dependency and imports
+    // it by bare name.
+    fs.writeFileSync(
+      path.join(tmpDir, 'feature/goods/oh-package.json5'),
+      '{\n  "name": "goods",\n  "dependencies": {\n    "data": "file:../../core/data", // local module\n  },\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'feature/goods/src/main/ets/GoodsViewModel.ets'),
+      'import { CartRepository } from "data";\n' +
+        '\n' +
+        'export class GoodsViewModel {\n' +
+        '  private cart: CartRepository = new CartRepository();\n' +
+        '\n' +
+        '  add(id: string): void {\n' +
+        '    this.cart.addToCart(id);\n' +
+        '  }\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const classes = cg.getNodesByKind('class');
+    const repo = classes.find((n) => n.name === 'CartRepository');
+    const vm = classes.find((n) => n.name === 'GoodsViewModel');
+    expect(repo).toBeDefined();
+    expect(vm).toBeDefined();
+
+    // add() -> addToCart() across the module boundary.
+    const methods = cg.getNodesByKind('method');
+    const add = methods.find((n) => n.qualifiedName === 'GoodsViewModel::add');
+    const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart');
+    expect(add).toBeDefined();
+    expect(addToCart).toBeDefined();
+    const targets = cg.getOutgoingEdges(add!.id).map((e) => e.target);
+    expect(targets).toContain(addToCart!.id);
+  });
+});
+
+describe('ArkUI state → build() re-render bridge (assignment-gated)', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('links assigning methods to build(), but not read-only methods', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-state-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'Page.ets'),
+      '@Entry\n@Component\nstruct Page {\n' +
+        '  @State todos: string[] = [];\n' +
+        '  @State count: number = 0;\n' +
+        '\n' +
+        '  addTodo(t: string): void {\n' +
+        '    this.todos.push(t);\n' +
+        '  }\n' +
+        '\n' +
+        '  reset(): void {\n' +
+        '    this.count = 0;\n' +
+        '  }\n' +
+        '\n' +
+        '  describeCount(): string {\n' +
+        '    return `count is ${this.count}`;\n' +
+        '  }\n' +
+        '\n' +
+        '  build() {\n' +
+        '    Column() {\n' +
+        '      Text(this.describeCount())\n' +
+        '    }\n' +
+        '  }\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const methods = cg.getNodesByKind('method');
+    const build = methods.find((n) => n.qualifiedName === 'Page::build')!;
+    const addTodo = methods.find((n) => n.qualifiedName === 'Page::addTodo')!;
+    const reset = methods.find((n) => n.qualifiedName === 'Page::reset')!;
+    const describeCount = methods.find((n) => n.qualifiedName === 'Page::describeCount')!;
+
+    const synthEdgesTo = (from: string) =>
+      cg
+        .getOutgoingEdges(from)
+        .filter(
+          (e) =>
+            e.target === build.id &&
+            (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-state'
+        );
+
+    // Array mutator and plain assignment both count as state writes.
+    expect(synthEdgesTo(addTodo.id)).toHaveLength(1);
+    expect(synthEdgesTo(reset.id)).toHaveLength(1);
+    // A read-only method gets NO re-render edge — the precision line.
+    expect(synthEdgesTo(describeCount.id)).toHaveLength(0);
+  });
+});
+
+describe('ArkUI @ohos.events.emitter bridge', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('links emit → on through a shared named constant, chased through a local EventsId', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'Bus.ets'),
+      "import emitter from '@ohos.events.emitter';\n" +
+        '\n' +
+        'export class EmitterConst {\n' +
+        '  static readonly ADD_EVENT_ID: number = 2;\n' +
+        '}\n' +
+        '\n' +
+        'class EventsId {\n' +
+        '  eventId: number;\n' +
+        '  constructor(eventId: number) {\n' +
+        '    this.eventId = eventId;\n' +
+        '  }\n' +
+        '}\n' +
+        '\n' +
+        'export class Bus {\n' +
+        '  subscribeCart(callback: Function): void {\n' +
+        '    let addGoodDataId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' +
+        '    emitter.on(addGoodDataId, (eventData) => {\n' +
+        '      callback(eventData);\n' +
+        '    });\n' +
+        '  }\n' +
+        '\n' +
+        '  publishAdd(goodId: number): void {\n' +
+        '    let addToCartId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' +
+        '    emitter.emit(addToCartId);\n' +
+        '  }\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const methods = cg.getNodesByKind('method');
+    const publishAdd = methods.find((n) => n.qualifiedName === 'Bus::publishAdd')!;
+    const subscribeCart = methods.find((n) => n.qualifiedName === 'Bus::subscribeCart')!;
+    const bridged = cg
+      .getOutgoingEdges(publishAdd.id)
+      .filter(
+        (e) =>
+          e.target === subscribeCart.id &&
+          (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-emitter'
+      );
+    expect(bridged).toHaveLength(1);
+  });
+
+  it('numeric-literal event ids never pair across files', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter2-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'A.ets'),
+      "import emitter from '@ohos.events.emitter';\n" +
+        'export function fireA(): void {\n' +
+        '  emitter.emit({ eventId: 1 });\n' +
+        '}\n'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'B.ets'),
+      "import emitter from '@ohos.events.emitter';\n" +
+        'export function listenB(): void {\n' +
+        '  emitter.on({ eventId: 1 }, () => {});\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const fns = cg.getNodesByKind('function');
+    const fireA = fns.find((n) => n.name === 'fireA')!;
+    const listenB = fns.find((n) => n.name === 'listenB')!;
+    const bridged = cg
+      .getOutgoingEdges(fireA.id)
+      .filter((e) => e.target === listenB.id);
+    expect(bridged).toHaveLength(0);
+  });
+});
+
+describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('links the navigating method to the target page struct, standard layout only', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-router-'));
+    fs.mkdirSync(path.join(tmpDir, 'entry/src/main/ets/pages'), { recursive: true });
+    fs.writeFileSync(
+      path.join(tmpDir, 'entry/src/main/ets/pages/Detail.ets'),
+      '@Entry\n@Component\nstruct Detail {\n  build() {\n    Column() {\n      Text("detail")\n    }\n  }\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'entry/src/main/ets/pages/Home.ets'),
+      "import router from '@ohos.router';\n" +
+        '\n' +
+        '@Entry\n@Component\nstruct Home {\n' +
+        '  openDetail(id: string): void {\n' +
+        "    router.pushUrl({ url: 'pages/Detail', params: { id: id } });\n" +
+        '  }\n' +
+        '\n' +
+        '  build() {\n' +
+        '    Column() {\n' +
+        "      Button('go').onClick(this.openDetail)\n" +
+        '    }\n' +
+        '  }\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const methods = cg.getNodesByKind('method');
+    const openDetail = methods.find((n) => n.qualifiedName === 'Home::openDetail')!;
+    const detail = cg.getNodesByKind('struct').find((n) => n.name === 'Detail')!;
+    const bridged = cg
+      .getOutgoingEdges(openDetail.id)
+      .filter(
+        (e) =>
+          e.target === detail.id &&
+          (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-route'
+      );
+    expect(bridged).toHaveLength(1);
+  });
+});
+
+describe('ohpm main entry (custom barrel + .ts consumer)', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('resolves a bare import through a custom main, from an .ets AND a .ts consumer', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-main-'));
+    fs.mkdirSync(path.join(tmpDir, 'core/data/src'), { recursive: true });
+    fs.mkdirSync(path.join(tmpDir, 'feature/goods/src'), { recursive: true });
+
+    // Custom entry — NOT the Index.ets convention.
+    fs.writeFileSync(
+      path.join(tmpDir, 'core/data/oh-package.json5'),
+      '{\n  "name": "data",\n  "main": "src/entry.ets",\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'core/data/src/entry.ets'),
+      "export { CartRepository } from './CartRepository';\n"
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'core/data/src/CartRepository.ets'),
+      'export class CartRepository {\n  addToCart(id: string): void {\n    console.log(id);\n  }\n}\n'
+    );
+
+    fs.writeFileSync(
+      path.join(tmpDir, 'feature/goods/oh-package.json5'),
+      '{\n  "name": "goods",\n  "dependencies": {\n    "data": "file:../../core/data",\n  },\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'feature/goods/src/GoodsVm.ets'),
+      'import { CartRepository } from "data";\n' +
+        'export class GoodsVm {\n' +
+        '  private cart: CartRepository = new CartRepository();\n' +
+        '  add(id: string): void {\n    this.cart.addToCart(id);\n  }\n' +
+        '}\n'
+    );
+    // The .ts consumer — resolves through the manifest's entry, no `.ets`
+    // in the TypeScript candidate list required.
+    fs.writeFileSync(
+      path.join(tmpDir, 'feature/goods/src/report.ts'),
+      'import { CartRepository } from "data";\n' +
+        'export function report(cart: CartRepository): string {\n' +
+        '  return typeof cart;\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const classes = cg.getNodesByKind('class');
+    const repo = classes.find((n) => n.name === 'CartRepository')!;
+    expect(repo).toBeDefined();
+
+    // .ets consumer: cross-module method call connects.
+    const methods = cg.getNodesByKind('method');
+    const add = methods.find((n) => n.qualifiedName === 'GoodsVm::add')!;
+    const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart')!;
+    expect(cg.getOutgoingEdges(add.id).map((e) => e.target)).toContain(addToCart.id);
+
+    // .ts consumer: the type annotation reference reaches the .ets class.
+    const report = cg.getNodesByKind('function').find((n) => n.name === 'report')!;
+    expect(cg.getOutgoingEdges(report.id).map((e) => e.target)).toContain(repo.id);
+  });
+});

+ 288 - 0
__tests__/extraction.test.ts

@@ -138,6 +138,12 @@ describe('Language Detection', () => {
     expect(detectLanguage('versions.tofu')).toBe('terraform');
   });
 
+  it('should detect ArkTS files', () => {
+    expect(detectLanguage('entry/src/main/ets/pages/Index.ets')).toBe('arkts');
+    // Plain `.ts` in a HarmonyOS project is still TypeScript.
+    expect(detectLanguage('entry/src/main/ets/common/utils.ts')).toBe('typescript');
+  });
+
   it('should return unknown for unsupported extensions', () => {
     expect(detectLanguage('styles.css')).toBe('unknown');
     expect(detectLanguage('data.json')).toBe('unknown');
@@ -10238,3 +10244,285 @@ resource "aws_instance" "x" {
     });
   });
 });
+
+// =============================================================================
+// ArkTS (HarmonyOS / OpenHarmony declarative UI — `.ets`)
+// =============================================================================
+
+describe('ArkTS Extraction', () => {
+  it('reports ArkTS as supported', () => {
+    expect(isLanguageSupported('arkts')).toBe(true);
+    expect(getSupportedLanguages()).toContain('arkts');
+  });
+
+  describe('@Component struct extraction', () => {
+    const code = `
+import { TodoItem } from '../model/TodoItem';
+
+@Entry
+@Component
+struct Index {
+  @State message: string = 'Hello';
+  @Prop count: number = 0;
+  @StorageLink('theme') theme: string = 'light';
+  private service: TodoService = new TodoService();
+
+  aboutToAppear(): void {
+    this.load();
+  }
+
+  load(): void {
+    this.message = 'loaded';
+  }
+
+  build() {
+    Column() {
+      Text(this.message).fontSize(50)
+    }
+    .height('100%')
+  }
+}
+`;
+
+    it('extracts the struct with its ArkUI decorators', () => {
+      const result = extractFromSource('pages/Index.ets', code);
+      const comp = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Index');
+      expect(comp).toBeDefined();
+      expect(comp?.language).toBe('arkts');
+      expect(comp?.decorators).toEqual(expect.arrayContaining(['Entry', 'Component']));
+    });
+
+    it('extracts an EXPORTED struct whose decorators sit on the export statement', () => {
+      const result = extractFromSource(
+        'components/Card.ets',
+        `@Component\nexport struct Card {\n  build() {\n    Row() {}\n  }\n}\n`
+      );
+      const card = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Card');
+      expect(card).toBeDefined();
+      expect(card?.isExported).toBe(true);
+      expect(card?.decorators).toContain('Component');
+    });
+
+    it('extracts struct members: build(), lifecycle + regular methods with qualified names', () => {
+      const result = extractFromSource('pages/Index.ets', code);
+      const methods = result.nodes.filter((n) => n.kind === 'method');
+      expect(methods.find((m) => m.qualifiedName === 'Index::build')).toBeDefined();
+      expect(methods.find((m) => m.qualifiedName === 'Index::aboutToAppear')).toBeDefined();
+      expect(methods.find((m) => m.qualifiedName === 'Index::load')).toBeDefined();
+    });
+
+    it('extracts @State/@Prop/@StorageLink members as properties with their decorators', () => {
+      const result = extractFromSource('pages/Index.ets', code);
+      const message = result.nodes.find((n) => n.kind === 'property' && n.qualifiedName === 'Index::message');
+      expect(message).toBeDefined();
+      expect(message?.decorators).toContain('State');
+      const count = result.nodes.find((n) => n.kind === 'property' && n.qualifiedName === 'Index::count');
+      expect(count?.decorators).toContain('Prop');
+      // Decorator-with-args: the decorator NAME is captured, not its argument.
+      const theme = result.nodes.find((n) => n.kind === 'property' && n.qualifiedName === 'Index::theme');
+      expect(theme?.decorators).toContain('StorageLink');
+    });
+
+    it('emits intra-struct method call refs (this.load())', () => {
+      const result = extractFromSource('pages/Index.ets', code);
+      const call = result.unresolvedReferences.find(
+        (r) => r.referenceKind === 'calls' && r.referenceName === 'load'
+      );
+      expect(call).toBeDefined();
+    });
+  });
+
+  describe('build() DSL call surface', () => {
+    const code = `
+@Extend(Text) function titleStyle(size: number) {
+  .fontSize(size)
+}
+
+@Component
+struct Page {
+  count: number = 0;
+
+  handleTap(): void {
+    this.count += 1;
+  }
+
+  @Builder
+  headerBar(title: string) {
+    Row() {
+      Text(title).titleStyle(24)
+      Button('Go').onClick(this.handleTap)
+    }
+  }
+
+  build() {
+    Column({ space: 8 }) {
+      this.headerBar('Home')
+      ChildCard({ label: 'hi' })
+    }
+    .height('100%')
+  }
+}
+`;
+
+    function callRefsFrom(result: ReturnType<typeof extractFromSource>, methodName: string): string[] {
+      const from = result.nodes.find((n) => n.kind === 'method' && n.name === methodName);
+      return result.unresolvedReferences
+        .filter((r) => r.referenceKind === 'calls' && r.fromNodeId === from?.id)
+        .map((r) => r.referenceName);
+    }
+
+    it('emits a call ref for a custom component instantiation inside build()', () => {
+      const result = extractFromSource('pages/Page.ets', code);
+      expect(callRefsFrom(result, 'build')).toContain('ChildCard');
+    });
+
+    it('emits dot-prefixed call refs for chained attributes (@Extend/@Styles-only resolution)', () => {
+      const result = extractFromSource('pages/Page.ets', code);
+      // `.titleStyle(24)` chains on the Text component — one node, repeated
+      // property/arguments field pairs, NOT nested call_expressions. The
+      // leading dot routes the ref to the decorator-gated matcher strategy so
+      // framework attributes (`.height` below) can never hit an arbitrary
+      // same-named symbol.
+      expect(callRefsFrom(result, 'headerBar')).toContain('.titleStyle');
+      expect(callRefsFrom(result, 'build')).toContain('.height');
+      expect(callRefsFrom(result, 'build')).not.toContain('height');
+    });
+
+    it('recovers the detached-chain shape (chain on the line after a nested component)', () => {
+      // Inside arkui_children, a chain starting after the closing `}` is
+      // detached by the grammar into sibling leading_dot_expression +
+      // parenthesized_expression statements — the close-button idiom.
+      const detached = `
+@Component
+struct Panel {
+  close(): void {}
+
+  build() {
+    Column() {
+      Row() {
+        Text('x')
+      }
+      .width(10)
+      .onClick(this.close)
+      .id('close_button')
+    }
+  }
+}
+`;
+      const result = extractFromSource('components/Panel.ets', detached);
+      const refs = callRefsFrom(result, 'build');
+      expect(refs).toContain('close');
+      expect(refs).toContain('.width');
+      expect(refs).not.toContain('width');
+    });
+
+    it('dot-prefixes the innermost call of a proper-form detached chain', () => {
+      // `.alignItems(x).layoutWeight(1)` under a leading_dot_expression: the
+      // wrapper consumes the dot, so the innermost call has a bare identifier
+      // function and would otherwise emit as a plain `alignItems(...)` call.
+      const chained = `
+@Component
+struct Card {
+  build() {
+    Column() {
+      List() {
+        Text('x')
+      }
+      .alignItems(HorizontalAlign.Start)
+      .layoutWeight(1)
+      .height('100%')
+    }
+  }
+}
+`;
+      const result = extractFromSource('components/Card.ets', chained);
+      const refs = callRefsFrom(result, 'build');
+      expect(refs).toContain('.alignItems');
+      expect(refs).not.toContain('alignItems');
+      expect(refs).toContain('.layoutWeight');
+      expect(refs).not.toContain('layoutWeight');
+    });
+
+    it('emits a call ref for an .onClick(this.handler) method-reference binding', () => {
+      const result = extractFromSource('pages/Page.ets', code);
+      expect(callRefsFrom(result, 'headerBar')).toContain('handleTap');
+    });
+
+    it('emits a call ref for a @Builder method invoked as this.headerBar()', () => {
+      const result = extractFromSource('pages/Page.ets', code);
+      expect(callRefsFrom(result, 'build')).toContain('headerBar');
+    });
+
+    it('extracts a global @Extend function with its decorator', () => {
+      const result = extractFromSource('pages/Page.ets', code);
+      const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'titleStyle');
+      expect(fn).toBeDefined();
+      expect(fn?.decorators).toContain('Extend');
+    });
+  });
+
+  describe('Global @Builder functions', () => {
+    it('extracts a decorated global @Builder function with signature and decorator', () => {
+      const result = extractFromSource(
+        'common/builders.ets',
+        `@Builder\nfunction EmptyHint(message: string) {\n  Column() {\n    Text(message).fontSize(16)\n  }\n}\n`
+      );
+      const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'EmptyHint');
+      expect(fn).toBeDefined();
+      expect(fn?.signature).toBe('(message: string)');
+      expect(fn?.decorators).toContain('Builder');
+    });
+  });
+
+  describe('Standard TypeScript constructs in .ets', () => {
+    it('extracts classes, interfaces, enums, type aliases and their members', () => {
+      const code = `
+export enum Priority { Low, Medium = 2, High }
+
+export interface Shape {
+  area(): number;
+}
+
+export type Handler = (e: string) => void;
+
+export class Service {
+  private count: number = 0;
+  doWork(x: number): number {
+    return this.helper(x);
+  }
+  helper(n: number): number { return n * 2; }
+}
+`;
+      const result = extractFromSource('common/service.ets', code);
+      expect(result.nodes.find((n) => n.kind === 'class' && n.name === 'Service')).toBeDefined();
+      expect(result.nodes.find((n) => n.kind === 'enum' && n.name === 'Priority')).toBeDefined();
+      const members = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.qualifiedName);
+      expect(members).toEqual(expect.arrayContaining(['Priority::Low', 'Priority::Medium', 'Priority::High']));
+      expect(result.nodes.find((n) => n.kind === 'interface' && n.name === 'Shape')).toBeDefined();
+      expect(result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'Handler')).toBeDefined();
+      const doWork = result.nodes.find((n) => n.qualifiedName === 'Service::doWork');
+      expect(doWork?.kind).toBe('method');
+      expect(doWork?.signature).toBe('(x: number): number');
+      expect(
+        result.unresolvedReferences.find((r) => r.referenceKind === 'calls' && r.referenceName === 'helper')
+      ).toBeDefined();
+    });
+  });
+
+  describe('Import extraction', () => {
+    it('extracts relative, SDK (@ohos/@kit) and default imports', () => {
+      const code = `
+import router from '@ohos.router';
+import { promptAction } from '@kit.ArkUI';
+import { TodoItem } from '../model/TodoItem';
+import DataStore from '../data/DataStore';
+`;
+      const result = extractFromSource('pages/imports.ets', code);
+      const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
+      expect(imports).toContain('@ohos.router');
+      expect(imports).toContain('@kit.ArkUI');
+      expect(imports).toContain('../model/TodoItem');
+      expect(imports).toContain('../data/DataStore');
+    });
+  });
+});

+ 58 - 0
__tests__/status-json.test.ts

@@ -87,3 +87,61 @@ describe('codegraph status --json — CI fields (#329)', () => {
     expect(ms).toBeLessThanOrEqual(after + 1000);
   });
 });
+
+describe('index completeness marker (index_state)', () => {
+  let tempDir: string;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-state-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('a clean full index stamps state=complete with reconciled counts', async () => {
+    fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export function f(): number { return 1; }\n');
+    fs.writeFileSync(path.join(tempDir, 'b.ts'), 'import { f } from "./a";\nexport const y = f();\n');
+    const cg = CodeGraph.initSync(tempDir);
+    const result = await cg.indexAll();
+
+    // The scan's ground truth is reported and fully accounted for.
+    expect(result.filesDiscovered).toBeDefined();
+    expect(result.filesIndexed + result.filesSkipped + result.filesErrored).toBe(
+      result.filesDiscovered
+    );
+    expect(result.errors.filter((e) => e.code === 'index_partial')).toHaveLength(0);
+    expect(cg.getIndexState()).toBe('complete');
+    cg.close();
+
+    const out = runStatusJson(tempDir);
+    expect((out.index as Record<string, unknown>).state).toBe('complete');
+  });
+
+  it('a run killed mid-index leaves state=indexing, and status --json surfaces it', async () => {
+    fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export const x = 1;\n');
+    const cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.close();
+
+    // Simulate a kill between the start-marker write and completion: the
+    // marker a dead process leaves behind is exactly 'indexing'. Written
+    // straight into the DB — the process that died can't have cleaned it up.
+    // (require, not import: vite tries to bundle a dynamic import specifier.)
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const { DatabaseSync } = require('node:sqlite');
+    const db = new DatabaseSync(path.join(tempDir, '.codegraph', 'codegraph.db'));
+    db.prepare(
+      "INSERT INTO project_metadata (key, value, updated_at) VALUES ('index_state', 'indexing', 0) " +
+        "ON CONFLICT(key) DO UPDATE SET value = 'indexing'"
+    ).run();
+    db.close();
+
+    const out = runStatusJson(tempDir);
+    expect((out.index as Record<string, unknown>).state).toBe('indexing');
+
+    const reopened = await CodeGraph.open(tempDir);
+    expect(reopened.getIndexState()).toBe('indexing');
+    reopened.close();
+  });
+});

+ 20 - 0
src/bin/codegraph.ts

@@ -354,6 +354,14 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
       clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`);
     }
     clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`);
+    // A PARTIAL index (files silently dropped mid-pipeline) must not pass
+    // as a clean run — it's the difference between "indexed the repo" and
+    // "indexed most of the repo, quietly". Only the completeness
+    // reconciliation warning; per-file extractor warnings stay in the
+    // error-code summary below.
+    for (const w of result.errors.filter((e) => e.code === 'index_partial')) {
+      clack.log.warn(w.message);
+    }
   } else if (hasErrors) {
     clack.log.error(`Indexing failed ${getGlyphs().dash} all ${formatNumber(result.filesErrored)} files had errors`);
   } else {
@@ -798,6 +806,7 @@ program
 
       const buildInfo = cg.getIndexBuildInfo();
       const reindexRecommended = cg.isIndexStale();
+      const indexState = cg.getIndexState();
 
       // JSON output mode
       if (options.json) {
@@ -829,6 +838,10 @@ program
             builtWithExtractionVersion: buildInfo.extractionVersion,
             currentExtractionVersion: EXTRACTION_VERSION,
             reindexRecommended,
+            // 'complete' | 'partial' (files silently dropped) | 'indexing'
+            // (a run was killed mid-index — the index is truncated) |
+            // 'failed' | null (predates the marker).
+            state: indexState,
           },
         }));
         cg.destroy();
@@ -842,6 +855,13 @@ program
       if (worktreeMismatch) {
         warn(worktreeMismatchWarning(worktreeMismatch));
       }
+      if (indexState === 'indexing') {
+        warn('The last index run never finished (killed mid-index?) — the index is truncated. Re-run "codegraph index".');
+      } else if (indexState === 'partial') {
+        warn('The last index run silently dropped files — the index is partial. Re-run "codegraph index".');
+      } else if (indexState === 'failed') {
+        warn('The last index run failed — results may be incomplete. Re-run "codegraph index".');
+      }
       console.log();
 
       // Index stats

+ 13 - 1
src/extraction/grammars.ts

@@ -47,6 +47,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
   erlang: 'tree-sitter-erlang.wasm',
   solidity: 'tree-sitter-solidity.wasm',
   terraform: 'tree-sitter-terraform.wasm',
+  arkts: 'tree-sitter-arkts.wasm',
 };
 
 /**
@@ -58,6 +59,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
   // ESM/CJS TypeScript module extensions — parsed as TS (no JSX). (#366)
   '.mts': 'typescript',
   '.cts': 'typescript',
+  // ArkTS (HarmonyOS / OpenHarmony) — a TypeScript superset with declarative
+  // UI (`@Component struct` + `build()`). Own grammar (a tree-sitter-typescript
+  // -style fork); plain `.ts` in an ArkTS project stays TypeScript. (#648)
+  '.ets': 'arkts',
   '.js': 'javascript',
   '.mjs': 'javascript',
   '.cjs': 'javascript',
@@ -292,7 +297,13 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
       // ship HCL/Terraform at all, so we vendor the prebuilt
       // tree-sitter-terraform.wasm from @tree-sitter-grammars/tree-sitter-hcl
       // 1.2.0 (Apache-2.0) — byte-identical to the npm package's artifact.
-      const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform')
+      // ArkTS: tree-sitter-wasms doesn't ship it either; we vendor the prebuilt
+      // tree-sitter-arkts.wasm from the tree-sitter-arkts 0.2.0 npm package
+      // (harmony-contrib/tree-sitter-arkts, MIT) — byte-identical to the npm
+      // tarball's artifact. It extends the tree-sitter-javascript grammar the
+      // same way tree-sitter-typescript does, adding `struct_declaration` and
+      // the `arkui_component_expression` build() DSL.
+      const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform' || lang === 'arkts')
         ? path.join(__dirname, 'wasm', wasmFile)
         : require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
       const language = await WasmLanguage.load(wasmPath);
@@ -518,6 +529,7 @@ export function getLanguageDisplayName(language: Language): string {
     vbnet: 'Visual Basic .NET',
     erlang: 'Erlang',
     terraform: 'Terraform',
+    arkts: 'ArkTS',
     unknown: 'Unknown',
   };
   return names[language] || language;

+ 10 - 0
src/extraction/index.ts

@@ -83,6 +83,14 @@ export interface IndexResult {
   filesIndexed: number;
   filesSkipped: number;
   filesErrored: number;
+  /**
+   * How many indexable files the scan discovered — the ground truth the
+   * indexed/skipped/errored tallies must add up to. A shortfall means files
+   * were silently dropped mid-pipeline (e.g. a killed worker under load) and
+   * the index is PARTIAL; callers surface that rather than trusting the
+   * counts. Only set by full-index runs (indexAll), not indexFiles/sync.
+   */
+  filesDiscovered?: number;
   nodesCreated: number;
   edgesCreated: number;
   errors: ExtractionError[];
@@ -1512,6 +1520,7 @@ export class ExtractionOrchestrator {
         filesIndexed,
         filesSkipped,
         filesErrored,
+        filesDiscovered: total,
         nodesCreated: totalNodes,
         edgesCreated: totalEdges,
         errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
@@ -1645,6 +1654,7 @@ export class ExtractionOrchestrator {
       filesIndexed,
       filesSkipped,
       filesErrored,
+      filesDiscovered: total,
       nodesCreated: totalNodes,
       edgesCreated: totalEdges,
       errors,

+ 128 - 0
src/extraction/languages/arkts.ts

@@ -0,0 +1,128 @@
+import type { LanguageExtractor } from '../tree-sitter-types';
+import { typescriptExtractor } from './typescript';
+import type { Node as SyntaxNode } from 'web-tree-sitter';
+
+/**
+ * ArkTS (HarmonyOS / OpenHarmony, `.ets`) — a TypeScript superset whose
+ * headline feature is declarative UI: an `@Component struct` with a `build()`
+ * method describing the view tree, `@State`/`@Prop`/`@Link` reactive
+ * properties, and global `@Builder`/`@Extend`/`@Styles` functions.
+ *
+ * The vendored grammar (harmony-contrib/tree-sitter-arkts) extends
+ * tree-sitter-javascript exactly the way tree-sitter-typescript does, so every
+ * TS node type — and therefore the whole typescriptExtractor — applies
+ * verbatim. ArkTS-specific shapes it adds:
+ *
+ *   - `struct_declaration` / `struct_body` — the `@Component struct`. Same
+ *     `name:`/`body:` fields as class_declaration; members are ordinary
+ *     `method_definition` / `public_field_definition` nodes, so struct members
+ *     extract through the standard class-member paths.
+ *   - `arkui_component_expression` — a build()-DSL component instantiation
+ *     (`Column() { … }`). Carries a `function:` field (the component), an
+ *     optional `children:` block, and — unlike TS — the CHAINED ATTRIBUTES as
+ *     repeated `property:`/`arguments:` field pairs on the SAME node
+ *     (`Text(x).fontSize(16).opacity(0.6)` is ONE node, not nested calls).
+ *     Handled by the arkts branch in extractCall (tree-sitter.ts).
+ *   - Decorators on functions (`@Builder function F() {}`) — invalid in TS,
+ *     first-class here (a `decorator:` field on function_declaration), so the
+ *     core's existing extractDecoratorsFor path captures them.
+ */
+
+/** Reactive/state decorators that make a member worth flagging (searchable). */
+const DECORATED_MEMBER_TYPES = new Set([
+  'struct_declaration',
+  'public_field_definition',
+  'method_definition',
+  'function_declaration',
+]);
+
+/**
+ * Collect decorator names for a declaration from BOTH positions the grammar
+ * produces: direct `decorator` children (`@Entry @Component struct X`,
+ * `@State count` on a field) and preceding `decorator` siblings (`@Builder`
+ * before a method_definition inside struct_body; `@Component` on the
+ * export_statement wrapping `export struct X`). The backwards sibling walk
+ * stops at the first non-decorator so an earlier declaration's decorators
+ * never leak in (mirrors extractDecoratorsFor's sibling pass).
+ */
+function collectDecoratorNames(node: SyntaxNode): string[] | undefined {
+  const names: string[] = [];
+  const nameOf = (dec: SyntaxNode): string | undefined => {
+    for (let i = 0; i < dec.namedChildCount; i++) {
+      const child = dec.namedChild(i);
+      if (!child) continue;
+      if (child.type === 'identifier') return child.text;
+      if (child.type === 'call_expression') {
+        // `@StorageLink('theme')` / `@Extend(Text)` — the decorator name is
+        // the callee.
+        const fn = child.childForFieldName('function');
+        if (fn?.type === 'identifier') return fn.text;
+      }
+    }
+    return undefined;
+  };
+
+  for (let i = 0; i < node.namedChildCount; i++) {
+    const child = node.namedChild(i);
+    if (child?.type === 'decorator') {
+      const n = nameOf(child);
+      if (n) names.push(n);
+    }
+  }
+
+  const parent = node.parent;
+  if (parent) {
+    // Find this node among the parent's named children by start offset
+    // (wrapper identity is not stable across navigation), then walk backwards.
+    const start = node.startIndex;
+    let idx = -1;
+    for (let i = 0; i < parent.namedChildCount; i++) {
+      const sib = parent.namedChild(i);
+      if (sib && sib.startIndex === start) {
+        idx = i;
+        break;
+      }
+    }
+    for (let i = idx - 1; i >= 0; i--) {
+      const sib = parent.namedChild(i);
+      if (!sib || sib.type !== 'decorator') break;
+      const n = nameOf(sib);
+      if (n) names.unshift(n);
+    }
+  }
+
+  return names.length > 0 ? names : undefined;
+}
+
+export const arktsExtractor: LanguageExtractor = {
+  ...typescriptExtractor,
+
+  // `@Component struct X { … }` — extractStruct handles it (kind `struct`,
+  // members extracted like class members, `this.m()` resolution and the
+  // class/struct containment gates in the name-matcher all apply as-is). The
+  // component-ness is preserved on the node's decorators (`Component`,
+  // `Entry`, `CustomDialog`, `Reusable`), captured by extractModifiers below.
+  structTypes: ['struct_declaration'],
+
+  // build()-DSL component instantiations are call sites: `TodoRow({...})`
+  // inside a parent's build() is the parent→child component edge, resolved by
+  // the ordinary call pipeline against the child's struct node. The arkts
+  // branch in extractCall also lifts each chained `.attr(...)` (emitted
+  // dot-prefixed so it can ONLY resolve to `@Extend`/`@Styles`/`@Builder`
+  // attribute helpers — see matchReference) and `.onXxx(this.handler)`
+  // method-reference bindings. `leading_dot_expression` is the detached-chain
+  // shape the grammar produces when a nested component's chain starts on the
+  // line after its closing `}` inside arkui_children.
+  callTypes: ['call_expression', 'arkui_component_expression', 'leading_dot_expression'],
+
+  // Surface ArkTS decorators on the node's `decorators` list (searchable, and
+  // the hook a future ArkUI state→build synthesizer keys off). Core paths
+  // already emit `decorates` REFERENCES for classes/methods/properties/
+  // functions; this hook is what puts the names on struct nodes too —
+  // extractStruct has no extractDecoratorsFor call, and node.decorators is
+  // only populated via extractModifiers (see createNode).
+  extractModifiers: (node) => {
+    if (!DECORATED_MEMBER_TYPES.has(node.type)) return undefined;
+    return collectDecoratorNames(node);
+  },
+};

+ 2 - 0
src/extraction/languages/index.ts

@@ -34,6 +34,7 @@ import { vbnetExtractor } from './vbnet';
 import { erlangExtractor } from './erlang';
 import { solidityExtractor } from './solidity';
 import { terraformExtractor } from './terraform';
+import { arktsExtractor } from './arkts';
 
 export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   typescript: typescriptExtractor,
@@ -65,4 +66,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   erlang: erlangExtractor,
   solidity: solidityExtractor,
   terraform: terraformExtractor,
+  arkts: arktsExtractor,
 };

+ 179 - 6
src/extraction/tree-sitter.ts

@@ -373,7 +373,7 @@ export class TreeSitterExtractor {
   // Value-reference edges (default ON; set CODEGRAPH_VALUE_REFS=0 to disable; see flushValueRefs).
   // Same-file reads of file-scope const/var symbols → `references` edges so impact analysis catches
   // value consumers ("change this constant/table, affect its readers").
-  private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift', 'dart', 'pascal']);
+  private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx', 'arkts', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift', 'dart', 'pascal']);
   private static readonly MAX_VALUE_REF_NODES = 20_000;
   private readonly valueRefsEnabled = process.env.CODEGRAPH_VALUE_REFS !== '0';
   private fileScopeValues = new Map<string, string>();
@@ -1183,7 +1183,8 @@ export class TreeSitterExtractor {
     else if (
       nodeType === 'export_statement' &&
       (this.language === 'typescript' || this.language === 'tsx' ||
-       this.language === 'javascript' || this.language === 'jsx') &&
+       this.language === 'javascript' || this.language === 'jsx' ||
+       this.language === 'arkts') &&
       getChildByField(node, 'source')
     ) {
       const parentId = this.nodeStack[this.nodeStack.length - 1];
@@ -2487,7 +2488,8 @@ export class TreeSitterExtractor {
 
     // Extract variable declarators based on language
     if (this.language === 'typescript' || this.language === 'javascript' ||
-        this.language === 'tsx' || this.language === 'jsx' || this.language === 'cfscript') {
+        this.language === 'tsx' || this.language === 'jsx' || this.language === 'cfscript' ||
+        this.language === 'arkts') {
       // Handle lexical_declaration and variable_declaration
       // These contain one or more variable_declarator children
       for (let i = 0; i < node.namedChildCount; i++) {
@@ -2916,7 +2918,7 @@ export class TreeSitterExtractor {
         // property/method nodes under the type alias so `recorder.stop()`
         // can attach the call edge to `RecorderHandle.stop` instead of
         // an unrelated class method picked by path-proximity (#359).
-        if (this.language === 'typescript' || this.language === 'tsx') {
+        if (this.language === 'typescript' || this.language === 'tsx' || this.language === 'arkts') {
           this.extractTsTypeAliasMembers(value, typeAliasNode);
           // `type List = [ Service<'name', Req, Resp>, … ]` — surface each
           // entry's string-literal name as a searchable member (issue #634).
@@ -3132,7 +3134,8 @@ export class TreeSitterExtractor {
         // called/typed symbols still record a cross-file dependency (TS/JS only).
         if (
           this.language === 'typescript' || this.language === 'tsx' ||
-          this.language === 'javascript' || this.language === 'jsx'
+          this.language === 'javascript' || this.language === 'jsx' ||
+          this.language === 'arkts'
         ) {
           const parentId = this.nodeStack[this.nodeStack.length - 1];
           if (parentId) this.emitImportBindingRefs(node, parentId);
@@ -3894,6 +3897,176 @@ export class TreeSitterExtractor {
       return;
     }
 
+    // ArkTS build()-DSL handling. Three shapes carry UI-attribute chains, and
+    // all of their attribute names are emitted with a LEADING DOT
+    // (`.titleStyle`, `.width`) — an impossible identifier that routes them to
+    // a dedicated matcher strategy resolving ONLY to decorator-marked
+    // attribute helpers (`@Extend`/`@Styles`/`@AnimatableExtend`/`@Builder`
+    // functions). Bare names would go through global name matching, where
+    // framework attributes (`.width`, `.fontSize`, appearing on nearly every
+    // UI line) hit arbitrary same-named symbols — measured on the OpenHarmony
+    // samples monorepo, that produced 36k wrong edges (17% of all calls),
+    // including single properties with 3,400+ false callers.
+    //
+    //   1. `Column({space:8}) { … }.height('100%')` — ONE
+    //      arkui_component_expression: `function:` = the component, chained
+    //      attributes as repeated `property:`/`arguments:` field pairs.
+    //      The component ref (`Column`, `TodoRow`) stays a PLAIN name — it
+    //      resolves to the child `@Component struct`, giving the parent→child
+    //      component-tree edge the way JSX children do for React.
+    //   2. `Image(x).width(10).onClick(this.f)` — ordinary nested
+    //      call_expressions whose `function:` is a member_expression chained
+    //      on a CALL RESULT (never a named receiver, so `svc.save()` /
+    //      `this.vm.load()` are untouched and fall through to the generic
+    //      paths below).
+    //   3. A nested component whose chain starts on the line AFTER its
+    //      closing `}` inside arkui_children — the grammar detaches the chain
+    //      into sibling `leading_dot_expression(identifier)` +
+    //      `parenthesized_expression(args)` statement pairs; reassemble from
+    //      the siblings.
+    //
+    // `.onXxx(this.handler)` METHOD-REFERENCE bindings (no call parens, so
+    // nothing else records them) additionally emit a call ref to the bare
+    // handler name — same-class resolution links the tap→handler hop.
+    // Arrow-function handlers need nothing: their bodies' calls already
+    // attribute to the enclosing build(). Children/argument subtrees are
+    // still walked by the caller, so nested components extract normally.
+    if (this.language === 'arkts') {
+      const emitAttr = (nameNode: SyntaxNode): void => {
+        const attrName = getNodeText(nameNode, this.source);
+        if (!attrName) return;
+        this.unresolvedReferences.push({
+          fromNodeId: callerId,
+          referenceName: '.' + attrName,
+          referenceKind: 'calls',
+          line: nameNode.startPosition.row + 1,
+          column: nameNode.startPosition.column,
+        });
+      };
+      // Emit `handler` for each bare `this.handler` among an on-attribute's
+      // arguments.
+      const emitThisHandlers = (args: SyntaxNode | null): void => {
+        if (!args) return;
+        for (let j = 0; j < args.namedChildCount; j++) {
+          const arg = args.namedChild(j);
+          if (arg?.type !== 'member_expression') continue;
+          const obj = getChildByField(arg, 'object');
+          const prop = getChildByField(arg, 'property');
+          if (obj?.type === 'this' && prop) {
+            this.unresolvedReferences.push({
+              fromNodeId: callerId,
+              referenceName: getNodeText(prop, this.source),
+              referenceKind: 'calls',
+              line: arg.startPosition.row + 1,
+              column: arg.startPosition.column,
+            });
+          }
+        }
+      };
+
+      // Shape 1: arkui_component_expression with property/arguments pairs.
+      if (node.type === 'arkui_component_expression') {
+        const componentField = getChildByField(node, 'function');
+        if (componentField && componentField.type === 'identifier') {
+          this.unresolvedReferences.push({
+            fromNodeId: callerId,
+            referenceName: getNodeText(componentField, this.source),
+            referenceKind: 'calls',
+            line: node.startPosition.row + 1,
+            column: node.startPosition.column,
+          });
+        }
+        for (let i = 0; i < node.childCount; i++) {
+          const child = node.child(i);
+          if (!child || child.type !== 'property_identifier') continue;
+          emitAttr(child);
+          if (/^on[A-Z]/.test(getNodeText(child, this.source))) {
+            // The attribute's arguments node is the next `arguments`-typed
+            // child before the following attribute name.
+            let args: SyntaxNode | null = null;
+            for (let k = i + 1; k < node.childCount; k++) {
+              const next = node.child(k);
+              if (!next) continue;
+              if (next.type === 'property_identifier') break;
+              if (next.type === 'arguments') {
+                args = next;
+                break;
+              }
+            }
+            emitThisHandlers(args);
+          }
+        }
+        return;
+      }
+
+      // Shape 2: fluent chain on a call result —
+      // call_expression(function: member_expression(object: <call>)), or the
+      // grammar's DSL-specific arkui_dsl_decorator_member_expression (same
+      // object/property fields; produced e.g. by `Column() { … }.alignItems(x)`
+      // in some chain positions — it ONLY occurs in attribute chains).
+      if (node.type === 'call_expression') {
+        const fn = getChildByField(node, 'function');
+        if (fn?.type === 'member_expression' || fn?.type === 'arkui_dsl_decorator_member_expression') {
+          const obj = getChildByField(fn, 'object');
+          const prop = getChildByField(fn, 'property');
+          if (
+            prop &&
+            (fn.type === 'arkui_dsl_decorator_member_expression' ||
+              obj?.type === 'call_expression' ||
+              obj?.type === 'arkui_component_expression')
+          ) {
+            emitAttr(prop);
+            if (/^on[A-Z]/.test(getNodeText(prop, this.source))) {
+              emitThisHandlers(getChildByField(node, 'arguments'));
+            }
+            return;
+          }
+        }
+        // The INNERMOST call of a proper-form detached chain
+        // (`.alignItems(x).layoutWeight(1)…` under a leading_dot_expression)
+        // has a BARE IDENTIFIER function — the leading dot was consumed by
+        // the wrapper, so it masquerades as a plain `alignItems(...)` call.
+        // Walk up the member/call alternation; topping out at
+        // leading_dot_expression means the dot belongs to this chain.
+        if (fn?.type === 'identifier') {
+          let p: SyntaxNode | null = node.parent;
+          while (p && (p.type === 'member_expression' || p.type === 'call_expression')) {
+            p = p.parent;
+          }
+          if (p?.type === 'leading_dot_expression') {
+            emitAttr(fn);
+            if (/^on[A-Z]/.test(getNodeText(fn, this.source))) {
+              emitThisHandlers(getChildByField(node, 'arguments'));
+            }
+            return;
+          }
+        }
+        // Not a chained attribute — fall through to the generic call paths.
+      }
+
+      // Shape 3: detached chain segment — leading_dot_expression whose only
+      // named child is a bare identifier; its arguments sit in the NEXT
+      // sibling statement as a parenthesized_expression.
+      if (node.type === 'leading_dot_expression') {
+        const only = node.namedChildCount === 1 ? node.namedChild(0) : null;
+        if (only && only.type === 'identifier') {
+          emitAttr(only);
+          if (/^on[A-Z]/.test(getNodeText(only, this.source))) {
+            const stmt = node.parent; // expression_statement
+            const nextStmt = stmt?.nextNamedSibling;
+            const paren = nextStmt?.namedChild(0);
+            if (paren?.type === 'parenthesized_expression') {
+              emitThisHandlers(paren);
+            }
+          }
+        }
+        // The proper form (child is a call_expression chain, as inside
+        // `@Extend` bodies) needs nothing here — the walker descends into it
+        // and the inner call_expressions take the paths above.
+        return;
+      }
+    }
+
     // Get the function/method being called
     let calleeName = '';
 
@@ -5442,7 +5615,7 @@ export class TreeSitterExtractor {
    * Languages that support type annotations (TypeScript, etc.)
    */
   private readonly TYPE_ANNOTATION_LANGUAGES = new Set([
-    'typescript', 'tsx', 'dart', 'kotlin', 'swift', 'rust', 'go', 'java', 'csharp', 'scala', 'php',
+    'typescript', 'tsx', 'arkts', 'dart', 'kotlin', 'swift', 'rust', 'go', 'java', 'csharp', 'scala', 'php',
   ]);
 
   /**

BIN
src/extraction/wasm/tree-sitter-arkts.wasm


+ 52 - 0
src/index.ts

@@ -437,6 +437,11 @@ export class CodeGraph {
       }
       try {
         const before = this.queries.getNodeAndEdgeCount();
+        // Mark the index as in-flight BEFORE any writes: a run killed
+        // mid-index (OOM, SIGKILL, the #850 liveness watchdog) leaves this
+        // marker behind, so `codegraph status` can tell a truncated index
+        // from a completed one instead of silently serving partial results.
+        try { this.queries.setMetadata('index_state', 'indexing'); } catch { /* metadata is advisory */ }
         // Segment vocabulary starts empty and is repopulated by the node write
         // path as every file (re-)indexes below — so a full index is also the
         // orphan-cleanup pass for names deleted since the last one.
@@ -513,6 +518,37 @@ export class CodeGraph {
           } catch { /* metadata is advisory — never fail an index over it */ }
         }
 
+        // Reconcile the scan's ground truth against what the pipeline
+        // accounted for. A shortfall means files were silently dropped
+        // (observed in the wild: a run under heavy load came up 37 files
+        // short with no error) — record it and tell the user, don't let the
+        // index pass as complete.
+        try {
+          if (!result.success) {
+            this.queries.setMetadata('index_state', 'failed');
+          } else {
+            const accounted = result.filesIndexed + result.filesSkipped + result.filesErrored;
+            const discovered = result.filesDiscovered;
+            const shortfall = discovered !== undefined ? discovered - accounted : 0;
+            if (discovered !== undefined && shortfall > 0) {
+              this.queries.setMetadata('index_state', 'partial');
+              this.queries.setMetadata('index_files_discovered', String(discovered));
+              this.queries.setMetadata('index_files_accounted', String(accounted));
+              result.errors.push({
+                message: `Index is missing ${shortfall} of ${discovered} discovered files (indexed ${result.filesIndexed}, skipped ${result.filesSkipped}, errored ${result.filesErrored}). The index is PARTIAL — re-run \`codegraph index\`.`,
+                severity: 'warning',
+                code: 'index_partial',
+              });
+            } else {
+              this.queries.setMetadata('index_state', 'complete');
+              if (discovered !== undefined) {
+                this.queries.setMetadata('index_files_discovered', String(discovered));
+                this.queries.setMetadata('index_files_accounted', String(accounted));
+              }
+            }
+          }
+        } catch { /* metadata is advisory — never fail an index over it */ }
+
         return result;
       } finally {
         this.fileLock.release();
@@ -761,6 +797,22 @@ export class CodeGraph {
     return this.queries.getLastIndexedAt();
   }
 
+  /**
+   * Completeness of the last full index run. `'complete'` is the only good
+   * state. `'indexing'` after the fact means a run was killed mid-index (OOM,
+   * SIGKILL, liveness watchdog) and the on-disk index is truncated;
+   * `'partial'` means the run finished but silently dropped files
+   * (discovered > indexed+skipped+errored); `'failed'` means it reported
+   * failure. `null` = index predates this marker. Surfaced by
+   * `codegraph status`.
+   */
+  getIndexState(): 'indexing' | 'complete' | 'partial' | 'failed' | null {
+    const raw = this.queries.getMetadata('index_state');
+    return raw === 'indexing' || raw === 'complete' || raw === 'partial' || raw === 'failed'
+      ? raw
+      : null;
+  }
+
   /**
    * Which engine built the current index: the package version + extraction
    * version stamped at the last full `indexAll`. Either field is null for an

+ 2 - 1
src/mcp/dynamic-boundaries.ts

@@ -65,7 +65,7 @@ interface FormSpec {
   keyWindow?: number;
 }
 
-const JS_FAMILY = new Set(['typescript', 'javascript', 'tsx', 'jsx', 'vue', 'svelte', 'astro']);
+const JS_FAMILY = new Set(['typescript', 'javascript', 'tsx', 'jsx', 'vue', 'svelte', 'astro', 'arkts']);
 const PY = new Set(['python']);
 const RB = new Set(['ruby']);
 const PHP = new Set(['php']);
@@ -201,6 +201,7 @@ function commentLang(language: string): CommentLang | null {
     case 'vue':
     case 'svelte':
     case 'astro':
+    case 'arkts':
       return 'typescript';
     case 'java':
     case 'kotlin':

+ 270 - 0
src/resolution/callback-synthesizer.ts

@@ -417,6 +417,269 @@ function flutterBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[
   return edges;
 }
 
+/**
+ * Reactive ArkUI property decorators: assigning a property carrying one of
+ * these re-runs the owning struct's `build()`. Covers both state models —
+ * V1 (`@Component`: State/Prop/Link/Provide/Consume/Storage*) and V2
+ * (`@ComponentV2`: Local/Provider/Consumer; `@Param` is read-only in V2 so
+ * the assignment gate never fires on it, and `@Trace` lives on `@ObservedV2`
+ * data classes, not struct properties).
+ */
+const ARKUI_REACTIVE_DECORATORS = new Set([
+  'State', 'Prop', 'Link', 'Provide', 'Consume', 'StorageLink', 'StorageProp',
+  'LocalStorageLink', 'LocalStorageProp', 'ObjectLink',
+  'Local', 'Provider', 'Consumer',
+]);
+
+/** ArkUI-observed array mutators — `this.todos.push(x)` re-renders like an assignment. */
+const ARKUI_ARRAY_MUTATORS = 'push|pop|shift|unshift|splice|sort|reverse|fill';
+
+/**
+ * Phase 4b-ets: ArkUI state → build (the ArkTS analog of react-render /
+ * flutter-build). Assigning a reactive-decorated property (`@State count`,
+ * `@Link selected`, …) re-runs the `@Component struct`'s `build()`, but that
+ * hop is framework-internal — no static edge — so "onClick → markAllDone →
+ * this.todos = […] → rebuilt list" dead-ends at the assignment. Bridge it:
+ * for each arkts struct with a `build()` method and at least one reactive
+ * property, link every sibling method whose body ASSIGNS (or array-mutates)
+ * one of those properties → `build`. Assignment-gated on the struct's OWN
+ * reactive property names — a method that merely reads state, or a struct
+ * with no reactive properties, gets nothing (this is the precision line the
+ * all-sibling-methods design would erase).
+ */
+function arkuiStateBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const struct of queries.getNodesByKind('struct')) {
+    if (struct.language !== 'arkts') continue;
+    const children = queries.getOutgoingEdges(struct.id, ['contains'])
+      .map((e) => queries.getNodeById(e.target))
+      .filter((n): n is Node => !!n);
+    const build = children.find((n) => n.kind === 'method' && n.name === 'build');
+    if (!build) continue;
+    const reactiveProps = children.filter(
+      (n) => n.kind === 'property' && (n.decorators ?? []).some((d) => ARKUI_REACTIVE_DECORATORS.has(d))
+    );
+    if (reactiveProps.length === 0) continue;
+    const propAlternation = reactiveProps
+      .map((p) => p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+      .join('|');
+    // `this.count = …` / `+=` / `++` / `--` / `this.todos.push(…)`. The
+    // `=(?!=)` keeps `this.done == x` comparisons out.
+    const mutationRe = new RegExp(
+      `this\\.(?:${propAlternation})\\s*(?:=(?!=)|\\+\\+|--|[+\\-*/%&|^]=|\\.(?:${ARKUI_ARRAY_MUTATORS})\\s*\\()`
+    );
+    let added = 0;
+    for (const m of children) {
+      if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+      if (m.kind !== 'method' || m.id === build.id) continue;
+      const content = ctx.readFile(m.filePath);
+      const src = content && sliceLines(content, m.startLine, m.endLine);
+      if (!src || !mutationRe.test(stripCommentsForRegex(src, 'typescript'))) continue;
+      const key = `${m.id}>${build.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: m.id, target: build.id, kind: 'calls', line: m.startLine,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'arkui-state', via: 'state assignment', registeredAt: `${build.filePath}:${build.startLine}` },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
+/** Emit/subscribe call sites of HarmonyOS's `@ohos.events.emitter` bus. */
+const ARKUI_EMITTER_CALL_RE = /\bemitter\s*\.\s*(emit|on|once)\s*\(\s*([A-Za-z_$][\w$.]*|\{[^)]{0,120}?\beventId\s*:\s*[^,}]+[^)]*?\})/g;
+
+/** Cap per event bucket — a generic key with many parties is dynamic routing, not a static pair. */
+const ARKUI_EMITTER_FANOUT_CAP = 8;
+
+/**
+ * Phase 4b-ets2: HarmonyOS `@ohos.events.emitter` bridge. The cross-component
+ * bus — `emitter.emit(eventId)` fires `emitter.on(eventId, cb)` — is
+ * framework-internal, so an order flow riding it (OrangeShopping's
+ * add-to-cart) dead-ends at the emit. Link emit-site enclosing
+ * function/method → on/once-site enclosing function/method when both
+ * reference the SAME statically-recoverable event key.
+ *
+ * Key recovery, per call site (comment-stripped enclosing-file source): the
+ * first argument is an `{ eventId: K }` literal, a `Names.Dotted` constant, or
+ * a local whose same-file declaration is `new EventsId(K)` / `= K` — chase one
+ * level. Precision scoping learned from the samples monorepo (thousands of
+ * unrelated samples, most using eventId 1): NUMERIC keys pair within the same
+ * FILE only; NAMED keys pair within the same workspace module directory (or
+ * the whole project when it declares no modules — the single-app case), both
+ * behind a fan-out cap. Inline `on(id, (e) => {…})` arrows need no special
+ * handling — their bodies' calls already attribute to the registering method,
+ * so targeting that method keeps the chain connected.
+ */
+function arkuiEmitterEdges(ctx: ResolutionContext): Edge[] {
+  interface Site { nodeId: string; file: string; line: number }
+  // bucket key -> emit sites / handler sites
+  const emits = new Map<string, Site[]>();
+  const handlers = new Map<string, Site[]>();
+
+  const moduleDirs = (() => {
+    const ws = ctx.getWorkspacePackages?.();
+    return ws ? [...new Set(ws.byName.values())].sort((a, b) => b.length - a.length) : [];
+  })();
+  const moduleScopeOf = (file: string): string => {
+    for (const dir of moduleDirs) {
+      if (file === dir || file.startsWith(dir + '/')) return dir;
+    }
+    return '';
+  };
+
+  for (const file of ctx.getAllFiles()) {
+    if (!file.endsWith('.ets')) continue;
+    const content = ctx.readFile(file);
+    if (!content || !content.includes('emitter.')) continue;
+    const safe = stripCommentsForRegex(content, 'typescript');
+    const nodes = ctx.getNodesInFile(file)
+      .filter((n) => n.kind === 'method' || n.kind === 'function');
+
+    ARKUI_EMITTER_CALL_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = ARKUI_EMITTER_CALL_RE.exec(safe))) {
+      const verb = m[1]!;
+      const arg = m[2]!.trim();
+      const line = safe.slice(0, m.index).split('\n').length;
+      const encl = nodes
+        .filter((n) => n.startLine <= line && n.endLine >= line)
+        .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
+      if (!encl) continue;
+
+      // Recover the event key from the first argument.
+      let key: string | null = null;
+      const idLit = arg.startsWith('{') ? arg.match(/\beventId\s*:\s*([\w$.]+)/)?.[1] : undefined;
+      const token = idLit ?? arg;
+      if (token !== undefined) {
+        if (/^\d+$/.test(token)) {
+          key = `num:${file}:${token}`; // numeric: same-file only
+        } else if (token.includes('.')) {
+          key = `name:${moduleScopeOf(file)}:${token}`;
+        } else {
+          // Local variable — chase its same-file declaration one level:
+          // `let x = new EventsId(K)` / `const x = K`.
+          const declRe = new RegExp(
+            `\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b\\s*(?::[^=\\n]+)?=\\s*(?:new\\s+[\\w$.]+\\(\\s*([^)\\n]+?)\\s*\\)|([\\w$.]+))`
+          );
+          const decl = safe.match(declRe);
+          const inner = (decl?.[1] ?? decl?.[2])?.trim();
+          if (inner && /^\d+$/.test(inner)) key = `num:${file}:${inner}`;
+          else if (inner && /^[\w$.]+$/.test(inner)) key = `name:${moduleScopeOf(file)}:${inner}`;
+        }
+      }
+      if (!key) continue;
+
+      const site: Site = { nodeId: encl.id, file, line };
+      if (verb === 'emit') {
+        (emits.get(key) ?? emits.set(key, []).get(key)!).push(site);
+      } else {
+        (handlers.get(key) ?? handlers.set(key, []).get(key)!).push(site);
+      }
+    }
+  }
+
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const [key, emitSites] of emits) {
+    const handlerSites = handlers.get(key);
+    if (!handlerSites) continue;
+    if (emitSites.length > ARKUI_EMITTER_FANOUT_CAP || handlerSites.length > ARKUI_EMITTER_FANOUT_CAP) continue;
+    const eventLabel = key.slice(key.lastIndexOf(':') + 1);
+    for (const e of emitSites) for (const h of handlerSites) {
+      if (e.nodeId === h.nodeId) continue;
+      const dedupe = `${e.nodeId}>${h.nodeId}`;
+      if (seen.has(dedupe)) continue;
+      seen.add(dedupe);
+      edges.push({
+        source: e.nodeId, target: h.nodeId, kind: 'calls', line: e.line,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'arkui-emitter', event: eventLabel, registeredAt: `${h.file}:${h.line}` },
+      });
+    }
+  }
+  return edges;
+}
+
+/** `router.pushUrl({ url: 'pages/Detail' })` / replaceUrl — literal urls only. */
+const ARKUI_ROUTER_RE = /\brouter\s*\.\s*(?:pushUrl|replaceUrl)\s*\(\s*\{[^)]{0,200}?\burl\s*:\s*['"]([\w\-./]+)['"]/g;
+
+/**
+ * Phase 4b-ets3: HarmonyOS page navigation. `router.pushUrl({ url:
+ * 'pages/Detail' })` reaches the `@Entry struct` of
+ * `<module>/src/main/ets/pages/Detail.ets`, but the hop is a string — no
+ * static edge — so "tap → openDetail → ???" ends at the router call. Bridge
+ * literal urls to the page struct: the url resolves against the standard
+ * `src/main/ets/` layout (what main_pages.json entries name); candidates
+ * prefer the caller's own workspace module (routes are module-scoped), and
+ * anything still ambiguous is dropped rather than guessed. Only `@Entry`
+ * structs qualify as targets — the decorator is what makes a file a page.
+ */
+function arkuiRouterEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+
+  const allFiles = ctx.getAllFiles();
+  const moduleDirs = (() => {
+    const ws = ctx.getWorkspacePackages?.();
+    return ws ? [...new Set(ws.byName.values())].sort((a, b) => b.length - a.length) : [];
+  })();
+  const moduleScopeOf = (file: string): string => {
+    for (const dir of moduleDirs) {
+      if (file === dir || file.startsWith(dir + '/')) return dir;
+    }
+    return '';
+  };
+
+  for (const file of allFiles) {
+    if (!file.endsWith('.ets')) continue;
+    const content = ctx.readFile(file);
+    if (!content || !content.includes('router.')) continue;
+    const safe = stripCommentsForRegex(content, 'typescript');
+    const nodes = ctx.getNodesInFile(file)
+      .filter((n) => n.kind === 'method' || n.kind === 'function');
+
+    ARKUI_ROUTER_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = ARKUI_ROUTER_RE.exec(safe))) {
+      const url = m[1]!;
+      const line = safe.slice(0, m.index).split('\n').length;
+      const encl = nodes
+        .filter((n) => n.startLine <= line && n.endLine >= line)
+        .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
+      if (!encl) continue;
+
+      const suffix = `/src/main/ets/${url}.ets`;
+      let candidates = allFiles.filter((f) => f.endsWith(suffix));
+      if (candidates.length > 1) {
+        const scope = moduleScopeOf(file);
+        const sameModule = candidates.filter((f) => moduleScopeOf(f) === scope);
+        if (sameModule.length > 0) candidates = sameModule;
+      }
+      if (candidates.length !== 1) continue; // ambiguous or unresolved — never guess
+
+      const page = ctx.getNodesInFile(candidates[0]!).find(
+        (n) => n.kind === 'struct' && (n.decorators ?? []).includes('Entry')
+      );
+      if (!page) continue;
+
+      const key = `${encl.id}>${page.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: encl.id, target: page.id, kind: 'calls', line,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'arkui-route', event: url, registeredAt: `${candidates[0]}:${page.startLine}` },
+      });
+    }
+  }
+  return edges;
+}
+
 /**
  * Phase 4c: C++ virtual override. A call through a base/interface pointer
  * (`db->Get(...)`, `iter->Next()`) dispatches at runtime to a subclass override,
@@ -485,6 +748,7 @@ function cppOverrideEdges(queries: QueryBuilder): Edge[] {
 // or an `object` (Scala) so the loop also iterates those kinds.
 const IFACE_OVERRIDE_LANGS = new Set([
   'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', 'go', 'rust',
+  'arkts',
 ]);
 /**
  * Go implicit interface satisfaction (#584). Go has no `implements` keyword — a
@@ -2887,6 +3151,9 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
   const svelteKitEdges = svelteKitLoadEdges(ctx); await yieldToLoop();
   const pascalEdges = pascalFormEdges(ctx); await yieldToLoop();
   const flutterEdges = flutterBuildEdges(queries, ctx); await yieldToLoop();
+  const arkuiStateEdges = arkuiStateBuildEdges(queries, ctx); await yieldToLoop();
+  const arkuiEmitter = arkuiEmitterEdges(ctx); await yieldToLoop();
+  const arkuiRoutes = arkuiRouterEdges(ctx); await yieldToLoop();
   const cppEdges = cppOverrideEdges(queries); await yieldToLoop();
   const ifaceEdges = interfaceOverrideEdges(queries); await yieldToLoop();
   const kotlinExpectActual = kotlinExpectActualEdges(queries); await yieldToLoop();
@@ -2923,6 +3190,9 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
     ...svelteKitEdges,
     ...pascalEdges,
     ...flutterEdges,
+    ...arkuiStateEdges,
+    ...arkuiEmitter,
+    ...arkuiRoutes,
     ...cppEdges,
     ...ifaceEdges,
     ...kotlinExpectActual,

+ 11 - 4
src/resolution/import-resolver.ts

@@ -16,6 +16,11 @@ import { resolveWorkspaceImport } from './workspace-packages';
  */
 const EXTENSION_RESOLUTION: Record<string, string[]> = {
   typescript: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js'],
+  // ArkTS imports both `.ets` components and plain `.ts` logic modules —
+  // HarmonyOS projects are always a mix. `/Index.ets` (capital I) is ohpm's
+  // module-entry convention, hit when a bare workspace import ("data") is
+  // rewritten to the member's directory; lowercase variants for safety.
+  arkts: ['.ets', '.ts', '.d.ts', '.js', '/Index.ets', '/index.ets', '/index.ts', '/index.js'],
   javascript: ['.js', '.jsx', '.mjs', '.cjs', '/index.js', '/index.jsx'],
   tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js'],
   jsx: ['.jsx', '.js', '/index.jsx', '/index.js'],
@@ -200,7 +205,7 @@ function isExternalImport(
   }
 
   // Common external patterns
-  if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
+  if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx' || language === 'arkts') {
     // Node built-ins
     if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) {
       return true;
@@ -649,7 +654,7 @@ export function extractImportMappings(
 ): ImportMapping[] {
   const mappings: ImportMapping[] = [];
 
-  if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
+  if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx' || language === 'arkts') {
     mappings.push(...extractJSImports(content));
   } else if (language === 'svelte' || language === 'vue' || language === 'astro') {
     // Svelte/Vue single-file components import via plain ES6 inside their
@@ -1061,7 +1066,8 @@ export function extractReExports(content: string, language: Language): ReExport[
     language !== 'typescript' &&
     language !== 'javascript' &&
     language !== 'tsx' &&
-    language !== 'jsx'
+    language !== 'jsx' &&
+    language !== 'arkts'
   ) {
     return [];
   }
@@ -1355,7 +1361,8 @@ export function resolveViaImport(
     ref.language === 'typescript' ||
     ref.language === 'tsx' ||
     ref.language === 'javascript' ||
-    ref.language === 'jsx'
+    ref.language === 'jsx' ||
+    ref.language === 'arkts'
   ) {
     const moduleFile = resolveModuleImportToFile(ref, imports, context);
     if (moduleFile) return moduleFile;

+ 18 - 3
src/resolution/index.ts

@@ -543,7 +543,7 @@ export class ReferenceResolver {
         // `.ts` index barrel and silently break the chain (#629). Re-key
         // the parse on the barrel's extension so the chase works no matter
         // what kind of file imports through it.
-        const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?)$/i.test(filePath);
+        const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?|ets)$/i.test(filePath);
         const reExports = extractReExports(content, isJsFamily ? 'typescript' : language);
         this.reExportCache.set(filePath, reExports);
         return reExports;
@@ -744,8 +744,15 @@ export class ReferenceResolver {
     // from './barrel'` where the barrel has `export { signIn as login }
     // from './auth'`) intentionally call a name that has no
     // declaration anywhere — only the renamed upstream symbol does.
+    // ArkTS chained-attribute refs carry a leading dot (`.titleStyle`) that
+    // routes them to the decorator-gated matcher; the symbol itself is
+    // indexed under the bare name, so the existence check strips the dot.
+    const existenceName =
+      ref.language === 'arkts' && ref.referenceName.startsWith('.')
+        ? ref.referenceName.slice(1)
+        : ref.referenceName;
     if (
-      !this.hasAnyPossibleMatch(ref.referenceName) &&
+      !this.hasAnyPossibleMatch(existenceName) &&
       !this.matchesAnyImport(ref) &&
       !this.frameworks.some((f) => f.claimsReference?.(ref.referenceName))
     ) {
@@ -1169,13 +1176,21 @@ export class ReferenceResolver {
   private isBuiltInOrExternal(ref: UnresolvedRef): boolean {
     const name = ref.referenceName;
     const isJsTs = ref.language === 'typescript' || ref.language === 'javascript'
-      || ref.language === 'tsx' || ref.language === 'jsx';
+      || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'arkts';
 
     // JavaScript/TypeScript built-ins
     if (isJsTs && JS_BUILT_INS.has(name)) {
       return true;
     }
 
+    // ArkTS resource-reference intrinsics — `$r('app.string.x')` /
+    // `$rawfile('x.png')` are framework-provided and appear dozens of times
+    // per UI file; without this they can resolve to a stray same-named
+    // symbol (e.g. a checked-in hvigor wrapper's `$r`).
+    if (ref.language === 'arkts' && (name === '$r' || name === '$rawfile')) {
+      return true;
+    }
+
     // Common JS/TS library calls (console.log, Math.floor, JSON.parse)
     if (isJsTs && (name.startsWith('console.') || name.startsWith('Math.') || name.startsWith('JSON.'))) {
       return true;

+ 39 - 1
src/resolution/name-matcher.ts

@@ -140,7 +140,9 @@ function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node {
 const LANGUAGE_FAMILY: Record<string, string> = {
   java: 'jvm', kotlin: 'jvm', scala: 'jvm',
   swift: 'apple', objc: 'apple',
-  typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web',
+  // ArkTS is a TS superset — every HarmonyOS project mixes `.ets` UI with
+  // `.ts` logic modules, so refs must cross freely between them.
+  typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', arkts: 'web',
   c: 'c', cpp: 'c',
   // Razor/Blazor markup names C# types — same family so `@model Foo` /
   // `<MyComponent/>` resolve to their `.cs` class through the cross-family gate.
@@ -226,6 +228,7 @@ export function matchFunctionRef(
   const bareFnOnly =
     ref.language === 'typescript' || ref.language === 'tsx' ||
     ref.language === 'javascript' || ref.language === 'jsx' ||
+    ref.language === 'arkts' ||
     ref.language === 'cpp' || ref.language === 'python' ||
     ref.language === 'php';
 
@@ -1079,6 +1082,7 @@ function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
     case 'javascript':
     case 'tsx':
     case 'jsx':
+    case 'arkts':
       return [
         new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger()
         // No keyword requirement, so this matches BOTH a local annotation
@@ -1742,6 +1746,9 @@ export function matchFuzzy(
 /**
  * Match all strategies in order of confidence
  */
+/** ArkUI attribute-helper decorators a `.attr(...)` chain may resolve to. */
+const ARKUI_ATTRIBUTE_DECORATORS = new Set(['Extend', 'Styles', 'AnimatableExtend', 'Builder']);
+
 export function matchReference(
   ref: UnresolvedRef,
   context: ResolutionContext
@@ -1753,6 +1760,37 @@ export function matchReference(
     return matchFunctionRef(ref, context);
   }
 
+  // ArkTS chained UI attributes — emitted with a leading dot (`.titleStyle`,
+  // `.width`) by the extractor — resolve ONLY to decorator-marked attribute
+  // helpers: `@Extend`/`@Styles`/`@AnimatableExtend` functions (and global
+  // `@Builder`s used attribute-position). Framework attributes (`.width`,
+  // `.fontSize` — on nearly every UI line) match no such helper and stay
+  // unresolved, NEVER falling through to bare-name matching: on a samples
+  // monorepo that fallthrough manufactured 36k wrong edges, giving single
+  // same-named properties thousands of false callers. Ambiguity rule matches
+  // the rest of the file: several same-named helpers → prefer the call-site
+  // file, still ambiguous → drop the ref rather than guess.
+  if (ref.language === 'arkts' && ref.referenceName.startsWith('.')) {
+    const base = ref.referenceName.slice(1);
+    const candidates = context
+      .getNodesByName(base)
+      .filter(
+        (n) =>
+          n.language === 'arkts' &&
+          n.kind === 'function' &&
+          (n.decorators ?? []).some((d) => ARKUI_ATTRIBUTE_DECORATORS.has(d))
+      );
+    const chosen =
+      candidates.length > 1 ? preferCallSiteFile(candidates, ref.filePath) : candidates;
+    if (chosen.length !== 1) return null;
+    return {
+      original: ref,
+      targetNodeId: chosen[0]!.id,
+      confidence: 0.85,
+      resolvedBy: 'exact-match',
+    };
+  }
+
   // Erlang `-behaviour(m)` refs target a MODULE. Letting them fall through to
   // bare-name matching grabs any same-named symbol — on emqx,
   // `-behaviour(supervisor)` resolved to a `-define(supervisor, …)` macro

+ 148 - 4
src/resolution/workspace-packages.ts

@@ -31,6 +31,16 @@ import { logDebug } from '../errors';
 export interface WorkspacePackages {
   /** Member package `name` → directory relative to projectRoot (posix). */
   byName: Map<string, string>;
+  /**
+   * Member package `name` → its declared ENTRY FILE relative to projectRoot
+   * (posix), when the member's manifest names one (ohpm's oh-package.json5
+   * `"main": "Index.ets"`). Lets a bare `import { X } from "data"` resolve to
+   * the member's real barrel even when it doesn't follow an index-file
+   * convention — and independent of the CONSUMER's language (a `.ts` file
+   * importing an `.ets` barrel resolves without `.ets` in the TS candidate
+   * list). Absent for npm/pnpm members (their index conventions cover it).
+   */
+  entryByName?: Map<string, string>;
 }
 
 /**
@@ -43,10 +53,9 @@ export interface WorkspacePackages {
  * the same way it does {@link loadProjectAliases} / {@link loadGoModule}.
  */
 export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | null {
-  const patterns = readWorkspaceGlobs(projectRoot);
-  if (patterns.length === 0) return null;
-
   const byName = new Map<string, string>();
+
+  const patterns = readWorkspaceGlobs(projectRoot);
   for (const pattern of patterns) {
     for (const dir of expandWorkspaceGlob(projectRoot, pattern)) {
       const pkgName = readPackageName(path.join(projectRoot, dir));
@@ -54,10 +63,138 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages |
       if (pkgName && !byName.has(pkgName)) byName.set(pkgName, dir);
     }
   }
+
+  // HarmonyOS/OpenHarmony (ArkTS) modular projects: every module's
+  // oh-package.json5 declares its local siblings as `"data": "file:../../
+  // core/data"` dependencies, and code then imports the bare name
+  // (`import { CartRepository } from "data"`). Same monorepo problem as npm
+  // workspaces, different manifest.
+  const entryByName = new Map<string, string>();
+  for (const [name, dir] of collectOhpmFileDeps(projectRoot)) {
+    if (byName.has(name)) continue;
+    byName.set(name, dir);
+    const entry = readOhpmMain(projectRoot, dir);
+    if (entry) entryByName.set(name, entry);
+  }
+
   if (byName.size === 0) return null;
 
   logDebug('workspace packages loaded', { count: byName.size });
-  return { byName };
+  return { byName, entryByName: entryByName.size > 0 ? entryByName : undefined };
+}
+
+/**
+ * Read an ohpm member's declared entry file: `<dir>/oh-package.json5`'s
+ * `main`, normalized to a projectRoot-relative posix path. Null when the
+ * manifest or field is missing/escaping.
+ */
+function readOhpmMain(projectRoot: string, dirRel: string): string | null {
+  let parsed: unknown;
+  try {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    parsed = require('jsonc-parser').parse(
+      fs.readFileSync(path.join(projectRoot, dirRel, OHPM_MANIFEST), 'utf-8')
+    );
+  } catch {
+    return null;
+  }
+  const main = (parsed as { main?: unknown } | null)?.main;
+  if (typeof main !== 'string' || !main.trim()) return null;
+  const entryAbs = path.resolve(projectRoot, dirRel, main.trim());
+  const entryRel = path.relative(projectRoot, entryAbs).replace(/\\/g, '/');
+  if (entryRel.startsWith('..')) return null;
+  return entryRel;
+}
+
+/**
+ * Scan the project for `oh-package.json5` manifests and collect their
+ * `file:`-protocol dependencies as workspace members: dep name (what the
+ * source imports) → target directory (projectRoot-relative posix).
+ *
+ * Precision rule: a name declared with DIFFERENT target directories in
+ * different manifests (e.g. every sample in a samples monorepo has its own
+ * "common") is AMBIGUOUS and dropped entirely — a missing edge beats a wrong
+ * cross-module link. Registry dependencies (`@ohos/axios: "^2.0.0"`) don't
+ * use `file:` and are ignored, staying external.
+ *
+ * The walk is bounded (depth + directory budget) and prunes build/dependency
+ * dirs, so non-ArkTS projects pay one readdir at the root and nothing else
+ * (they have no oh-package.json5 anywhere shallow).
+ */
+const OHPM_MANIFEST = 'oh-package.json5';
+const OHPM_WALK_MAX_DEPTH = 6;
+const OHPM_WALK_DIR_BUDGET = 8000;
+const OHPM_SKIP_DIRS = new Set([
+  'node_modules', 'oh_modules', '.git', '.codegraph', '.hvigor', '.preview',
+  'build', 'dist', 'out', 'oh-package-lock.json5',
+]);
+
+function collectOhpmFileDeps(projectRoot: string): Map<string, string> {
+  const byName = new Map<string, string>();
+  const ambiguous = new Set<string>();
+
+  const queue: Array<{ rel: string; depth: number }> = [{ rel: '', depth: 0 }];
+  let visited = 0;
+  while (queue.length > 0) {
+    const { rel, depth } = queue.shift()!;
+    if (++visited > OHPM_WALK_DIR_BUDGET) break;
+    const abs = path.join(projectRoot, rel);
+
+    let entries: fs.Dirent[];
+    try {
+      entries = fs.readdirSync(abs, { withFileTypes: true });
+    } catch {
+      continue;
+    }
+
+    for (const e of entries) {
+      if (e.isDirectory()) {
+        if (depth >= OHPM_WALK_MAX_DEPTH) continue;
+        if (e.name.startsWith('.') || OHPM_SKIP_DIRS.has(e.name)) continue;
+        queue.push({ rel: rel ? `${rel}/${e.name}` : e.name, depth: depth + 1 });
+        continue;
+      }
+      if (e.name !== OHPM_MANIFEST) continue;
+
+      const deps = readOhpmFileDeps(path.join(abs, e.name));
+      for (const [name, target] of deps) {
+        const targetAbs = path.resolve(abs, target);
+        const targetRel = path.relative(projectRoot, targetAbs).replace(/\\/g, '/');
+        if (targetRel.startsWith('..')) continue; // escapes the project
+        const existing = byName.get(name);
+        if (existing === undefined) {
+          if (!ambiguous.has(name)) byName.set(name, targetRel);
+        } else if (existing !== targetRel) {
+          byName.delete(name);
+          ambiguous.add(name);
+        }
+      }
+    }
+  }
+
+  return byName;
+}
+
+/** Parse one oh-package.json5's dependencies → [name, file-target] pairs. */
+function readOhpmFileDeps(manifestAbs: string): Array<[string, string]> {
+  const out: Array<[string, string]> = [];
+  let parsed: unknown;
+  try {
+    // JSON5 tolerates comments and trailing commas; jsonc-parser (already a
+    // dependency, used by the opencode installer target) handles both.
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    parsed = require('jsonc-parser').parse(fs.readFileSync(manifestAbs, 'utf-8'));
+  } catch {
+    return out;
+  }
+  const deps = (parsed as { dependencies?: Record<string, unknown> } | null)?.dependencies;
+  if (!deps || typeof deps !== 'object') return out;
+  for (const [name, value] of Object.entries(deps)) {
+    if (typeof value !== 'string' || !value.startsWith('file:')) continue;
+    const target = value.slice('file:'.length).trim();
+    if (target) out.push([name, target]);
+  }
+  return out;
 }
 
 /**
@@ -82,6 +219,13 @@ export function resolveWorkspaceImport(
   if (!bestName) return null;
   const dir = ws.byName.get(bestName)!;
   const subpath = importPath.slice(bestName.length); // '' or '/widgets'
+  // A bare member import resolves straight to the member's declared entry
+  // file when the manifest names one (ohpm `main`) — the caller's exact-path
+  // check hits it without extension/index guessing.
+  if (!subpath) {
+    const entry = ws.entryByName?.get(bestName);
+    if (entry) return entry;
+  }
   return (dir + subpath).replace(/\/{2,}/g, '/');
 }
 

+ 1 - 0
src/types.ts

@@ -68,6 +68,7 @@ export const LANGUAGES = [
   'javascript',
   'tsx',
   'jsx',
+  'arkts',
   'python',
   'go',
   'rust',

Vissa filer visades inte eftersom för många filer har ändrats