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; + nodesById?: Map; +} = {}) { + 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; + fileNodes?: Map; +} = {}) { + 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(); + nodesById.set('build-1', buildNode); + nodesById.set('onClick-1', onClickNode); + const edges = new Map(); + 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(); + nodesById.set('build-2', buildNode); + const edges = new Map(); + 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(); + fileContents.set('pages/Home.ets', src); + const fileNodes = new Map(); + 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(); + nodesById.set('helper-1', helperNode); + const edges = new Map(); + 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(); + fileContents.set('pages/Home.ets', src); + const fileNodes = new Map(); + 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(); + fileContents.set('pages/Click.ets', src); + const fileNodes = new Map(); + 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(); + fileContents.set('pages/Rec.ets', src); + const fileNodes = new Map(); + 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(); + fileContents.set('test.ets', src); + const fileNodes = new Map(); + 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.', () => { + 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(); + fileContents.set('test.ts', 'export function foo() {}'); + const fileNodes = new Map(); + 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(); + fileContents.set('test.ets', src); + const fileNodes = new Map(); + 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 | 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 { @@ -1224,7 +1270,7 @@ program } const seen = new Set(); - 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(); - 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 ', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt') .option('-l, --location ', '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 ', 'Target agent(s): comma-separated ids, or "all". Default: all') .option('-l, --location ', '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 = { */ 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 = { 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 = { // 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. + 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> = { typescript: typescriptExtractor, @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial> = { 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 \`\\t\` 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 ]\` +- **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(); + // 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.`, 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(); + + // Build a map: structId/classId → methods for quick lookup. + const classMethods = new Map(); + // 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.. + 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(); + + 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, + fileNodes: Node[], + source: string, + addEdge: (src: string, tgt: string, meta: Record, line?: number) => void, + parentWidgetId?: string, + metaExtras?: Record, +): 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, + fileNodes: Node[], + source: string, + addEdge: (src: string, tgt: string, meta: Record, line?: number) => void, + parentWidgetId?: string, + metaExtras?: Record, +): 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 = { 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(); + + const addEdge = (source: string, target: string, meta: Record, 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(); + const builderMethods = new Map(); + const stateProps = new Map(); + 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(); @@ -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',