diff --git a/__tests__/arkui-framework.test.ts b/__tests__/arkui-framework.test.ts
new file mode 100644
index 00000000..415fa37f
--- /dev/null
+++ b/__tests__/arkui-framework.test.ts
@@ -0,0 +1,1489 @@
+/**
+ * ArkUI Framework Resolver Tests
+ */
+import { beforeAll, describe, it, expect } from 'vitest';
+import { arkuiResolver } from '../src/resolution/frameworks/arkui';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+describe('arkuiResolver.extract', () => {
+  it('extracts @Entry-decorated struct as arkui_page node', () => {
+    const src = `
+@Entry
+@Component
+struct IndexPage {
+  @State message: string = 'Hello';
+  build() {
+    Column() {
+      Text(this.message)
+    }
+  }
+}
+`;
+    const { nodes, references } = arkuiResolver.extract!('pages/Index.ets', src);
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0].kind).toBe('arkui_page');
+    expect(nodes[0].name).toBe('IndexPage');
+    expect(nodes[0].language).toBe('arkts');
+    expect(nodes[0].id).toContain('arkui_page:');
+    expect(nodes[0].qualifiedName).toContain('IndexPage');
+    expect(references).toHaveLength(1);
+    expect(references[0].referenceName).toBe('IndexPage');
+    expect(references[0].referenceKind).toBe('references');
+  });
+
+  it('extracts @Entry struct without extra decorators', () => {
+    const src = `
+@Entry
+struct SimplePage {
+  build() {
+    Text('simple');
+  }
+}
+`;
+    const { nodes } = arkuiResolver.extract!('pages/SimplePage.ets', src);
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0].kind).toBe('arkui_page');
+    expect(nodes[0].name).toBe('SimplePage');
+  });
+
+  it('extracts multiple @Entry structs from one file', () => {
+    const src = `
+@Entry
+@Component
+struct PageA {
+  build() { Text('A'); }
+}
+
+@Entry
+struct PageB {
+  build() { Text('B'); }
+}
+`;
+    const { nodes } = arkuiResolver.extract!('pages/Multi.ets', src);
+    expect(nodes).toHaveLength(2);
+    expect(nodes.map((n) => n.name).sort()).toEqual(['PageA', 'PageB']);
+    expect(nodes.every((n) => n.kind === 'arkui_page')).toBe(true);
+  });
+
+  it('extracts @Component struct (without @Entry) as component node', () => {
+    const src = `
+@Component
+struct NotAPage {
+  build() {
+    Text('not a page');
+  }
+}
+`;
+    const { nodes, references } = arkuiResolver.extract!('pages/NotEntry.ets', src);
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0].kind).toBe('component');
+    expect(nodes[0].name).toBe('NotAPage');
+    expect(nodes[0].decorators).toEqual(['Component']);
+    expect(references).toHaveLength(1);
+    expect(references[0].referenceName).toBe('NotAPage');
+    expect(references[0].referenceKind).toBe('references');
+  });
+
+  it('extracts router.pushUrl as unresolved reference', () => {
+    const src = `
+@Entry
+struct HomePage {
+  build() {
+    Button('Go Detail')
+      .onClick(() => {
+        router.pushUrl({ url: 'pages/Detail' })
+      })
+  }
+}
+`;
+    const { nodes, references } = arkuiResolver.extract!('pages/HomePage.ets', src);
+    expect(nodes).toHaveLength(1);
+    const navRefs = references.filter(
+      (r) => r.referenceKind === 'references' && r.referenceName !== 'HomePage'
+    );
+    expect(navRefs).toHaveLength(1);
+    expect(navRefs[0].referenceName).toBe('pages/Detail');
+    expect(navRefs[0].fromNodeId).toBe(nodes[0].id);
+  });
+
+  it('extracts router.replaceUrl as unresolved reference', () => {
+    const src = `
+@Entry
+struct LoginPage {
+  build() {
+    Button('Login')
+      .onClick(() => {
+        router.replaceUrl({ url: 'pages/Home' })
+      })
+  }
+}
+`;
+    const { references } = arkuiResolver.extract!('pages/Login.ets', src);
+    const navRefs = references.filter((r) => r.referenceName !== 'LoginPage');
+    expect(navRefs).toHaveLength(1);
+    expect(navRefs[0].referenceName).toBe('pages/Home');
+  });
+
+  it('attributes pushUrl to nearest preceding @Entry struct', () => {
+    const src = `
+@Entry
+struct PageOne {
+  build() { Text('one'); }
+}
+
+router.pushUrl({ url: 'pages/PageTwo' })
+
+@Entry
+struct PageTwo {
+  build() { Text('two'); }
+}
+`;
+    const { nodes, references } = arkuiResolver.extract!('pages/RouteTest.ets', src);
+    expect(nodes).toHaveLength(2);
+    const navRef = references.find((r) => r.referenceName === 'pages/PageTwo');
+    expect(navRef).toBeDefined();
+    expect(navRef!.fromNodeId).toBe(nodes.find((n) => n.name === 'PageOne')!.id);
+  });
+
+  it('falls back to file-level id when no @Entry precedes pushUrl', () => {
+    const src = `
+router.pushUrl({ url: 'pages/Standalone' })
+`;
+    const { references } = arkuiResolver.extract!('pages/NopageRef.ets', src);
+    const navRef = references.find((r) => r.referenceName === 'pages/Standalone');
+    expect(navRef).toBeDefined();
+    expect(navRef!.fromNodeId).toMatch(/^file:/);
+  });
+
+  it('returns empty for non-.ets files', () => {
+    const { nodes, references } = arkuiResolver.extract!('test.ts', '');
+    expect(nodes).toEqual([]);
+    expect(references).toEqual([]);
+  });
+
+  it('skips // line-commented @Entry', () => {
+    const src = `
+// @Entry
+// struct FakePage {}
+@Entry
+struct RealPage {
+  build() { Text('real'); }
+}
+`;
+    const { nodes } = arkuiResolver.extract!('pages/Commented.ets', src);
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0].name).toBe('RealPage');
+  });
+
+  it('skips /* block-commented */ @Entry', () => {
+    const src = `
+/*
+@Entry
+struct FakePage {
+  build() { Text('fake'); }
+}
+*/
+@Entry
+struct RealPage {
+  build() { Text('real'); }
+}
+`;
+    const { nodes } = arkuiResolver.extract!('pages/BlockCommented.ets', src);
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0].name).toBe('RealPage');
+  });
+
+  it('does not duplicate @Entry+@Component struct as component', () => {
+    const src = `
+@Entry
+@Component
+struct IndexPage {
+  build() {
+    Text('hello');
+  }
+}
+`;
+    const { nodes } = arkuiResolver.extract!('pages/Index.ets', src);
+    const pages = nodes.filter((n) => n.kind === 'arkui_page');
+    const components = nodes.filter((n) => n.kind === 'component');
+    expect(pages).toHaveLength(1);
+    expect(pages[0].name).toBe('IndexPage');
+    expect(components).toHaveLength(0);
+  });
+
+  it('extracts @Component-only structs alongside @Entry pages', () => {
+    const src = `
+@Entry
+@Component
+struct HomePage {
+  build() { Text('home'); }
+}
+
+@Component
+struct MyButton {
+  build() { Button('click'); }
+}
+
+@Component
+struct MyLabel {
+  build() { Text('label'); }
+}
+`;
+    const { nodes } = arkuiResolver.extract!('pages/Mixed.ets', src);
+    const pages = nodes.filter((n) => n.kind === 'arkui_page');
+    const components = nodes.filter((n) => n.kind === 'component');
+    expect(pages).toHaveLength(1);
+    expect(pages[0].name).toBe('HomePage');
+    expect(components).toHaveLength(2);
+    expect(components.map((c) => c.name).sort()).toEqual(['MyButton', 'MyLabel']);
+    components.forEach((c) => {
+      expect(c.decorators).toEqual(['Component']);
+    });
+  });
+
+  it('extracts @Entry with routeName param', () => {
+    const src = `
+@Entry({ routeName: 'main' })
+@Component
+struct MainPage {
+  build() { Text('main'); }
+}
+`;
+    const { nodes } = arkuiResolver.extract!('pages/Main.ets', src);
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0].kind).toBe('arkui_page');
+    expect(nodes[0].name).toBe('MainPage');
+  });
+
+  it('extracts @Component with freezeWhenInvisible param', () => {
+    const src = `
+@Component({ freezeWhenInvisible: true })
+struct FrozenLabel {
+  build() { Text('frozen'); }
+}
+`;
+    const { nodes, references } = arkuiResolver.extract!('pages/Frozen.ets', src);
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0].kind).toBe('component');
+    expect(nodes[0].name).toBe('FrozenLabel');
+    expect(nodes[0].decorators).toEqual(['Component']);
+    expect(references).toHaveLength(1);
+    expect(references[0].referenceName).toBe('FrozenLabel');
+  });
+});
+
+describe('arkuiResolver.postExtract', () => {
+  it('returns empty array when main_pages.json is absent', () => {
+    const context = {
+      readFile: (_path: string) => null,
+      getNodesByKind: (_kind: string) => [],
+    };
+    const result = arkuiResolver.postExtract!(context as any);
+    expect(result).toEqual([]);
+  });
+
+  it('creates arkui_page nodes from main_pages.json src entries', () => {
+    const json = JSON.stringify({ src: ['pages/Index', 'pages/Detail'] });
+    const context = {
+      readFile: (path: string) => {
+        if (path === 'entry/src/main/resources/base/profile/main_pages.json') return null;
+        if (path === 'main_pages.json') return json;
+        return null;
+      },
+      getNodesByKind: (_kind: string) => [] as any[],
+    };
+    const result = arkuiResolver.postExtract!(context as any);
+    expect(result).toHaveLength(2);
+    expect(result[0].kind).toBe('arkui_page');
+    expect(result[0].name).toBe('pages/Index');
+    expect(result[1].name).toBe('pages/Detail');
+  });
+
+  it('de-duplicates against already extracted pages (filePath match)', () => {
+    const json = JSON.stringify({ src: ['pages/Index', 'pages/Detail'] });
+    const context = {
+      readFile: (path: string) => {
+        if (path === 'entry/src/main/resources/base/profile/main_pages.json') return null;
+        if (path === 'main_pages.json') return json;
+        return null;
+      },
+      getNodesByKind: (_kind: string) => [
+        {
+          filePath: 'pages/Index.ets',
+          qualifiedName: 'pages/Index.ets::Index',
+          name: 'Index',
+        },
+      ] as any[],
+    };
+    const result = arkuiResolver.postExtract!(context as any);
+    expect(result).toHaveLength(1);
+    expect(result[0].name).toBe('pages/Detail');
+  });
+
+  it('de-duplicates against qualifiedName end-match', () => {
+    const json = JSON.stringify({ src: ['pages/Detail'] });
+    const context = {
+      readFile: (path: string) => {
+        if (path === 'entry/src/main/resources/base/profile/main_pages.json') return null;
+        if (path === 'main_pages.json') return json;
+        return null;
+      },
+      getNodesByKind: (_kind: string) => [
+        {
+          filePath: 'pages/Detail.ets',
+          qualifiedName: 'entry/src/main/ets/pages/Detail.ets::Detail',
+          name: 'Detail',
+        },
+      ] as any[],
+    };
+    const result = arkuiResolver.postExtract!(context as any);
+    expect(result).toEqual([]);
+  });
+
+  it('prefers primary config path over fallback', () => {
+    const primary = JSON.stringify({ src: ['pages/Primary'] });
+    const fallback = JSON.stringify({ src: ['pages/Fallback'] });
+    const context = {
+      readFile: (path: string) => {
+        if (path === 'entry/src/main/resources/base/profile/main_pages.json') return primary;
+        if (path === 'main_pages.json') return fallback;
+        return null;
+      },
+      getNodesByKind: (_kind: string) => [] as any[],
+    };
+    const result = arkuiResolver.postExtract!(context as any);
+    expect(result).toHaveLength(1);
+    expect(result[0].name).toBe('pages/Primary');
+  });
+});
+
+describe('arkuiResolver.resolve', () => {
+  it('resolves pages/Detail to matching arkui_page by filePath', () => {
+    const context = {
+      getNodesByKind: (_kind: string) => [
+        {
+          id: 'page1',
+          filePath: 'entry/src/main/ets/pages/Detail.ets',
+          qualifiedName: 'entry/src/main/ets/pages/Detail.ets::Detail',
+          name: 'Detail',
+        },
+      ],
+    };
+    const ref = {
+      fromNodeId: 'caller1',
+      referenceName: 'pages/Detail',
+      referenceKind: 'references' as const,
+      line: 10,
+      column: 0,
+    };
+    const result = arkuiResolver.resolve!(ref as any, context as any);
+    expect(result).not.toBeNull();
+    expect(result!.targetNodeId).toBe('page1');
+    expect(result!.confidence).toBe(0.9);
+  });
+
+  it('resolves pages/Detail to index.ets fallback', () => {
+    const context = {
+      getNodesByKind: (_kind: string) => [
+        {
+          id: 'page2',
+          filePath: 'entry/src/main/ets/pages/Detail/index.ets',
+          qualifiedName: 'entry/src/main/ets/pages/Detail/index.ets::Detail',
+          name: 'Detail',
+        },
+      ],
+    };
+    const ref = {
+      fromNodeId: 'caller2',
+      referenceName: 'pages/Detail',
+      referenceKind: 'references' as const,
+      line: 10,
+      column: 0,
+    };
+    const result = arkuiResolver.resolve!(ref as any, context as any);
+    expect(result).not.toBeNull();
+    expect(result!.targetNodeId).toBe('page2');
+    expect(result!.confidence).toBe(0.9);
+  });
+
+  it('falls back to partial path match with lower confidence', () => {
+    const context = {
+      getNodesByKind: (_kind: string) => [
+        {
+          id: 'page3',
+          filePath: 'feature/src/main/ets/custom/Detail.ets',
+          qualifiedName: 'feature/src/main/ets/custom/Detail.ets::Detail',
+          name: 'Detail',
+        },
+      ],
+    };
+    const ref = {
+      fromNodeId: 'caller3',
+      referenceName: 'pages/Detail',
+      referenceKind: 'references' as const,
+      line: 10,
+      column: 0,
+    };
+    const result = arkuiResolver.resolve!(ref as any, context as any);
+    expect(result).not.toBeNull();
+    expect(result!.targetNodeId).toBe('page3');
+    expect(result!.confidence).toBeGreaterThanOrEqual(0.65);
+    expect(result!.confidence).toBeLessThan(0.9);
+  });
+
+  it('returns null for non-page references', () => {
+    const context = { getNodesByKind: (_kind: string) => [] };
+    const ref = {
+      fromNodeId: 'caller4',
+      referenceName: 'SomeUtility',
+      referenceKind: 'references' as const,
+      line: 10,
+      column: 0,
+    };
+    const result = arkuiResolver.resolve!(ref as any, context as any);
+    expect(result).toBeNull();
+  });
+
+  it('returns null for pages/ reference with no matching nodes', () => {
+    const context = { getNodesByKind: (_kind: string) => [] };
+    const ref = {
+      fromNodeId: 'caller5',
+      referenceName: 'pages/NotFound',
+      referenceKind: 'references' as const,
+      line: 10,
+      column: 0,
+    };
+    const result = arkuiResolver.resolve!(ref as any, context as any);
+    expect(result).toBeNull();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Callback-synthesizer phase tests
+// ---------------------------------------------------------------------------
+import type { Node, Edge } from '../src/types';
+import { arkuiStateChainEdges, arkuiStateDepEdges, arkuiEventChainEdges, arkuiAstEdges } from '../src/resolution/callback-synthesizer';
+
+/** Create a minimal QueryBuilder mock. */
+function mockQueries(overrides: {
+  classes?: Node[];
+  structs?: Node[];
+  edges?: Map<string, Edge[]>;
+  nodesById?: Map<string, Node>;
+} = {}) {
+  const classes = overrides.classes ?? [];
+  const structs = overrides.structs ?? [];
+  const edges = overrides.edges ?? new Map();
+  const nodesById = overrides.nodesById ?? new Map();
+  return {
+    getNodesByKind: (kind: string) => {
+      if (kind === 'class') return classes;
+      if (kind === 'struct') return structs;
+      return [];
+    },
+    getOutgoingEdges: (nodeId: string, _kinds: string[]) => edges.get(nodeId) ?? [],
+    getNodeById: (id: string) => nodesById.get(id) ?? null,
+  } as any;
+}
+
+/** Create a minimal ResolutionContext mock. */
+function mockCtx(overrides: {
+  files?: string[];
+  fileContents?: Map<string, string>;
+  fileNodes?: Map<string, Node[]>;
+} = {}) {
+  const files = overrides.files ?? [];
+  const fileContents = overrides.fileContents ?? new Map();
+  const fileNodes = overrides.fileNodes ?? new Map();
+  return {
+    getAllFiles: () => files,
+    readFile: (path: string) => fileContents.get(path) ?? null,
+    getNodesInFile: (path: string) => fileNodes.get(path) ?? [],
+  } as any;
+}
+
+describe('arkuiStateChainEdges', () => {
+  it('links every sibling method to build() in .ets structs', () => {
+    const buildNode: Node = {
+      id: 'build-1', kind: 'method', name: 'build',
+      filePath: 'pages/Index.ets', startLine: 20, endLine: 30,
+      qualifiedName: 'pages/Index.ets::Index::build', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const onClickNode: Node = {
+      id: 'onClick-1', kind: 'method', name: 'onClick',
+      filePath: 'pages/Index.ets', startLine: 10, endLine: 18,
+      qualifiedName: 'pages/Index.ets::Index::onClick', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const structNode: Node = {
+      id: 'struct-1', kind: 'struct', name: 'Index',
+      filePath: 'pages/Index.ets', startLine: 1, endLine: 35,
+      qualifiedName: 'pages/Index.ets::Index', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const nodesById = new Map<string, Node>();
+    nodesById.set('build-1', buildNode);
+    nodesById.set('onClick-1', onClickNode);
+    const edges = new Map<string, Edge[]>();
+    edges.set('struct-1', [
+      { source: 'struct-1', target: 'build-1', kind: 'contains', line: 20 },
+      { source: 'struct-1', target: 'onClick-1', kind: 'contains', line: 10 },
+    ] as any);
+    const queries = mockQueries({
+      structs: [structNode],
+      edges,
+      nodesById,
+    });
+    const ctx = mockCtx();
+
+    const result = arkuiStateChainEdges(queries as any, ctx as any);
+    expect(result).toHaveLength(1);
+    expect(result[0].source).toBe('onClick-1');
+    expect(result[0].target).toBe('build-1');
+    expect(result[0].kind).toBe('calls');
+    expect(result[0].provenance).toBe('heuristic');
+    expect(result[0].metadata?.synthesizedBy).toBe('arkui-state-chain');
+  });
+
+  it('skips non-.ets files', () => {
+    const clsNode: Node = {
+      id: 'cls-ts', kind: 'class', name: 'Foo',
+      filePath: 'utils.ts', startLine: 0, endLine: 10,
+      qualifiedName: 'utils.ts::Foo', language: 'typescript',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const queries = mockQueries({ classes: [clsNode] });
+    const result = arkuiStateChainEdges(queries as any, mockCtx() as any);
+    expect(result).toEqual([]);
+  });
+});
+
+describe('arkuiStateDepEdges', () => {
+  it('links methods that read @State properties → property nodes', () => {
+    // Source: @State at line 5, build() body spans 5-7, struct covers 1-8.
+    const buildNode: Node = {
+      id: 'build-2', kind: 'method', name: 'build',
+      filePath: 'pages/Home.ets', startLine: 5, endLine: 7,
+      qualifiedName: 'pages/Home.ets::Home::build', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const countProp: Node = {
+      id: 'prop-count', kind: 'property', name: 'count',
+      filePath: 'pages/Home.ets', startLine: 5, endLine: 5,
+      qualifiedName: 'pages/Home.ets::Home::count', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const structNode: Node = {
+      id: 'struct-2', kind: 'struct', name: 'Home',
+      filePath: 'pages/Home.ets', startLine: 1, endLine: 8,
+      qualifiedName: 'pages/Home.ets::Home', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const nodesById = new Map<string, Node>();
+    nodesById.set('build-2', buildNode);
+    const edges = new Map<string, Edge[]>();
+    edges.set('struct-2', [
+      { source: 'struct-2', target: 'build-2', kind: 'contains', line: 5 },
+    ] as any);
+    const queries = mockQueries({ structs: [structNode], edges, nodesById });
+
+    const src = `
+@Entry
+@Component
+struct Home {
+  @State count: number = 0;
+  build() {
+    Text(this.count.toString());
+  }
+}
+`;
+    const fileContents = new Map<string, string>();
+    fileContents.set('pages/Home.ets', src);
+    const fileNodes = new Map<string, Node[]>();
+    fileNodes.set('pages/Home.ets', [structNode, buildNode, countProp]);
+    const ctx = mockCtx({
+      files: ['pages/Home.ets'],
+      fileContents,
+      fileNodes,
+    });
+
+    const result = arkuiStateDepEdges(queries as any, ctx as any);
+    expect(result.length).toBeGreaterThanOrEqual(1);
+    // build() reads this.count → edge: build → count property
+    const buildEdge = result.find((e) => e.source === 'build-2');
+    expect(buildEdge).toBeDefined();
+    expect(buildEdge!.target).toBe('prop-count');
+    expect(buildEdge!.kind).toBe('calls');
+    expect(buildEdge!.provenance).toBe('heuristic');
+    expect(buildEdge!.metadata?.synthesizedBy).toBe('arkui-state-dep');
+  });
+
+  it('does not link methods that do not reference the state property', () => {
+    // Source: @State at 5, helper at 6, build at 7, struct covers 1-7.
+    const helperNode: Node = {
+      id: 'helper-1', kind: 'method', name: 'helper',
+      filePath: 'pages/Home.ets', startLine: 6, endLine: 6,
+      qualifiedName: 'pages/Home.ets::Home::helper', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const countProp: Node = {
+      id: 'prop-count-2', kind: 'property', name: 'count',
+      filePath: 'pages/Home.ets', startLine: 5, endLine: 5,
+      qualifiedName: 'pages/Home.ets::Home::count', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const structNode: Node = {
+      id: 'struct-3', kind: 'struct', name: 'Home',
+      filePath: 'pages/Home.ets', startLine: 1, endLine: 7,
+      qualifiedName: 'pages/Home.ets::Home', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const nodesById = new Map<string, Node>();
+    nodesById.set('helper-1', helperNode);
+    const edges = new Map<string, Edge[]>();
+    edges.set('struct-3', [
+      { source: 'struct-3', target: 'helper-1', kind: 'contains', line: 6 },
+    ] as any);
+    const queries = mockQueries({ structs: [structNode], edges, nodesById });
+
+    const src = `
+@Entry
+@Component
+struct Home {
+  @State count: number = 0;
+  helper() { return 42; }
+  build() { Text('hello'); }
+}
+`;
+    const fileContents = new Map<string, string>();
+    fileContents.set('pages/Home.ets', src);
+    const fileNodes = new Map<string, Node[]>();
+    fileNodes.set('pages/Home.ets', [structNode, helperNode, countProp]);
+    const ctx = mockCtx({
+      files: ['pages/Home.ets'],
+      fileContents,
+      fileNodes,
+    });
+
+    const result = arkuiStateDepEdges(queries as any, ctx as any);
+    const helperEdge = result.find((e) => e.source === 'helper-1');
+    expect(helperEdge).toBeUndefined();
+  });
+});
+
+describe('arkuiEventChainEdges', () => {
+  it('links build() → handler for .onClick(this.handler)', () => {
+    // Source is 7 lines (1-indexed): handleClick at 3, build() body spans 4-6.
+    const buildNode: Node = {
+      id: 'build-3', kind: 'method', name: 'build',
+      filePath: 'pages/Click.ets', startLine: 4, endLine: 6,
+      qualifiedName: 'pages/Click.ets::Page::build', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const handlerNode: Node = {
+      id: 'handleClick-1', kind: 'method', name: 'handleClick',
+      filePath: 'pages/Click.ets', startLine: 3, endLine: 3,
+      qualifiedName: 'pages/Click.ets::Page::handleClick', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const src = `
+@Entry
+struct Page {
+  handleClick() { console.log('clicked'); }
+  build() {
+    Button('OK').onClick(() => { this.handleClick(); });
+  }
+}
+`;
+    const fileContents = new Map<string, string>();
+    fileContents.set('pages/Click.ets', src);
+    const fileNodes = new Map<string, Node[]>();
+    fileNodes.set('pages/Click.ets', [buildNode, handlerNode]);
+    const ctx = mockCtx({
+      files: ['pages/Click.ets'],
+      fileContents,
+      fileNodes,
+    });
+
+    const result = arkuiEventChainEdges(ctx as any);
+    expect(result).toHaveLength(1);
+    expect(result[0].source).toBe('build-3');
+    expect(result[0].target).toBe('handleClick-1');
+    expect(result[0].kind).toBe('calls');
+    expect(result[0].provenance).toBe('heuristic');
+    expect(result[0].metadata?.synthesizedBy).toBe('arkui-event-chain');
+    expect(result[0].metadata?.handler).toBe('handleClick');
+  });
+
+  it('skips refs where handler name is build', () => {
+    // Source: build() body spans lines 3-5.
+    const buildNode: Node = {
+      id: 'build-4', kind: 'method', name: 'build',
+      filePath: 'pages/Rec.ets', startLine: 3, endLine: 5,
+      qualifiedName: 'pages/Rec.ets::Page::build', language: 'arkts',
+      startColumn: 0, endColumn: 0, updatedAt: Date.now(),
+    };
+    const src = `
+@Entry
+struct Page {
+  build() {
+    Column() { this.build(); }
+  }
+}
+`;
+    const fileContents = new Map<string, string>();
+    fileContents.set('pages/Rec.ets', src);
+    const fileNodes = new Map<string, Node[]>();
+    fileNodes.set('pages/Rec.ets', [buildNode]);
+    const ctx = mockCtx({
+      files: ['pages/Rec.ets'],
+      fileContents,
+      fileNodes,
+    });
+
+    const result = arkuiEventChainEdges(ctx as any);
+    expect(result).toEqual([]);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// AST-based ArkUI edge synthesis tests (Phase C/D/E/F)
+// ---------------------------------------------------------------------------
+describe('arkuiAstEdges', () => {
+  beforeAll(async () => {
+    await initGrammars();
+    await loadAllGrammars();
+  });
+
+  // ── helpers ──
+
+  function makeNode(kind: string, name: string, id: string, startLine: number, endLine?: number): Node {
+    return {
+      id, kind, name,
+      filePath: 'test.ets',
+      startLine,
+      endLine: endLine ?? startLine,
+      qualifiedName: `test.ets::Test::${name}`,
+      language: 'arkts',
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: Date.now(),
+    };
+  }
+
+  /** Helper: create a struct/class node with explicit endLine (required for scoping). */
+  function makeStruct(name: string, id: string, startLine: number, endLine: number): Node {
+    return makeNode('struct', name, id, startLine, endLine);
+  }
+
+  /** Helper: create a method node. */
+  function makeMethod(name: string, id: string, startLine: number, endLine?: number): Node {
+    return makeNode('method', name, id, startLine, endLine ?? startLine);
+  }
+
+  /** Helper: create a property node. */
+  function makeProp(name: string, id: string, startLine: number): Node {
+    return makeNode('property', name, id, startLine);
+  }
+
+  /** Helper: create a component node. */
+  function makeComponent(name: string, id: string, startLine: number, endLine: number): Node {
+    return makeNode('component', name, id, startLine, endLine);
+  }
+
+  function runEdges(src: string, nodes: Node[]): Edge[] {
+    const fileContents = new Map<string, string>();
+    fileContents.set('test.ets', src);
+    const fileNodes = new Map<string, Node[]>();
+    fileNodes.set('test.ets', nodes);
+    const ctx = mockCtx({
+      files: ['test.ets'],
+      fileContents,
+      fileNodes,
+    });
+    return arkuiAstEdges(ctx as any);
+  }
+
+  // ── Phase D: UI tree edges (arkui-render) ──
+
+  it('emits arkui-render edge for custom component used in build()', () => {
+    const src = `
+@Component
+struct MyButton {
+  build() { Button('click'); }
+}
+
+@Entry
+@Component
+struct HomePage {
+  build() {
+    Column() {
+      MyButton()
+    }
+  }
+}
+`;
+    const nodes = [
+      makeComponent('MyButton', 'mybtn', 3, 5),
+      makeStruct('HomePage', 'home-struct', 9, 15),
+      makeMethod('build', 'home-build', 10, 14),
+    ];
+    const edges = runEdges(src, nodes);
+    const renderEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-render');
+    expect(renderEdges).toHaveLength(1);
+    expect(renderEdges[0].source).toBe('home-struct');
+    expect(renderEdges[0].target).toBe('mybtn');
+    expect(renderEdges[0].metadata?.widget).toBe('MyButton');
+  });
+
+  it('emits arkui-render edge for nested custom components', () => {
+    const src = `
+@Component
+struct InnerLabel {
+  build() { Text('inner'); }
+}
+
+@Component
+struct OuterBox {
+  build() {
+    Column() {
+      InnerLabel()
+    }
+  }
+}
+
+@Entry
+@Component
+struct Page {
+  build() {
+    OuterBox()
+  }
+}
+`;
+    const nodes = [
+      makeComponent('InnerLabel', 'inner', 3, 5),
+      makeMethod('build', 'inner-build', 4),
+      makeStruct('OuterBox', 'outer', 8, 14),
+      makeStruct('Page', 'page-struct', 18, 22),
+      makeMethod('build', 'outer-build', 9, 13),
+      makeMethod('build', 'page-build', 19, 21),
+    ];
+    const edges = runEdges(src, nodes);
+    // Page → OuterBox
+    const p2o = edges.find((e) => e.source === 'page-struct' && e.target === 'outer');
+    expect(p2o).toBeDefined();
+    expect(p2o!.metadata?.synthesizedBy).toBe('arkui-render');
+    // OuterBox → InnerLabel
+    const o2i = edges.find((e) => e.source === 'outer' && e.target === 'inner');
+    expect(o2i).toBeDefined();
+    expect(o2i!.metadata?.synthesizedBy).toBe('arkui-render');
+  });
+
+  it('walks ForEach body with forEach metadata on child edges', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  build() {
+    ForEach([1,2,3], (item: number) => {
+      Text(item.toString())
+    })
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeMethod('build', 'pg-build', 5, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    // ForEach itself should NOT create an edge; Text is built-in → no edge
+    // But we can verify ForEach doesn't crash
+    expect(edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-render')).toEqual([]);
+  });
+
+  it('walks ForEach body and finds custom components inside', () => {
+    const src = `
+@Component
+struct Card {
+  build() { Text('card'); }
+}
+
+@Entry
+@Component
+struct Page {
+  build() {
+    ForEach([1,2,3], (item: number) => {
+      Card()
+    })
+  }
+}
+`;
+    const nodes = [
+      makeComponent('Card', 'card', 3, 5),
+      makeStruct('Page', 'pg', 9, 15),
+      makeMethod('build', 'pg-build', 10, 14),
+    ];
+    const edges = runEdges(src, nodes);
+    const renderEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-render');
+    expect(renderEdges).toHaveLength(1);
+    expect(renderEdges[0].source).toBe('pg');
+    expect(renderEdges[0].target).toBe('card');
+    expect(renderEdges[0].metadata?.forEach).toBe(true);
+    expect(renderEdges[0].metadata?.widget).toBe('Card');
+  });
+
+  it('walks if/else branches with conditional metadata', () => {
+    const src = `
+@Component
+struct TrueWidget {
+  build() { Text('true'); }
+}
+
+@Component
+struct FalseWidget {
+  build() { Text('false'); }
+}
+
+@Entry
+@Component
+struct Page {
+  @State flag: boolean = true;
+  build() {
+    if (this.flag) {
+      TrueWidget()
+    } else {
+      FalseWidget()
+    }
+  }
+}
+`;
+    const nodes = [
+      makeComponent('TrueWidget', 'tw', 3, 5),
+      makeComponent('FalseWidget', 'fw', 8, 10),
+      makeStruct('Page', 'pg', 14, 23),
+      makeMethod('build', 'pg-build', 16, 22),
+    ];
+    const edges = runEdges(src, nodes);
+    const renderEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-render');
+    expect(renderEdges).toHaveLength(2);
+    // Both branches should be walked
+    const twEdge = renderEdges.find((e) => e.metadata?.widget === 'TrueWidget');
+    expect(twEdge).toBeDefined();
+    expect(twEdge!.metadata?.conditional).toBe(true);
+    const fwEdge = renderEdges.find((e) => e.metadata?.widget === 'FalseWidget');
+    expect(fwEdge).toBeDefined();
+    expect(fwEdge!.metadata?.conditional).toBe(true);
+  });
+
+  it('does not emit render edge for built-in widgets without graph nodes', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  build() {
+    Column() {
+      Row() {
+        Text('hello')
+        Button('click')
+      }
+    }
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 11),
+      makeMethod('build', 'pg-build', 5, 11),
+    ];
+    const edges = runEdges(src, nodes);
+    expect(edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-render')).toEqual([]);
+  });
+
+  // ── Phase C: Event chain edges (arkui-event-chain) ──
+
+  it('emits arkui-event-chain for .onClick(this.handler)', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  handleClick() { console.log('clicked'); }
+  build() {
+    Button('OK').onClick(this.handleClick)
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeMethod('handleClick', 'handler', 5),
+      makeMethod('build', 'pg-build', 6, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    const evtEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-event-chain');
+    expect(evtEdges).toHaveLength(1);
+    expect(evtEdges[0].source).toBe('pg-build');
+    expect(evtEdges[0].target).toBe('handler');
+    expect(evtEdges[0].metadata?.event).toBe('Click');
+    expect(evtEdges[0].metadata?.handler).toBe('handleClick');
+  });
+
+  it('emits arkui-event-chain for .onClick(() => { this.handler() })', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  handleClick() { console.log('clicked'); }
+  build() {
+    Button('OK').onClick(() => { this.handleClick(); })
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeMethod('handleClick', 'handler', 5),
+      makeMethod('build', 'pg-build', 6, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    const evtEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-event-chain');
+    expect(evtEdges).toHaveLength(1);
+    expect(evtEdges[0].source).toBe('pg-build');
+    expect(evtEdges[0].target).toBe('handler');
+    expect(evtEdges[0].metadata?.handler).toBe('handleClick');
+  });
+
+  it('emits arkui-event-chain for .onChange(this.onTextChange)', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  onTextChange(v: string) { console.log(v); }
+  build() {
+    TextInput().onChange(this.onTextChange)
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeMethod('onTextChange', 'handler', 5),
+      makeMethod('build', 'pg-build', 6, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    const evtEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-event-chain');
+    expect(evtEdges).toHaveLength(1);
+    expect(evtEdges[0].metadata?.event).toBe('Change');
+    expect(evtEdges[0].metadata?.handler).toBe('onTextChange');
+  });
+
+  it('skips event chain when handler name is build', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  build() {
+    Column() { this.build(); }
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 7),
+      makeMethod('build', 'pg-build', 5, 7),
+    ];
+    const edges = runEdges(src, nodes);
+    expect(edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-event-chain')).toEqual([]);
+  });
+
+  it('does not emit event chain for non-event method calls', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  helper() { return 42; }
+  build() {
+    Column().width(100)
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeMethod('helper', 'helper', 5),
+      makeMethod('build', 'pg-build', 6, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    expect(edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-event-chain')).toEqual([]);
+  });
+
+  // ── Phase E: State dependency edges (arkui-state-dep) ──
+
+  it('emits arkui-state-dep for method reading @State property via this.<prop>', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  @State count: number = 0;
+  increment() { this.count++; }
+  build() { Text(this.count.toString()); }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeProp('count', 'prop-count', 5),
+      makeMethod('increment', 'inc', 6),
+      makeMethod('build', 'pg-build', 7, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    const depEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-state-dep');
+    expect(depEdges).toHaveLength(1);
+    expect(depEdges[0].source).toBe('inc');
+    expect(depEdges[0].target).toBe('prop-count');
+    expect(depEdges[0].metadata?.decorator).toBe('@State');
+    expect(depEdges[0].metadata?.property).toBe('count');
+  });
+
+  it('skips build() method for state dependency edges', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  @State count: number = 0;
+  build() { Text(this.count.toString()); }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 7),
+      makeProp('count', 'prop-count', 5),
+      makeMethod('build', 'pg-build', 6, 7),
+    ];
+    const edges = runEdges(src, nodes);
+    // build() reads this.count but Phase E skips build()
+    expect(edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-state-dep')).toEqual([]);
+  });
+
+  it('detects @Prop and @Link decorators for state-dep edges', () => {
+    const src = `
+@Component
+struct Child {
+  @Prop title: string = '';
+  @Link active: boolean;
+  toggle() { this.active = !this.active; }
+  build() { Text(this.title); }
+}
+`;
+    const nodes = [
+      makeStruct('Child', 'child', 3, 8),
+      makeProp('title', 'prop-title', 4),
+      makeProp('active', 'prop-active', 5),
+      makeMethod('toggle', 'toggle', 6),
+      makeMethod('build', 'child-build', 7, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    const depEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-state-dep');
+    expect(depEdges).toHaveLength(1);
+    // toggle reads this.active → @Link
+    expect(depEdges[0].source).toBe('toggle');
+    expect(depEdges[0].target).toBe('prop-active');
+    expect(depEdges[0].metadata?.decorator).toBe('@Link');
+  });
+
+  it('detects @StorageLink and @StorageProp decorators', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  @StorageLink('theme') theme: string = 'light';
+  updateTheme() { this.theme = 'dark'; }
+  build() { Text(this.theme); }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeProp('theme', 'prop-theme', 5),
+      makeMethod('updateTheme', 'update', 6),
+      makeMethod('build', 'pg-build', 7, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    const depEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-state-dep');
+    expect(depEdges).toHaveLength(1);
+    expect(depEdges[0].source).toBe('update');
+    expect(depEdges[0].target).toBe('prop-theme');
+    expect(depEdges[0].metadata?.decorator).toBe('@StorageLink');
+  });
+
+  it('does not emit state-dep for non-state property access', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  regularField: string = 'hello';
+  helper() { this.regularField = 'world'; }
+  build() { Text('hi'); }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeProp('regularField', 'prop-reg', 5),
+      makeMethod('helper', 'helper', 6),
+      makeMethod('build', 'pg-build', 7, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    expect(edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-state-dep')).toEqual([]);
+  });
+
+  it('does not emit state-dep for method that does not access state', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  @State count: number = 0;
+  helper() { return 42; }
+  build() { Text('hello'); }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeProp('count', 'prop-count', 5),
+      makeMethod('helper', 'helper', 6),
+      makeMethod('build', 'pg-build', 7, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    // helper doesn't read this.count
+    expect(edges.filter((e) => e.source === 'helper')).toEqual([]);
+  });
+
+  // ── Phase F: Builder edges (arkui-builder) ──
+
+  it('emits arkui-builder for @Builder method called via this.xxx() in build()', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  @Builder myFooter() { Text('footer'); }
+  build() {
+    Column() {
+      this.myFooter()
+    }
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 11),
+      makeMethod('myFooter', 'footer', 5),
+      makeMethod('build', 'pg-build', 6, 10),
+    ];
+    const edges = runEdges(src, nodes);
+    const builderEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-builder');
+    expect(builderEdges).toHaveLength(1);
+    expect(builderEdges[0].source).toBe('pg-build');
+    expect(builderEdges[0].target).toBe('footer');
+    expect(builderEdges[0].metadata?.builder).toBe('myFooter');
+  });
+
+  it('does not emit builder edge for non-@Builder method called in build()', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  helper() { return Text('help'); }
+  build() {
+    Column() {
+      this.helper()
+    }
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 11),
+      makeMethod('helper', 'helper', 5),
+      makeMethod('build', 'pg-build', 6, 10),
+    ];
+    const edges = runEdges(src, nodes);
+    expect(edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-builder')).toEqual([]);
+  });
+
+  it('handles multiple @Builder methods', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  @Builder header() { Text('header'); }
+  @Builder footer() { Text('footer'); }
+  build() {
+    Column() {
+      this.header()
+      this.footer()
+    }
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 13),
+      makeMethod('header', 'hdr', 5),
+      makeMethod('footer', 'ftr', 6),
+      makeMethod('build', 'pg-build', 7, 12),
+    ];
+    const edges = runEdges(src, nodes);
+    const builderEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-builder');
+    expect(builderEdges).toHaveLength(2);
+    const names = builderEdges.map((e) => e.metadata?.builder).sort();
+    expect(names).toEqual(['footer', 'header']);
+  });
+
+  // ── Edge cases ──
+
+  it('skips files without build() method', () => {
+    const src = `
+@Component
+struct EmptyStruct {
+  helper() { return 42; }
+}
+`;
+    const nodes = [
+      makeStruct('EmptyStruct', 'es', 3, 5),
+      makeMethod('helper', 'helper', 4),
+    ];
+    const edges = runEdges(src, nodes);
+    expect(edges).toEqual([]);
+  });
+
+  it('skips non-.ets files', () => {
+    const fileContents = new Map<string, string>();
+    fileContents.set('test.ts', 'export function foo() {}');
+    const fileNodes = new Map<string, Node[]>();
+    fileNodes.set('test.ts', []);
+    const ctx = mockCtx({
+      files: ['test.ts'],
+      fileContents,
+      fileNodes,
+    });
+    const edges = arkuiAstEdges(ctx as any);
+    expect(edges).toEqual([]);
+  });
+
+  it('processes multiple structs independently in one file', () => {
+    const src = `
+@Component
+struct WidgetA {
+  build() { Text('A'); }
+}
+
+@Entry
+@Component
+struct WidgetB {
+  handleClick() { console.log('B clicked'); }
+  build() {
+    WidgetA()
+    Button('B').onClick(this.handleClick)
+  }
+}
+`;
+    const nodes = [
+      makeComponent('WidgetA', 'wa', 3, 5),
+      makeMethod('build', 'wa-build', 4, 5),
+      makeStruct('WidgetB', 'wb', 9, 15),
+      makeMethod('handleClick', 'handler', 10),
+      makeMethod('build', 'wb-build', 11, 14),
+    ];
+    const edges = runEdges(src, nodes);
+    // WidgetB → WidgetA (ui-render)
+    const renderEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-render');
+    expect(renderEdges).toHaveLength(1);
+    expect(renderEdges[0].source).toBe('wb');
+    expect(renderEdges[0].target).toBe('wa');
+    // WidgetB.build → handleClick (event-chain)
+    const evtEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-event-chain');
+    expect(evtEdges).toHaveLength(1);
+    expect(evtEdges[0].source).toBe('wb-build');
+    expect(evtEdges[0].target).toBe('handler');
+  });
+
+  it('deduplicates edges by source>target key', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  handleClick() { console.log('clicked'); }
+  build() {
+    Button('A').onClick(this.handleClick)
+    Button('B').onClick(this.handleClick)
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 9),
+      makeMethod('handleClick', 'handler', 5),
+      makeMethod('build', 'pg-build', 6, 9),
+    ];
+    const edges = runEdges(src, nodes);
+    // Two .onClick(this.handleClick) but same source>target → deduplicated
+    const evtEdges = edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-event-chain');
+    expect(evtEdges).toHaveLength(1);
+  });
+
+  it('sets provenance to heuristic and kind to calls', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  handleClick() { console.log('clicked'); }
+  build() {
+    Button('OK').onClick(this.handleClick)
+  }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeMethod('handleClick', 'handler', 5),
+      makeMethod('build', 'pg-build', 6, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    for (const e of edges) {
+      expect(e.provenance).toBe('heuristic');
+      expect(e.kind).toBe('calls');
+    }
+  });
+
+  it('skips files without recognizable UI patterns via quick-skip regex', () => {
+    const src = `
+@Component
+struct Helper {
+  add(a: number, b: number): number { return a + b; }
+}
+`;
+    const fileContents = new Map<string, string>();
+    fileContents.set('test.ets', src);
+    const fileNodes = new Map<string, Node[]>();
+    fileNodes.set('test.ets', [
+      makeStruct('Helper', 'h', 3, 5),
+      makeMethod('add', 'add', 4),
+    ]);
+    // Note: 'build' is NOT in the source, and no UI patterns
+    const ctx = mockCtx({
+      files: ['test.ets'],
+      fileContents,
+      fileNodes,
+    });
+    const edges = arkuiAstEdges(ctx as any);
+    // Quick-skip: no 'build', no Column/Row/Text/Button/etc.
+    expect(edges).toEqual([]);
+  });
+
+  it('handles @State with watch parameter', () => {
+    const src = `
+@Entry
+@Component
+struct Page {
+  @State({ watch: 'onCountChange' }) count: number = 0;
+  onCountChange() { console.log('changed'); }
+  build() { Text(this.count.toString()); }
+}
+`;
+    const nodes = [
+      makeStruct('Page', 'pg', 4, 8),
+      makeProp('count', 'prop-count', 5),
+      makeMethod('onCountChange', 'occ', 6),
+      makeMethod('build', 'pg-build', 7, 8),
+    ];
+    const edges = runEdges(src, nodes);
+    // onCountChange doesn't access this.count → no state-dep edge
+    // But we verify the @State is recognized (no crash)
+    expect(edges.filter((e) => e.metadata?.synthesizedBy === 'arkui-state-dep')).toEqual([]);
+  });
+});
diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts
index 879dbb07..29717e07 100644
--- a/src/bin/codegraph.ts
+++ b/src/bin/codegraph.ts
@@ -34,6 +34,52 @@ import { getGlyphs } from '../ui/glyphs';
 import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
 import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
 import { EXTRACTION_VERSION } from '../extraction/extraction-version';
+import type { Edge } from '../types';
+
+/**
+ * Return a compact edge annotation for heuristic/synthesized edges.
+ * Returns null for non-heuristic edges (tree-sitter direct calls).
+ */
+function synthEdgeCompact(edge: Edge): string | null {
+  if (edge.provenance !== 'heuristic') return null;
+  const m = edge.metadata as Record<string, unknown> | undefined;
+  const at = typeof m?.registeredAt === 'string' ? ` @${m.registeredAt}` : '';
+  const s = m?.synthesizedBy;
+  if (s === 'arkui-render') {
+    const widget = m?.widget ? `<${String(m.widget)}>` : 'child';
+    const extra = (m?.forEach ? ' in list' : '') + (m?.conditional ? ' cond' : '');
+    return `ArkUI render ${widget}${extra}`;
+  }
+  if (s === 'arkui-builder') {
+    return `ArkUI @Builder ${m?.builder ? String(m.builder) : ''}`;
+  }
+  if (s === 'arkui-event-chain') {
+    const ev = m?.event ? String(m.event) : 'Event';
+    return `ArkUI ${ev}`;
+  }
+  if (s === 'arkui-state-dep') {
+    const dec = m?.decorator ? String(m.decorator) : '@State';
+    return `ArkUI ${dec}`;
+  }
+  if (s === 'arkui-state-chain') {
+    const via = m?.via ? String(m.via) : 'method';
+    return `ArkUI state chain ${via}`;
+  }
+  if (s === 'jsx-render') {
+    const v = m?.via ? `<${String(m.via)}>` : 'child';
+    return `JSX render ${v}`;
+  }
+  if (s === 'react-render') return 'React re-render';
+  if (s === 'vue-handler') {
+    const ev = m?.event ? `@${String(m.event)}` : 'event';
+    return `Vue ${ev}`;
+  }
+  if (s === 'callback') return 'callback' + at;
+  if (s === 'event-emitter') return 'event-emitter' + at;
+  if (s === 'interface-impl') return 'interface->impl' + at;
+  if (s === 'closure-collection') return 'closure' + at;
+  return 'dynamic' + at;
+}
 
 // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
 async function loadCodeGraph(): Promise<typeof import('../index')> {
@@ -1224,7 +1270,7 @@ program
       }
 
       const seen = new Set<string>();
-      const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = [];
+      const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number; synthesizedBy?: string; edgeCompact?: string }> = [];
 
       for (const match of matches) {
         const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
@@ -1232,7 +1278,8 @@ program
         for (const c of cg.getCallers(match.node.id)) {
           if (!seen.has(c.node.id)) {
             seen.add(c.node.id);
-            allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
+                        const ec = synthEdgeCompact(c.edge);
+            allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, synthesizedBy: c.edge.metadata?.synthesizedBy as string | undefined, edgeCompact: ec ?? undefined });
           }
         }
       }
@@ -1242,7 +1289,8 @@ program
         for (const c of cg.getCallers(matches[0].node.id)) {
           if (!seen.has(c.node.id)) {
             seen.add(c.node.id);
-            allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
+                        const ec = synthEdgeCompact(c.edge);
+            allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, synthesizedBy: c.edge.metadata?.synthesizedBy as string | undefined, edgeCompact: ec ?? undefined });
           }
         }
       }
@@ -1250,16 +1298,22 @@ program
       const limited = allCallers.slice(0, limit);
 
       if (options.json) {
-        console.log(JSON.stringify({ symbol, callers: limited }, null, 2));
+                  const callersJson = limited.map(c => ({
+            name: c.name, kind: c.kind, filePath: c.filePath, startLine: c.startLine,
+            ...(c.synthesizedBy ? { synthesizedBy: c.synthesizedBy } : {}),
+          }));
+          console.log(JSON.stringify({ symbol, callers: callersJson }, null, 2));
       } else if (limited.length === 0) {
         info(`No callers found for "${symbol}"`);
       } else {
         console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\n`));
         for (const node of limited) {
           const loc = node.startLine ? `:${node.startLine}` : '';
+          const edgeLabel = (node as any).edgeCompact ? chalk.yellow(` [${(node as any).edgeCompact}]`) : '';
           console.log(
             chalk.cyan(node.kind.padEnd(12)) +
-            chalk.white(node.name)
+            chalk.white(node.name) +
+            edgeLabel
           );
           console.log(chalk.dim(`  ${node.filePath}${loc}`));
           console.log();
@@ -1303,7 +1357,7 @@ program
       }
 
       const seen = new Set<string>();
-      const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = [];
+      const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number; synthesizedBy?: string; edgeCompact?: string }> = [];
 
       for (const match of matches) {
         const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
@@ -1311,7 +1365,8 @@ program
         for (const c of cg.getCallees(match.node.id)) {
           if (!seen.has(c.node.id)) {
             seen.add(c.node.id);
-            allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
+            const ec = synthEdgeCompact(c.edge);
+            allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, synthesizedBy: c.edge.metadata?.synthesizedBy as string | undefined, edgeCompact: ec ?? undefined });
           }
         }
       }
@@ -1320,7 +1375,8 @@ program
         for (const c of cg.getCallees(matches[0].node.id)) {
           if (!seen.has(c.node.id)) {
             seen.add(c.node.id);
-            allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
+            const ec = synthEdgeCompact(c.edge);
+            allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine, synthesizedBy: c.edge.metadata?.synthesizedBy as string | undefined, edgeCompact: ec ?? undefined });
           }
         }
       }
@@ -1328,16 +1384,22 @@ program
       const limited = allCallees.slice(0, limit);
 
       if (options.json) {
-        console.log(JSON.stringify({ symbol, callees: limited }, null, 2));
+                  const calleesJson = limited.map(c => ({
+            name: c.name, kind: c.kind, filePath: c.filePath, startLine: c.startLine,
+            ...(c.synthesizedBy ? { synthesizedBy: c.synthesizedBy } : {}),
+          }));
+          console.log(JSON.stringify({ symbol, callees: calleesJson }, null, 2));
       } else if (limited.length === 0) {
         info(`No callees found for "${symbol}"`);
       } else {
         console.log(chalk.bold(`\nCallees of "${symbol}" (${limited.length}):\n`));
         for (const node of limited) {
           const loc = node.startLine ? `:${node.startLine}` : '';
+          const edgeLabel = (node as any).edgeCompact ? chalk.yellow(` [${(node as any).edgeCompact}]`) : '';
           console.log(
             chalk.cyan(node.kind.padEnd(12)) +
-            chalk.white(node.name)
+            chalk.white(node.name) +
+            edgeLabel
           );
           console.log(chalk.dim(`  ${node.filePath}${loc}`));
           console.log();
@@ -1591,7 +1653,7 @@ program
  */
 program
   .command('install')
-  .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
+  .description('Install codegraph MCP server into one or more agents (Chrys, Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity, Kiro)')
   .option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt')
   .option('-l, --location <where>', 'Install location: "global" or "local". Default: prompt')
   .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
@@ -1658,7 +1720,7 @@ program
  */
 program
   .command('uninstall')
-  .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
+  .description('Remove codegraph from your agents (Chrys, Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity, Kiro)')
   .option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "all". Default: all')
   .option('-l, --location <where>', 'Uninstall location: "global" or "local". Default: prompt')
   .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all')
diff --git a/src/context/index.ts b/src/context/index.ts
index 68123c28..49f482ce 100644
--- a/src/context/index.ts
+++ b/src/context/index.ts
@@ -158,7 +158,7 @@ const DEFAULT_BUILD_OPTIONS: Required<BuildContextOptions> = {
  */
 const HIGH_VALUE_NODE_KINDS: NodeKind[] = [
   'function', 'method', 'class', 'interface', 'type_alias', 'struct', 'trait',
-  'component', 'route', 'variable', 'constant', 'enum', 'module', 'namespace',
+  'component', 'route', 'arkui_page', 'variable', 'constant', 'enum', 'module', 'namespace',
 ];
 
 /**
@@ -388,6 +388,18 @@ export class ContextBuilder {
         ? `renders <${String(m.via || 'child')}>`
         : m.synthesizedBy === 'vue-handler'
         ? `Vue @${String(m.event || 'event')} handler`
+        : m.synthesizedBy === 'arkui-state-chain'
+        ? `state chain via ${m.via ? `\`${String(m.via)}\`` : 'method'}${at}`
+        : m.synthesizedBy === 'arkui-state-dep'
+        ? `reads ${m.decorator ? `\`${String(m.decorator)}\`` : '@State'} ${String(m.property || 'prop')}`
+        : m.synthesizedBy === 'arkui-event-chain'
+        ? `event ${m.event ? `\`${String(m.event)}\`` : ''} → ${m.handler ? `\`${String(m.handler)}\`` : 'handler'}${at}`
+        : m.synthesizedBy === 'arkui-render'
+        ? `renders <${String(m.widget || 'widget')}>` +
+          (m.forEach ? ' (in list)' : '') +
+          (m.conditional ? ' (conditional)' : '')
+        : m.synthesizedBy === 'arkui-builder'
+        ? `@Builder ${m.builder ? `\`${String(m.builder)}\`` : 'method'}`
         : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
       synthByPair.set(`${e.source}>${e.target}`, label);
     }
@@ -555,7 +567,7 @@ export class ContextBuilder {
           : ['file', 'module', 'class', 'struct', 'interface', 'trait', 'protocol',
              'function', 'method', 'property', 'field', 'variable', 'constant',
              'enum', 'enum_member', 'type_alias', 'namespace', 'export',
-             'route', 'component'] as NodeKind[];
+             'route', 'component', 'arkui_page'] as NodeKind[];
         for (const term of searchTerms) {
           const termResults = this.queries.searchNodes(term, {
             limit: opts.searchLimit * 2,
diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts
index 5f493740..d2b2ed86 100644
--- a/src/extraction/grammars.ts
+++ b/src/extraction/grammars.ts
@@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
   lua: 'tree-sitter-lua.wasm',
   luau: 'tree-sitter-luau.wasm',
   objc: 'tree-sitter-objc.wasm',
+  arkts: 'tree-sitter-arkts.wasm',
 };
 
 /**
@@ -49,6 +50,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
   // ESM/CJS TypeScript module extensions — parsed as TS (no JSX). (#366)
   '.mts': 'typescript',
   '.cts': 'typescript',
+  '.ets': 'arkts',
   '.js': 'javascript',
   '.mjs': 'javascript',
   '.cjs': 'javascript',
@@ -205,7 +207,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
       // `class Foo(...)` as an ERROR that swallows the whole class (#237); we
       // vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
       // primary constructors natively.
-      const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp')
+      const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'arkts')
         ? path.join(__dirname, 'wasm', wasmFile)
         : require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
       const language = await WasmLanguage.load(wasmPath);
@@ -409,6 +411,7 @@ export function getLanguageDisplayName(language: Language): string {
     lua: 'Lua',
     luau: 'Luau',
     objc: 'Objective-C',
+    arkts: 'ArkTS',
     yaml: 'YAML',
     twig: 'Twig',
     xml: 'XML',
diff --git a/src/extraction/languages/arkts.ts b/src/extraction/languages/arkts.ts
new file mode 100644
index 00000000..76ec7b6a
--- /dev/null
+++ b/src/extraction/languages/arkts.ts
@@ -0,0 +1,34 @@
+import type { LanguageExtractor } from '../tree-sitter-types';
+import { typescriptExtractor } from './typescript';
+
+/**
+ * ArkTS language extractor.
+ *
+ * ArkTS is a TypeScript superset used in HarmonyOS/ArkUI development.
+ * It extends TypeScript with:
+ *   - `struct` keyword for component definitions (@Component struct X { ... })
+ *   - Decorator-first patterns (@State, @Prop, @Link, @Builder, @Styles, etc.)
+ *   - `.ets` file extension
+ *
+ * The base TypeScript extractor handles all shared syntax. ArkTS-specific
+ * constructs (decorators, structs) are recognized through the existing
+ * decorator and struct extraction paths.
+ */
+export const arktsExtractor: LanguageExtractor = {
+  ...typescriptExtractor,
+
+  // ArkTS uses `struct` keyword for component definitions.
+  // tree-sitter-arkts grammar parses these as `struct_declaration` nodes.
+  structTypes: ['struct_declaration'],
+
+  // Override methodTypes: exclude public_field_definition — ArkUI state
+  // properties (@State count: number = 0) are field declarations, not
+  // methods.  Pure arrow-function class fields are uncommon in ArkTS;
+  // the ArkUI convention defines event handlers as proper methods.
+  methodTypes: ['method_definition'],
+
+  // Extract ArkUI state properties as 'property' nodes so the state-dep
+  // edge synthesis (Phase B + Phase E) can find and link them to handler
+  // methods that read this.<prop>.
+  propertyTypes: ['public_field_definition'],
+};
diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts
index 543598b8..49683a47 100644
--- a/src/extraction/languages/index.ts
+++ b/src/extraction/languages/index.ts
@@ -26,6 +26,7 @@ import { scalaExtractor } from './scala';
 import { luaExtractor } from './lua';
 import { luauExtractor } from './luau';
 import { objcExtractor } from './objc';
+import { arktsExtractor } from './arkts';
 
 export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   typescript: typescriptExtractor,
@@ -49,4 +50,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   lua: luaExtractor,
   luau: luauExtractor,
   objc: objcExtractor,
+  arkts: arktsExtractor,
 };
diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts
index 7fef6756..a3e60b3e 100644
--- a/src/extraction/tree-sitter.ts
+++ b/src/extraction/tree-sitter.ts
@@ -1046,6 +1046,10 @@ export class TreeSitterExtractor {
     // `record struct M(decimal Amount)` which the grammar nests here).
     this.extractCsharpPrimaryCtorParamRefs(node, structNode.id);
 
+    // Extract decorators on the struct (e.g. ArkUI @Entry, @Component).
+    // Mirrors extractClass (line ~870) which already does this for classes.
+    this.extractDecoratorsFor(node, structNode.id);
+
     // Push to stack for field extraction
     this.nodeStack.push(structNode.id);
     for (let i = 0; i < body.namedChildCount; i++) {
diff --git a/src/extraction/wasm/tree-sitter-arkts.wasm b/src/extraction/wasm/tree-sitter-arkts.wasm
new file mode 100644
index 00000000..95b6d63c
Binary files /dev/null and b/src/extraction/wasm/tree-sitter-arkts.wasm differ
diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts
index b246899c..e40a6953 100644
--- a/src/mcp/server-instructions.ts
+++ b/src/mcp/server-instructions.ts
@@ -45,7 +45,7 @@ typically one to a few calls; a grep/read exploration is dozens.
 ## Tool selection by intent
 
 - **Almost any question — "how does X work", architecture, a bug, "what/where is X", or surveying an area** → \`codegraph_explore\` (PRIMARY — call FIRST; ONE capped call returns the verbatim source of the relevant symbols grouped by file; most often the ONLY call you need)
-- **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, including dynamic-dispatch hops (callbacks, React re-render, JSX children) grep can't follow
+- **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, including dynamic-dispatch hops (callbacks, React re-render, JSX children, ArkUI render/event/builder/state chains) grep can't follow
 - **"What is the symbol named X?" (just its location)** → \`codegraph_search\`
 - **"What calls this?" / "What does this call?" / "What would changing this break?"** → \`codegraph_callers\` / \`codegraph_callees\` / \`codegraph_impact\`
 - **Reading a source FILE (any time you'd use the \`Read\` tool)** → \`codegraph_node\` with a \`file\` path and no \`symbol\`. It returns the file's **current source with line numbers — the same \`<n>\\t<line>\` shape \`Read\` gives you, safe to \`Edit\` from** — narrowable with \`offset\`/\`limit\` exactly like \`Read\`, PLUS a one-line note of which files depend on it. Same bytes as \`Read\`, faster (served from the index), with the blast radius attached. Use it **instead of \`Read\`** for indexed source files; fall back to \`Read\` only for what codegraph doesn't index (configs, docs). Pass \`symbolsOnly: true\` for just the file's structure.
@@ -60,6 +60,27 @@ typically one to a few calls; a grep/read exploration is dozens.
 - **Refactor planning**: \`codegraph_search\` → \`codegraph_callers\` → \`codegraph_impact\`. The blast-radius answer comes from impact, not from walking callers manually.
 - **Debugging a regression**: \`codegraph_callers\` of the suspected symbol; widen with \`codegraph_impact\` if an unexpected call appears.
 
+## ArkUI / HarmonyOS
+
+Codegraph synthesizes ArkUI component relationships that static analysis alone
+misses. These edges carry \`[ArkUI …]\` labels in CLI output and flow through
+\`codegraph_explore\`, \`codegraph_callers\`, \`codegraph_callees\`, and
+\`codegraph_impact\` just like direct calls:
+
+- **Component tree**: \`build()\` renders child components → \`[ArkUI render <Widget>]\`
+- **Event binding**: \`.onClick(this.handler)\` creates an edge from \`build()\` → handler
+- **State dependency**: a handler reading \`this.count\` links to the \`@State count\` property
+- **State chain**: any sibling method in the struct links to \`build()\` (potential re-render)
+- **@Builder**: \`this.myBuilder()\` in \`build()\` links to the \`@Builder\` method
+
+### ArkUI project guidance
+
+- **"What components does this struct render?"** → \`codegraph_callees\` on the struct — the \`[ArkUI render]\` edges show the component tree. One \`codegraph_explore\` with the struct + child names shows the full UI path.
+- **"What happens when a user clicks this button?"** → \`codegraph_callers\` on the handler — the \`[ArkUI Click]\` edge traces back to \`build()\`'s \`.onClick()\`.
+- **"What state drives this UI?"** → \`codegraph_callees\` on the handler → \`[ArkUI @State]\` edges show which reactive properties it reads.
+- **"What would changing this @State property break?"** → \`codegraph_impact\` on the property — it follows the state-dep edges to handlers and the state-chain to \`build()\`.
+- \`.ets\` files are indexed with the \`arkts\` grammar; structs, methods, and state properties are all first-class nodes.
+
 ## Anti-patterns
 
 - **Trust codegraph's results — don't re-verify them with grep.** They come from a full AST parse; re-checking with grep is slower, less accurate, and wastes context.
diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts
index 94fcc5dd..d77aceff 100644
--- a/src/mcp/tools.ts
+++ b/src/mcp/tools.ts
@@ -389,7 +389,7 @@ export const tools: ToolDefinition[] = [
         kind: {
           type: 'string',
           description: 'Filter by node kind',
-          enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
+          enum: ['function', 'method', 'class', 'struct', 'interface', 'type', 'variable', 'route', 'component', 'arkui_page'],
         },
         limit: {
           type: 'number',
@@ -1275,6 +1275,49 @@ export class ToolHandler {
         registeredAt,
       };
     }
+    if (m?.synthesizedBy === 'arkui-state-chain') {
+      const via = m.via ? `\`${String(m.via)}\`` : 'sibling method';
+      return {
+        label: `ArkUI state chain — ${via} triggers build() re-render (dynamic dispatch)`,
+        compact: `dynamic: ArkUI state chain via ${via}${at}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'arkui-state-dep') {
+      const decorator = m.decorator ? `\`${String(m.decorator)}\`` : '@State';
+      const prop = m.property ? String(m.property) : 'property';
+      return {
+        label: `ArkUI state dep — reads ${decorator} ${prop} (dynamic dispatch)`,
+        compact: `dynamic: ArkUI reads ${decorator} ${prop}${at}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'arkui-event-chain') {
+      const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
+      const handler = m.handler ? `\`${String(m.handler)}\`` : 'handler';
+      return {
+        label: `ArkUI event chain — .on${String(m.event || 'Event')}() → ${handler} (dynamic dispatch)`,
+        compact: `dynamic: ArkUI ${ev} → ${handler}${at}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'arkui-render') {
+      const widget = m.widget ? `<${String(m.widget)}>` : 'child widget';
+      const extra = (m.forEach ? ' in list' : '') + (m.conditional ? ' conditional' : '');
+      return {
+        label: `ArkUI render — renders ${widget}${extra} (dynamic dispatch)`,
+        compact: `dynamic: ArkUI renders ${widget}${extra}${at}`,
+        registeredAt,
+      };
+    }
+    if (m?.synthesizedBy === 'arkui-builder') {
+      const builder = m.builder ? `\`${String(m.builder)}\`` : 'method';
+      return {
+        label: `ArkUI builder — @Builder ${builder} (dynamic dispatch)`,
+        compact: `dynamic: ArkUI @Builder ${builder}${at}`,
+        registeredAt,
+      };
+    }
     return null;
   }
 
diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts
index ad3f6121..a6108e97 100644
--- a/src/resolution/callback-synthesizer.ts
+++ b/src/resolution/callback-synthesizer.ts
@@ -26,6 +26,9 @@ import type { QueryBuilder } from '../db/queries';
 import type { ResolutionContext } from './types';
 import { isGeneratedFile } from '../extraction/generated-detection';
 import { stripCommentsForRegex } from './strip-comments';
+import { Node as SyntaxNode } from 'web-tree-sitter';
+import { getNodeText, getChildByField } from '../extraction/tree-sitter-helpers';
+import { getParser } from '../extraction/grammars';
 
 const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
 const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
@@ -1646,9 +1649,671 @@ function svelteKitLoadEdges(ctx: ResolutionContext): Edge[] {
   return edges;
 }
 
+// =============================================================================
+// ArkUI (HarmonyOS) synthesis phases
+// =============================================================================
+// ArkUI structs are tree-sitter-parsed as `class` nodes in .ets files. Each
+// struct with a `build()` method is a component; inside it, `@State`/`@Prop`/
+// `@Link` properties drive re-renders, `@Builder` methods produce sub-trees,
+// and event bindings (`.onClick()`, `.onChange()`) invoke handlers.
+// Three phases close the static gap between these declaration-time concepts
+// and the runtime flow that tree-sitter can't see.
+
+/** ArkUI file extensions handled by these phases. */
+const ARKUI_EXT_RE = /\.ets$/;
+
+/** Set of known ArkUI reactive state decorator names (without @ prefix). */
+const STATE_DECORATOR_SET = new Set([
+  'State', 'Prop', 'Link', 'StorageLink', 'StorageProp', 'Provide', 'Consume',
+]);
+
+/** Regex for ArkUI reactive state decorators: @State / @Prop / @Link / @StorageLink / @Provide / @Consume. */
+const ARKUI_STATE_RE = /@(?:State|Prop|Link|StorageLink|StorageProp|Provide|Consume)\s*(?:\([^)]*\))?\s*(\w+)\s*[:=(]/g;
+
+/** Regex for ArkUI event bindings inside build(): .onClick(...), .onChange(...), etc. */
+const ARKUI_HANDLER_RE = /\.on(?:Click|Change|Appear|DisAppear|Touch|Gesture|DragStart|DragEnd|DragMove|Drop|DragEnter|DragLeave)\s*\([\s\S]*?this\.(\w+)/g;
+
+// --- Phase A: ArkUI state-chain edges ------------------------------------------
+
+/**
+ * Phase A: ArkUI state-chain edges.
+ *
+ * In ArkUI, `@State`/`@Prop` mutation triggers a re-render of `build()`.
+ * The framework-internal re-render hop is invisible to static analysis, so
+ * a flow like "onClick → this.count++ → rebuilt UI" dead-ends at the state
+ * mutation. Bridge it: for each ArkUI struct (class in .ets) with a `build()`
+ * method, link every sibling method → `build()`.
+ */
+export function arkuiStateChainEdges(queries: QueryBuilder, _ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  // ArkUI structs are tree-sitter kind 'struct'; TypeScript classes in .ets
+  // files (libraries, helpers) are kind 'class'. Query both.
+  for (const kind of ['class', 'struct'] as const) {
+  for (const cls of queries.getNodesByKind(kind)) {
+    if (!ARKUI_EXT_RE.test(cls.filePath)) continue;
+    const children = queries.getOutgoingEdges(cls.id, ['contains'])
+      .map((e) => queries.getNodeById(e.target))
+      .filter((n): n is Node => !!n && n.kind === 'method');
+    const build = children.find((n) => n.name === 'build');
+    if (!build) continue;
+    let added = 0;
+    for (const m of children) {
+      if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+      if (m.id === build.id) 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-chain', via: m.name, registeredAt: `${build.filePath}:${build.startLine}` },
+      });
+      added++;
+    }
+  }
+  }
+  return edges;
+}
+
+// --- Phase B: ArkUI state-dependency edges ------------------------------------
+
+/**
+ * Phase B: ArkUI state-dependency edges.
+ *
+ * `@State`/`@Prop`/`@Link`/`@StorageLink`/`@Provide`/`@Consume` properties
+ * are the reactive primitives that drive ArkUI re-renders. When a `@Builder`
+ * method or regular method reads `this.<stateProp>`, link the method → the
+ * state property so data-flow traces show which state each method depends on.
+ */
+export function arkuiStateDepEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+
+  // Build a map: structId/classId → methods for quick lookup.
+  const classMethods = new Map<string, { methods: Node[] }>();
+  // ArkUI structs are tree-sitter kind 'struct'; TypeScript classes in .ets
+  // files (libraries, helpers) are kind 'class'. Query both.
+  for (const kind of ['class', 'struct'] as const) {
+  for (const cls of queries.getNodesByKind(kind)) {
+    if (!ARKUI_EXT_RE.test(cls.filePath)) continue;
+    const children = queries.getOutgoingEdges(cls.id, ['contains'])
+      .map((e) => queries.getNodeById(e.target))
+      .filter((n): n is Node => !!n && n.kind === 'method');
+    if (children.length > 0) classMethods.set(cls.id, { methods: children });
+  }
+  }
+  if (classMethods.size === 0) return edges;
+
+  for (const file of ctx.getAllFiles()) {
+    if (!ARKUI_EXT_RE.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content) continue;
+
+    const classScopes = ctx.getNodesInFile(file)
+      .filter((n) => (n.kind === 'class' || n.kind === 'struct') && classMethods.has(n.id))
+      .map((c) => ({ id: c.id, start: c.startLine, end: c.endLine }));
+
+    const safe = stripCommentsForRegex(content, 'typescript');
+    ARKUI_STATE_RE.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = ARKUI_STATE_RE.exec(safe))) {
+      const propName = m[1]!;
+      const line = safe.slice(0, m.index).split('\n').length;
+
+      // Find which class scope this property belongs to.
+      let classId: string | null = null;
+      for (const scope of classScopes) {
+        if (line >= scope.start && line <= scope.end) {
+          classId = scope.id;
+          break;
+        }
+      }
+      if (!classId) continue;
+
+      // Find the property node for this @State/@Prop/@Link property.
+      const propNode = ctx.getNodesInFile(file).find(
+        (n) => n.kind === 'property' && n.name === propName &&
+          n.startLine >= line && n.startLine <= line + 2
+      );
+      if (!propNode) continue;
+
+      const cm = classMethods.get(classId);
+      if (!cm) continue;
+
+      // For each method in this struct, check if it reads this.<propName>.
+      const refRe = new RegExp(
+        `this\\.${propName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`
+      );
+      for (const method of cm.methods) {
+        const methodSrc = sliceLines(content, method.startLine, method.endLine);
+        if (!methodSrc || !refRe.test(methodSrc)) continue;
+
+        const key = `${method.id}>${propNode.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: method.id, target: propNode.id, kind: 'calls', line: method.startLine,
+          provenance: 'heuristic',
+          metadata: { synthesizedBy: 'arkui-state-dep', decorator: m[0]!.split(/\s+/)[0]!, property: propName },
+        });
+      }
+    }
+  }
+  return edges;
+}
+
+// --- Phase C: ArkUI event-chain edges -----------------------------------------
+
+/**
+ * Phase C: ArkUI event-chain edges.
+ *
+ * ArkUI `build()` bodies declare event bindings like
+ * `Button('OK').onClick(() => { this.handleOK() })`. The `handleOK` method
+ * is reachable only at runtime through the framework event system — no static
+ * call edge exists. Bridge it: for each `.ets` file, scan the body of every
+ * `build()` method for `.onXxx(this.handler)` patterns and link
+ * `build() → handlerMethod`.
+ */
+export function arkuiEventChainEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+
+  for (const file of ctx.getAllFiles()) {
+    if (!ARKUI_EXT_RE.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content || !/\.on(?:Click|Change|Appear|DisAppear|Touch|Gesture|DragStart|DragEnd|DragMove|Drop|DragEnter|DragLeave)\s*\(/.test(content)) continue;
+
+    const nodes = ctx.getNodesInFile(file);
+    const buildMethods = nodes.filter(
+      (n) => n.kind === 'method' && n.name === 'build' && ARKUI_EXT_RE.test(n.filePath)
+    );
+    if (buildMethods.length === 0) continue;
+
+    for (const build of buildMethods) {
+      const src = sliceLines(content, build.startLine, build.endLine);
+      if (!src) continue;
+
+      ARKUI_HANDLER_RE.lastIndex = 0;
+      let m: RegExpExecArray | null;
+      while ((m = ARKUI_HANDLER_RE.exec(src))) {
+        const handlerName = m[1]!;
+        if (handlerName === 'build') continue;
+
+        // Resolve handler to a method in the same file.
+        const handler = nodes.find(
+          (n) => n.kind === 'method' && n.name === handlerName
+        );
+        if (!handler || handler.id === build.id) continue;
+
+        const key = `${build.id}>${handler.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: build.id, target: handler.id, kind: 'calls', line: build.startLine,
+          provenance: 'heuristic',
+          metadata: { synthesizedBy: 'arkui-event-chain', event: m[0]!.match(/\.on(\w+)/)?.[1] ?? '', handler: handlerName },
+        });
+      }
+    }
+  }
+  return edges;
+}
+
+/**
+ * Recursively collect descendant SyntaxNodes matching any of the given types.
+ */
+function collectDescendantsOfType(node: SyntaxNode, types: string[], out: SyntaxNode[]): void {
+  for (let i = 0; i < node.namedChildCount; i++) {
+    const child = node.namedChild(i);
+    if (!child) continue;
+    if (types.includes(child.type)) out.push(child);
+    collectDescendantsOfType(child, types, out);
+  }
+}
+
+/**
+ * Extract the decorator name from a decorator node (e.g., `@State` → `"State"`).
+ */
+function getDecoratorName(decoratorNode: SyntaxNode, source: string): string | null {
+  for (let i = 0; i < decoratorNode.namedChildCount; i++) {
+    const child = decoratorNode.namedChild(i);
+    if (!child) continue;
+    if (child.type === 'identifier') {
+      return getNodeText(child, source);
+    }
+    // Decorator with arguments, e.g. @StorageLink('theme'): the identifier is
+    // nested inside a call_expression.
+    if (child.type === 'call_expression') {
+      const func = getChildByField(child, 'function');
+      if (func && func.type === 'identifier') {
+        return getNodeText(func, source);
+      }
+    }
+  }
+  return null;
+}
+
+/**
+ * Extract the handler method name from an event binding call expression.
+ * Handles both `.onClick(this.handler)` and `.onClick(() => { this.handler() })`.
+ */
+function extractEventHandlerName(callExpr: SyntaxNode, source: string): string | null {
+  const args = getChildByField(callExpr, 'arguments');
+  if (!args) return null;
+
+  for (let i = args.namedChildCount - 1; i >= 0; i--) {
+    const arg = args.namedChild(i);
+    if (!arg) continue;
+
+    if (arg.type === 'member_expression') {
+      const obj = getChildByField(arg, 'object');
+      const prop = getChildByField(arg, 'property');
+      if (obj && prop && obj.type === 'this') {
+        return getNodeText(prop, source);
+      }
+    }
+
+    if (arg.type === 'arrow_function' || arg.type === 'function_expression') {
+      const body = getChildByField(arg, 'body');
+      if (body) {
+        const memberExprs: SyntaxNode[] = [];
+        collectDescendantsOfType(body, ['member_expression'], memberExprs);
+        for (const me of memberExprs) {
+          const obj = getChildByField(me, 'object');
+          const prop = getChildByField(me, 'property');
+          if (obj && prop && obj.type === 'this') {
+            return getNodeText(prop, source);
+          }
+        }
+      }
+    }
+  }
+  return null;
+}
+
+/**
+ * Find the outermost call_expression within an expression_statement node.
+ */
+function findOutermostCallExpression(node: SyntaxNode): SyntaxNode | null {
+  if (node.type === 'call_expression') return node;
+  for (let i = 0; i < node.namedChildCount; i++) {
+    const child = node.namedChild(i);
+    if (!child) continue;
+    const found = findOutermostCallExpression(child);
+    if (found) return found;
+  }
+  return null;
+}
+
+/**
+ * Extract the widget/component name from a call expression.
+ * E.g., `MyButton()` → `"MyButton"`, `ForEach()` → `"ForEach"`.
+ */
+function extractWidgetName(callExpr: SyntaxNode, source: string): string | null {
+  const func = getChildByField(callExpr, 'function');
+  if (!func) return null;
+  if (func.type === 'identifier') return getNodeText(func, source);
+  if (func.type === 'member_expression') {
+    const obj = getChildByField(func, 'object');
+    const prop = getChildByField(func, 'property');
+    if (obj && prop && obj.type === 'identifier' && getNodeText(obj, source) === 'ForEach') {
+      return 'ForEach';
+    }
+    return extractWidgetNameFromChain(func, source);
+  }
+  return null;
+}
+
+/**
+ * Walk a chained member expression to find the widget name.
+ * For patterns like `Column().width(100)` → `"Column"`.
+ */
+function extractWidgetNameFromChain(memberExpr: SyntaxNode, source: string): string | null {
+  const obj = getChildByField(memberExpr, 'object');
+  if (obj && obj.type === 'call_expression') {
+    return extractWidgetName(obj, source);
+  }
+  return null;
+}
+
+/**
+ * Walk the build() body AST recursively, emitting arkui-render edges for
+ * parent→child widget relationships. Handles ForEach bodies and if/else branches.
+ */
+function walkBuildBodyForUiTree(
+  bodyNode: SyntaxNode,
+  structNode: Node,
+  buildNode: Node,
+  methods: Map<string, { graphNode: Node; astNode: SyntaxNode }>,
+  fileNodes: Node[],
+  source: string,
+  addEdge: (src: string, tgt: string, meta: Record<string, unknown>, line?: number) => void,
+  parentWidgetId?: string,
+  metaExtras?: Record<string, unknown>,
+): void {
+  for (let i = 0; i < bodyNode.namedChildCount; i++) {
+    const child = bodyNode.namedChild(i);
+    if (!child) continue;
+
+    if (child.type === 'expression_statement') {
+      const callExpr = findOutermostCallExpression(child);
+      if (callExpr) {
+        processWidgetCall(callExpr, structNode, buildNode, methods, fileNodes, source, addEdge, parentWidgetId, metaExtras);
+      }
+    }
+
+    if (child.type === 'if_statement') {
+      const branchMeta = { ...metaExtras, conditional: true };
+      const cons = getChildByField(child, 'consequence');
+      if (cons) walkBuildBodyForUiTree(cons, structNode, buildNode, methods, fileNodes, source, addEdge, parentWidgetId, branchMeta);
+      const alt = getChildByField(child, 'alternative');
+      if (alt) {
+        if (alt.type === 'else_clause') {
+          walkBuildBodyForUiTree(alt, structNode, buildNode, methods, fileNodes, source, addEdge, parentWidgetId, branchMeta);
+        } else {
+          walkBuildBodyForUiTree(alt, structNode, buildNode, methods, fileNodes, source, addEdge, parentWidgetId, branchMeta);
+        }
+      }
+    }
+
+    if (child.type === 'statement_block') {
+      walkBuildBodyForUiTree(child, structNode, buildNode, methods, fileNodes, source, addEdge, parentWidgetId, metaExtras);
+    }
+
+    if (child.type === 'for_each_statement' || child.type === 'for_statement') {
+      const forBody = getChildByField(child, 'body');
+      if (forBody) {
+        walkBuildBodyForUiTree(forBody, structNode, buildNode, methods, fileNodes, source, addEdge, parentWidgetId, { ...metaExtras, forEach: true });
+      }
+    }
+  }
+}
+
+/**
+ * Process a widget call expression: emit arkui-render edge and recurse into
+ * the widget's trailing statement_block for child widgets.
+ */
+function processWidgetCall(
+  callExpr: SyntaxNode,
+  structNode: Node,
+  buildNode: Node,
+  methods: Map<string, { graphNode: Node; astNode: SyntaxNode }>,
+  fileNodes: Node[],
+  source: string,
+  addEdge: (src: string, tgt: string, meta: Record<string, unknown>, line?: number) => void,
+  parentWidgetId?: string,
+  metaExtras?: Record<string, unknown>,
+): void {
+  const widgetName = extractWidgetName(callExpr, source);
+  if (!widgetName) return;
+
+  if (widgetName === 'ForEach') {
+    const args = getChildByField(callExpr, 'arguments');
+    if (args) {
+      for (let i = 0; i < args.namedChildCount; i++) {
+        const arg = args.namedChild(i);
+        if (!arg) continue;
+        if (arg.type === 'arrow_function' || arg.type === 'function_expression') {
+          const body = getChildByField(arg, 'body');
+          if (body) {
+            walkBuildBodyForUiTree(body, structNode, buildNode, methods, fileNodes, source, addEdge, parentWidgetId, { ...metaExtras, forEach: true });
+          }
+        }
+      }
+    }
+    return;
+  }
+
+  const widgetGraphNode = fileNodes.find(
+    (n) => n.name === widgetName &&
+      (n.kind === 'component' || n.kind === 'arkui_page' || n.kind === 'struct' || n.kind === 'class')
+  );
+
+  const widgetId = widgetGraphNode?.id;
+  const srcId = parentWidgetId ?? structNode.id;
+
+  if (widgetId) {
+    const meta: Record<string, unknown> = { synthesizedBy: 'arkui-render', widget: widgetName };
+    if (metaExtras) Object.assign(meta, metaExtras);
+    addEdge(srcId, widgetId, meta, buildNode.startLine);
+  }
+
+  const childParentId = widgetId ?? parentWidgetId ?? structNode.id;
+  for (let i = 0; i < callExpr.namedChildCount; i++) {
+    const child = callExpr.namedChild(i);
+    if (!child) continue;
+    if (child.type === 'statement_block') {
+      walkBuildBodyForUiTree(child, structNode, buildNode, methods, fileNodes, source, addEdge, childParentId, metaExtras);
+    }
+  }
+
+  const args = getChildByField(callExpr, 'arguments');
+  if (args) {
+    for (let i = 0; i < args.namedChildCount; i++) {
+      const arg = args.namedChild(i);
+      if (!arg) continue;
+      if (arg.type === 'statement_block') {
+        walkBuildBodyForUiTree(arg, structNode, buildNode, methods, fileNodes, source, addEdge, childParentId, metaExtras);
+      }
+    }
+  }
+}
+
+/**
+ * Phase C/D/E/F unified: ArkUI AST-based edge synthesis.
+ *
+ * Uses tree-sitter AST for precise analysis, handling nested closures,
+ * ForEach bodies, if/else branches, @Builder decorators, and state reads
+ * that regex approaches struggle with. One parse pass produces four edge types:
+ *   - arkui-render     (Phase D: UI tree — parent struct → child component)
+ *   - arkui-event-chain (Phase C: build() → event handler method)
+ *   - arkui-state-dep   (Phase E: method → @State/@Prop/@Link property)
+ *   - arkui-builder     (Phase F: build() → @Builder method)
+ */
+export function arkuiAstEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+
+  const addEdge = (source: string, target: string, meta: Record<string, unknown>, line?: number): void => {
+    const key = `${source}>${target}`;
+    if (seen.has(key)) return;
+    seen.add(key);
+    edges.push({ source, target, kind: 'calls', line, provenance: 'heuristic', metadata: meta });
+  };
+
+  for (const file of ctx.getAllFiles()) {
+    if (!ARKUI_EXT_RE.test(file)) continue;
+    const content = ctx.readFile(file);
+    if (!content) continue;
+    // Quick skip: only parse files that contain `build` and have UI patterns
+    if (!/\bbuild\b/.test(content)) continue;
+    if (!/\.on[A-Z]|Column|Row|Text|Button|Image|List|@Builder|@State|@Prop|ForEach/i.test(content)) continue;
+
+    const parser = getParser('arkts');
+    if (!parser) continue;
+    const tree = parser.parse(content);
+    if (!tree) continue;
+
+    const root = tree.rootNode;
+    const fileNodes = ctx.getNodesInFile(file);
+
+    // Find all struct/class declarations at the top level
+    const structLikeNodes: SyntaxNode[] = [];
+    for (let i = 0; i < root.namedChildCount; i++) {
+      const child = root.namedChild(i);
+      if (!child) continue;
+      if (child.type === 'struct_declaration' || child.type === 'class_declaration') {
+        structLikeNodes.push(child);
+      }
+    }
+
+    for (const structNode of structLikeNodes) {
+      const structNameNode = getChildByField(structNode, 'name');
+      if (!structNameNode) continue;
+      const structName = getNodeText(structNameNode, content);
+
+      const structGraphNode = fileNodes.find(
+        (n) => n.name === structName && (n.kind === 'class' || n.kind === 'struct')
+      );
+      if (!structGraphNode) continue;
+
+      const body = getChildByField(structNode, 'body');
+      if (!body) continue;
+
+      // ── Collect methods, @Builder methods, @State properties ──
+      const methods = new Map<string, { graphNode: Node; astNode: SyntaxNode }>();
+      const builderMethods = new Map<string, Node>();
+      const stateProps = new Map<string, { propNode: Node; decorator: string }>();
+      let buildGraphNode: Node | null = null;
+      let buildAstNode: SyntaxNode | null = null;
+
+      const structStart = structGraphNode.startLine;
+      const structEnd = structGraphNode.endLine;
+
+      let pendingBuilderDecorator = false;
+
+      for (let i = 0; i < body.namedChildCount; i++) {
+        const child = body.namedChild(i);
+        if (!child) continue;
+
+        if (child.type === 'decorator') {
+          const decName = getDecoratorName(child, content);
+          if (decName === 'Builder') pendingBuilderDecorator = true;
+          continue;
+        }
+
+        if (child.type === 'method_definition') {
+          const mn = getChildByField(child, 'name');
+          if (!mn) { pendingBuilderDecorator = false; continue; }
+          const mname = getNodeText(mn, content);
+          const graphNode = fileNodes.find(
+            (n) => n.kind === 'method' && n.name === mname &&
+              n.startLine >= structStart && n.startLine <= structEnd
+          );
+          if (!graphNode) { pendingBuilderDecorator = false; continue; }
+          methods.set(mname, { graphNode, astNode: child });
+          if (mname === 'build') { buildGraphNode = graphNode; buildAstNode = child; }
+          if (pendingBuilderDecorator) {
+            builderMethods.set(mname, graphNode);
+            pendingBuilderDecorator = false;
+          }
+          for (let j = 0; j < child.namedChildCount; j++) {
+            const dec = child.namedChild(j);
+            if (dec?.type === 'decorator') {
+              const decName = getDecoratorName(dec, content);
+              if (decName === 'Builder') builderMethods.set(mname, graphNode);
+            }
+          }
+        }
+
+        if (child.type === 'public_field_definition') {
+          const pn = getChildByField(child, 'name');
+          if (!pn) continue;
+          const pname = getNodeText(pn, content);
+          const propNode = fileNodes.find(
+            (n) => n.kind === 'property' && n.name === pname &&
+              n.startLine >= structStart && n.startLine <= structEnd
+          );
+          if (!propNode) continue;
+          for (let j = 0; j < child.namedChildCount; j++) {
+            const dec = child.namedChild(j);
+            if (dec?.type === 'decorator') {
+              const decName = getDecoratorName(dec, content);
+              if (decName && STATE_DECORATOR_SET.has(decName)) {
+                stateProps.set(pname, { propNode, decorator: decName });
+              }
+            }
+          }
+        }
+      }
+
+      if (!buildGraphNode || !buildAstNode) continue;
+
+      // ── Phase D: UI tree edges — walk build() body ──
+      const buildBodyNode = getChildByField(buildAstNode, 'body');
+      if (buildBodyNode) {
+        walkBuildBodyForUiTree(
+          buildBodyNode, structGraphNode, buildGraphNode,
+          methods, fileNodes, content, addEdge
+        );
+      }
+
+      // ── Phase C: Event chain edges — find .onXxx() in build() ──
+      if (buildBodyNode) {
+        const eventCallExprs: SyntaxNode[] = [];
+        collectDescendantsOfType(buildBodyNode, ['call_expression'], eventCallExprs);
+        for (const call of eventCallExprs) {
+          const func = getChildByField(call, 'function');
+          if (!func || func.type !== 'member_expression') continue;
+          const prop = getChildByField(func, 'property');
+          if (!prop) continue;
+          const propName = getNodeText(prop, content);
+          if (!/^on[A-Z]/.test(propName)) continue;
+          const eventName = propName.slice(2);
+          const handlerName = extractEventHandlerName(call, content);
+          if (!handlerName || handlerName === 'build') continue;
+          const handlerMethod = methods.get(handlerName);
+          if (!handlerMethod) continue;
+          addEdge(
+            buildGraphNode.id, handlerMethod.graphNode.id,
+            { synthesizedBy: 'arkui-event-chain', event: eventName, handler: handlerName },
+            buildGraphNode.startLine
+          );
+        }
+      }
+
+      // ── Phase E: State dependency edges — this.<@State prop> in each method ──
+      for (const [mname, method] of methods) {
+        if (mname === 'build') continue;
+        const methodBody = getChildByField(method.astNode, 'body');
+        if (!methodBody) continue;
+        const memberExprs: SyntaxNode[] = [];
+        collectDescendantsOfType(methodBody, ['member_expression'], memberExprs);
+        for (const me of memberExprs) {
+          const obj = getChildByField(me, 'object');
+          const prop = getChildByField(me, 'property');
+          if (!obj || !prop || obj.type !== 'this') continue;
+          const accessedProp = getNodeText(prop, content);
+          const sp = stateProps.get(accessedProp);
+          if (!sp) continue;
+          addEdge(
+            method.graphNode.id, sp.propNode.id,
+            { synthesizedBy: 'arkui-state-dep', decorator: `@${sp.decorator}`, property: accessedProp },
+            method.graphNode.startLine
+          );
+        }
+      }
+
+      // ── Phase F: Builder edges — this.<@Builder method>() in build() ──
+      if (buildBodyNode && builderMethods.size > 0) {
+        const buildCallExprs: SyntaxNode[] = [];
+        collectDescendantsOfType(buildBodyNode, ['call_expression'], buildCallExprs);
+        for (const call of buildCallExprs) {
+          const func = getChildByField(call, 'function');
+          if (!func || func.type !== 'member_expression') continue;
+          const obj = getChildByField(func, 'object');
+          const prop = getChildByField(func, 'property');
+          if (!obj || !prop || obj.type !== 'this') continue;
+          const calledMethod = getNodeText(prop, content);
+          const builder = builderMethods.get(calledMethod);
+          if (!builder) continue;
+          addEdge(
+            buildGraphNode.id, builder.id,
+            { synthesizedBy: 'arkui-builder', builder: calledMethod },
+            buildGraphNode.startLine
+          );
+        }
+      }
+    }
+  }
+
+  return edges;
+}
+
 /**
  * Synthesize dispatcher→callback edges (field observers + EventEmitters +
- * React re-render + JSX children + Vue templates + SvelteKit load + RN event
+ * React re-render + JSX children + Vue templates + SvelteKit load + ArkUI phases + RN event
  * channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain).
  * Returns the count added. Never throws into indexing — callers wrap in try/catch.
  */
@@ -1687,6 +2352,10 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const rnXPlatEdges = rnCrossPlatformEdges(queries);
   const mybatisEdges = mybatisJavaXmlEdges(queries);
   const ginEdges = ginMiddlewareChainEdges(queries, ctx);
+  const arkuiStateChainE = arkuiStateChainEdges(queries, ctx);
+  const arkuiStateE = arkuiStateDepEdges(queries, ctx);
+  const arkuiEventChainE = arkuiEventChainEdges(ctx);
+  const arkuiAstE = arkuiAstEdges(ctx);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
@@ -1710,6 +2379,10 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...rnXPlatEdges,
     ...mybatisEdges,
     ...ginEdges,
+    ...arkuiStateChainE,
+    ...arkuiStateE,
+    ...arkuiEventChainE,
+    ...arkuiAstE,
   ]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;
diff --git a/src/resolution/frameworks/arkui.ts b/src/resolution/frameworks/arkui.ts
new file mode 100644
index 00000000..4cb8665d
--- /dev/null
+++ b/src/resolution/frameworks/arkui.ts
@@ -0,0 +1,301 @@
+/**
+ * ArkUI Framework Resolver (HarmonyOS)
+ *
+ * Handles ArkUI declarative UI constructs in .ets files:
+ *   - @Entry-decorated structs → arkui_page nodes
+ *   - router.pushUrl / router.replaceUrl → unresolved references
+ *   - main_pages.json → cross-file page registration
+ *
+ * Regex-over-source approach (comment-stripped), matching the
+ * NestJS/Express pattern. ArkTS is a TypeScript superset.
+ */
+
+import { Node } from '../../types';
+import {
+  FrameworkResolver,
+  UnresolvedRef,
+  ResolvedRef,
+  ResolutionContext,
+} from '../types';
+import { stripCommentsForRegex } from '../strip-comments';
+
+export const arkuiResolver: FrameworkResolver = {
+  name: 'arkui',
+  languages: ['arkts'],
+
+  // ------------------------------------------------------------------
+  // detect
+  // ------------------------------------------------------------------
+  detect(context: ResolutionContext): boolean {
+    // build-profile.json5 is the canonical HarmonyOS/ArkUI project marker.
+    if (context.fileExists('build-profile.json5')) return true;
+
+    // Fallback: any .ets file with an @Entry decorator.
+    for (const file of context.getAllFiles()) {
+      if (!file.endsWith('.ets')) continue;
+      const content = context.readFile(file);
+      if (content && /@Entry\b/.test(content)) return true;
+    }
+    return false;
+  },
+
+  // ------------------------------------------------------------------
+  // resolve
+  // ------------------------------------------------------------------
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    // Only handle navigation references whose referenceName is a page URL
+    // (e.g. "pages/Detail", "entry/src/main/ets/pages/Detail").
+    const routePath = ref.referenceName;
+    if (
+      !routePath.startsWith('pages/') &&
+      !routePath.startsWith('entry/src/main/ets/')
+    ) {
+      return null;
+    }
+
+    // Match against arkui_page nodes.
+    let best: Node | null = null;
+    let bestScore = 0;
+
+    for (const node of context.getNodesByKind('arkui_page')) {
+      // Exact file-path match (highest confidence).
+      // Anchor with a leading '/' so `pages/Detail` matches
+      // `entry/.../pages/Detail.ets` but NOT
+      // `feature/.../custom/Detail.ets`.
+      const pathSuffix = `/${routePath}.ets`;
+      const indexPathSuffix = `/${routePath}/index.ets`;
+      if (
+        node.filePath.endsWith(pathSuffix) ||
+        node.filePath.endsWith(indexPathSuffix)
+      ) {
+        return {
+          original: ref,
+          targetNodeId: node.id,
+          confidence: 0.9,
+          resolvedBy: 'framework',
+        };
+      }
+
+      // Partial path match.
+      if (node.qualifiedName.includes(routePath)) {
+        const score = 0.7;
+        if (score > bestScore) {
+          best = node;
+          bestScore = score;
+        }
+      }
+
+      // Name-based fallback — last path segment matches page name.
+      const pageName = routePath.split('/').pop()!;
+      if (node.name === pageName) {
+        const score = 0.65;
+        if (score > bestScore) {
+          best = node;
+          bestScore = score;
+        }
+      }
+    }
+
+    if (best) {
+      return {
+        original: ref,
+        targetNodeId: best.id,
+        confidence: bestScore,
+        resolvedBy: 'framework',
+      };
+    }
+
+    return null;
+  },
+
+  // ------------------------------------------------------------------
+  // extract
+  // ------------------------------------------------------------------
+  extract(filePath, content) {
+    if (!filePath.endsWith('.ets')) return { nodes: [], references: [] };
+
+    const nodes: Node[] = [];
+    const references: UnresolvedRef[] = [];
+    const now = Date.now();
+
+    // ArkTS is a TypeScript superset — identical comment/string syntax.
+    const safe = stripCommentsForRegex(content, 'typescript');
+
+    // ── Pass 1: @Entry-decorated structs → arkui_page nodes ───────
+    //
+    // Pattern: @Entry (optionally followed by @Component / @Preview /
+    // @V2 / @Observed / @Reusable, possibly with params like
+    // @Entry({ routeName: 'main' })) then `struct Name`.
+    // (?:[^{}]|\{[^}]*\})*? skips decorator param braces but stops
+    // before the struct body's opening brace.
+    const entryRe = /@Entry\b(?:[^{}]|\{[^}]*\})*?struct\s+(\w+)/g;
+    const entryPositions: Array<{ line: number; name: string; node: Node }> = [];
+
+    let match: RegExpExecArray | null;
+    while ((match = entryRe.exec(safe)) !== null) {
+      const structName = match[1]!;
+      const line = safe.slice(0, match.index).split('\n').length;
+      const pageNode: Node = {
+        id: `arkui_page:${filePath}:${line}:${structName}`,
+        kind: 'arkui_page',
+        name: structName,
+        qualifiedName: `${filePath}::${structName}`,
+        filePath,
+        startLine: line,
+        endLine: line,
+        startColumn: 0,
+        endColumn: match[0].length,
+        language: 'arkts',
+        updatedAt: now,
+      };
+      nodes.push(pageNode);
+      entryPositions.push({ line, name: structName, node: pageNode });
+
+      // Link the arkui_page node back to its declaring struct via a
+      // references edge. Standard name-based resolution matches this
+      // to the struct node produced by tree-sitter extraction.
+      references.push({
+        fromNodeId: pageNode.id,
+        referenceName: structName,
+        referenceKind: 'references',
+        line,
+        column: 0,
+        filePath,
+        language: 'arkts',
+      });
+    }
+
+    // ── Pass 2: router.pushUrl / router.replaceUrl → references ────
+    //
+    // Pattern: router.pushUrl({ ..., url: 'pages/X', ... })
+    // Uses [\s\S]*? to span multiple lines inside the object literal.
+    const pushUrlRe =
+      /router\.(pushUrl|replaceUrl)\s*\(\s*\{[\s\S]*?url\s*:\s*['"]([^'"]+)['"]/g;
+    pushUrlRe.lastIndex = 0;
+
+    while ((match = pushUrlRe.exec(safe)) !== null) {
+      const url = match[2]!;
+      const callLine = safe.slice(0, match.index).split('\n').length;
+
+      // Attribute the navigation call to the nearest preceding @Entry
+      // struct — the page that contains this router.pushUrl call.
+      let fromNodeId = `file:${filePath}`;
+      for (let i = entryPositions.length - 1; i >= 0; i--) {
+        const entry = entryPositions[i]!;
+        if (entry.line < callLine) {
+          fromNodeId = entry.node.id;
+          break;
+        }
+      }
+
+      references.push({
+        fromNodeId,
+        referenceName: url,
+        referenceKind: 'references',
+        line: callLine,
+        column: 0,
+        filePath,
+        language: 'arkts',
+      });
+    }
+
+    // ── Pass 3: @Component-decorated structs (without @Entry) → component nodes
+    //
+    // Pattern: @Component (optionally followed by @Preview / @V2 /
+    // @Observed / @Reusable, possibly with params) then `struct Name`,
+    // excluding structs already captured as @Entry pages.
+    const entryNames = new Set(entryPositions.map((e) => e.name));
+    const componentRe = /@Component\b(?:[^{}]|\{[^}]*\})*?struct\s+(\w+)/g;
+    componentRe.lastIndex = 0;
+
+    while ((match = componentRe.exec(safe)) !== null) {
+      const structName = match[1]!;
+      if (entryNames.has(structName)) continue;
+
+      const line = safe.slice(0, match.index).split('\n').length;
+      const componentNode: Node = {
+        id: `component:${filePath}:${line}:${structName}`,
+        kind: 'component',
+        name: structName,
+        qualifiedName: `${filePath}::${structName}`,
+        filePath,
+        startLine: line,
+        endLine: line,
+        startColumn: 0,
+        endColumn: match[0].length,
+        language: 'arkts',
+        updatedAt: now,
+        decorators: ['Component'],
+      };
+      nodes.push(componentNode);
+
+      // Link the component node back to its declaring struct via a
+      // references edge — same pattern as arkui_page nodes.
+      references.push({
+        fromNodeId: componentNode.id,
+        referenceName: structName,
+        referenceKind: 'references',
+        line,
+        column: 0,
+        filePath,
+        language: 'arkts',
+      });
+    }
+
+    return { nodes, references };
+  },
+
+  // ------------------------------------------------------------------
+  // postExtract
+  // ------------------------------------------------------------------
+  postExtract(context: ResolutionContext): Node[] {
+    // main_pages.json (HarmonyOS 5.0+) lists all page entry routes.
+    // Common locations: src/main/resources/base/profile/main_pages.json
+    // or main_pages.json at project root.
+    const content =
+      context.readFile(
+        'entry/src/main/resources/base/profile/main_pages.json'
+      ) ?? context.readFile('main_pages.json');
+    if (!content) return [];
+
+    let config: { src?: string[] };
+    try {
+      config = JSON.parse(content);
+    } catch {
+      return [];
+    }
+
+    const pages: string[] = config.src ?? [];
+    if (pages.length === 0) return [];
+
+    // Only emit nodes for pages not already captured by extract().
+    const existingRoutes = context.getNodesByKind('arkui_page');
+    const now = Date.now();
+    const nodes: Node[] = [];
+
+    for (const pagePath of pages) {
+      const alreadyExists = existingRoutes.some(
+        (n) =>
+          n.filePath.endsWith(pagePath + '.ets') ||
+          n.filePath.endsWith(pagePath + '/index.ets')
+      );
+      if (alreadyExists) continue;
+
+      nodes.push({
+        id: `arkui_page:main_pages.json:0:${pagePath}`,
+        kind: 'arkui_page',
+        name: pagePath,
+        qualifiedName: `main_pages.json::${pagePath}`,
+        filePath: 'main_pages.json',
+        startLine: 0,
+        endLine: 0,
+        startColumn: 0,
+        endColumn: 0,
+        language: 'arkts',
+        updatedAt: now,
+      });
+    }
+
+    return nodes;
+  },
+};
diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts
index 88bf205e..6bd98530 100644
--- a/src/resolution/frameworks/index.ts
+++ b/src/resolution/frameworks/index.ts
@@ -25,6 +25,7 @@ import { swiftObjcBridgeResolver } from './swift-objc';
 import { reactNativeBridgeResolver } from './react-native';
 import { expoModulesResolver } from './expo-modules';
 import { fabricViewResolver } from './fabric';
+import { arkuiResolver } from './arkui';
 
 /**
  * All registered framework resolvers
@@ -66,6 +67,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   expoModulesResolver,
   // React Native Fabric / Codegen view components — TS spec → component nodes
   fabricViewResolver,
+  // ArkUI (HarmonyOS)
+  arkuiResolver,
 ];
 
 /**
@@ -140,3 +143,4 @@ export { swiftObjcBridgeResolver } from './swift-objc';
 export { reactNativeBridgeResolver } from './react-native';
 export { expoModulesResolver } from './expo-modules';
 export { fabricViewResolver } from './fabric';
+export { arkuiResolver } from './arkui';
diff --git a/src/search/query-utils.ts b/src/search/query-utils.ts
index 1a7b121f..2af5425d 100644
--- a/src/search/query-utils.ts
+++ b/src/search/query-utils.ts
@@ -397,6 +397,7 @@ export function kindBonus(kind: Node['kind']): number {
     enum: 5,
     component: 8,
     route: 9,
+    arkui_page: 9,
     module: 4,
     property: 3,
     field: 3,
diff --git a/src/types.ts b/src/types.ts
index 0ff4b7a5..2dcb1ec1 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -38,6 +38,7 @@ export const NODE_KINDS = [
   'export',
   'route',
   'component',
+  'arkui_page',
 ] as const;
 
 export type NodeKind = (typeof NODE_KINDS)[number];
@@ -89,6 +90,7 @@ export const LANGUAGES = [
   'lua',
   'luau',
   'objc',
+  'arkts',
   'yaml',
   'twig',
   'xml',
