diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 8699a53..574cc1a 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -561,5 +561,28 @@ "files": "~1800", "question": "In the eks/cluster component, how does the cluster IAM role get created and reach the EKS cluster resource, and which outputs expose cluster identity to other components?" } + ], + "ArkTS": [ + { + "name": "HarmoneyOpenEye", + "repo": "https://github.com/WinWang/HarmoneyOpenEye", + "size": "Small", + "files": "~82", + "question": "How does the home page get its feed data from the network layer, and how does that data end up rendered as the list on screen? Trace the flow from the HTTP request through the view model into the home page UI." + }, + { + "name": "CoolMallArkTS", + "repo": "https://github.com/Joker-x-dev/CoolMallArkTS", + "size": "Medium", + "files": "~528", + "question": "When the user adds a product to the cart from the goods detail page, how does the item travel from the UI action to persistent storage? Trace the flow across the feature and core modules." + }, + { + "name": "applications_app_samples", + "repo": "https://github.com/openharmony/applications_app_samples", + "size": "Large", + "files": "~9500", + "question": "In the OrangeShopping sample app, how does the product detail page's bottom bar (add to cart / buy) lead to the order placement flow? Trace from the bottom navigation component to where the order is created." + } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2201da7..55a71c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- CodeGraph now indexes **ArkTS** (`.ets`) — the language of HarmonyOS / OpenHarmony apps. Everything TypeScript gets extracted (classes, interfaces, enums, type aliases, imports/exports, call edges), plus ArkTS's own constructs: `@Component` / `@ComponentV2` structs with their decorators (`@Entry`, `@State`, `@Prop`, `@Link`, `@Local`, `@Param`, …) captured and searchable, `build()` view trees linked parent→child so "which pages render this component" is answerable, chained attributes connected to the `@Extend`/`@Styles` functions they invoke, `@Builder` methods and functions wired into the call graph, and `.onClick(this.handler)`-style event bindings linked to their handler methods. Modular HarmonyOS projects resolve across module boundaries too: a bare `import { CartRepository } from "data"` follows the `oh-package.json5` `file:` dependency to the right module — honoring each module's declared `main` entry, from `.ets` and `.ts` consumers alike — while ambiguous names in multi-app monorepos deliberately stay unlinked rather than guessed, and mixed `.ets`/`.ts` codebases cross-link freely. Validated on real HarmonyOS apps including the official OpenHarmony samples monorepo. (#396, #512, #648, #890) +- ArkUI's dynamic hops are bridged so flow questions cross them instead of going dark, each labeled as dynamic dispatch rather than shown as a plain call: methods that assign a reactive property (`@State`, `@Local`, …) link to the component's `build()` (the re-render hop — assignment-gated, so a method that merely reads state gets no edge); `emitter.emit(eventId)` links to the matching `emitter.on/once` subscriber when both sides share a statically-recoverable event key (numeric ids pair within one file only, named constants within one module, so unrelated samples in a monorepo never cross-link); and `router.pushUrl({ url: 'pages/Detail' })` links to the target page's `@Entry` struct, with ambiguous urls left unlinked rather than guessed. +- Interrupted or incomplete indexing is now visible instead of silent: a run killed mid-index (crash, out-of-memory, watchdog) leaves a marker that `codegraph status` reports as a truncated index, a completed run that dropped files reports itself as partial — both in the human output and in `status --json` — and `codegraph index` prints a warning with the exact counts when its result doesn't add up to what the scan discovered. - CodeGraph now indexes **Terraform and OpenTofu** (`.tf`, `.tfvars`, `.tofu`) — resources, data sources, modules, variables, outputs, providers, and every `locals` attribute become symbols (e.g. `aws_s3_bucket.my_bucket`, `var.region`, `module.vpc`, `local.prefix`), and uses like `var.region`, `module.vpc.id`, `data.aws_caller_identity.current`, or `aws_s3_bucket.my.arn` are wired up cross-file, so search, callers, and impact queries return real results on infrastructure repos instead of nothing. Module calls are bridged across the module boundary: a `module` block's inputs link to the child module's variables, `module.vpc.vpc_id` reaches the child's `output "vpc_id"` definition, and the block's local `source` path links to the module's files — so "what breaks if I change this module's variable" reaches every caller instead of dead-ending at the declaration (registry and git sources are deliberately left as visible boundaries rather than guessed). Cross-component wiring through the cloudposse/atmos `remote-state` module connects too: `module.vpc.outputs.vpc_cidr` in one component reaches the `vpc` component's own output when the component name is statically declared (a literal, or a variable with a literal default) and exactly one directory matches — anything dynamic or ambiguous stays unlinked. Aliased providers are first-class: `provider "aws" { alias = "east" }` gets its own symbol, and `provider = aws.east` on a resource (or a module's `providers` map) links to that configuration, found up the module tree the way Terraform actually inherits it. `moved`/`import`/`removed` state-migration blocks and `check` assertions reference the resources they name, so a refactor's paper trail is part of the graph. `.tfvars` assignments link to the variables they set, including var-files kept in a subdirectory. Resolution follows Terraform's real per-directory scoping, so same-named variables across modules never cross-link and "what depends on `var.project_id`" in a multi-module repo never mixes in unrelated modules. Thanks @Javviviii2. (#83, #310, #648) - CodeGraph now indexes **CUDA** (`.cu`, `.cuh`) — kernels, device/host functions, structs, and classes become symbols, and the host→kernel call edge survives the `<<>>` launch syntax, so questions like "how does this call reach the GPU kernel?" trace across the CPU/GPU boundary instead of going dark at the launch site. Real-world launch styles all connect: templated launches (`my_kernel<<>>(args)`), launches through a local function pointer (`auto kernel = &my_kernel<...>; ... kernel<<>>(args)` — each branch-assigned target linked), brace-initialized launch configs (`<<>>`), and kernels defined through a name-in-first-argument macro (flash-attention's `DEFINE_FLASH_FORWARD_KERNEL(kernel_name, ...) { ... }` style), which now index under their real kernel names. CUDA that lives in plain `.h`/`.hpp` headers — where much real-world device code sits, launch-template headers included — is recognized by content and indexed the same way. Validated on llm.c, flash-attention, and NVIDIA CUTLASS. (#387, #648) - C++ symbols defined inside `namespace` blocks now carry the namespace in their qualified name (`flash::compute_attn`, C++17 `namespace a::b {` included), and namespace-qualified calls (`ns::fn(...)`) resolve to their definitions — previously such calls never linked at all, which hid much of the call graph in namespace-heavy C++ codebases from callers and impact analysis. diff --git a/README.md b/README.md index 67b2a03..e19b8a3 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -692,6 +692,7 @@ is written): |----------|-----------|--------| | TypeScript | `.ts`, `.tsx` | Full support | | JavaScript | `.js`, `.jsx`, `.mjs` | Full support | +| ArkTS (HarmonyOS) | `.ets` | Full support (everything TypeScript has, plus `@Component`/`@ComponentV2` structs with their ArkUI decorators (`@State`/`@Prop`/`@Link`/`@Local`/`@Builder`/…), `build()` view trees — parent→child component edges, chained-attribute links to `@Extend`/`@Styles` functions, `.onClick(this.handler)` event bindings — dynamic-dispatch bridges for state→`build()` re-renders, `@ohos.events.emitter` emit→subscriber pairs (static event keys only), and `router.pushUrl` literal urls → the target page struct; ohpm workspace modules resolve bare `import { X } from "data"` through `oh-package.json5` `file:` dependencies, honoring each module's `main` entry) | | Python | `.py` | Full support | | Go | `.go` | Full support | | Rust | `.rs` | Full support | diff --git a/__tests__/arkts-resolution.test.ts b/__tests__/arkts-resolution.test.ts new file mode 100644 index 0000000..b095a44 --- /dev/null +++ b/__tests__/arkts-resolution.test.ts @@ -0,0 +1,428 @@ +/** + * ArkTS end-to-end resolution tests. + * + * Pins the precision contract for build()-DSL attribute chains: a chained + * `.attr(...)` resolves ONLY to a decorator-marked attribute helper + * (`@Extend`/`@Styles`/…) — a framework attribute like `.width(...)` must + * NEVER link to an arbitrary same-named symbol elsewhere in the project + * (measured on the OpenHarmony samples monorepo, that fallthrough produced + * 36k wrong edges — single properties with thousands of false callers). + * + * Also pins the ohpm workspace bridge: a bare `import { X } from "data"` + * follows the oh-package.json5 `file:` dependency to the member module. + */ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('ArkTS attribute-chain resolution precision', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('links .titleStyle() to the @Extend helper but never .width() to a decoy symbol', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkts-')); + fs.mkdirSync(path.join(tmpDir, 'pages')); + fs.mkdirSync(path.join(tmpDir, 'decoy')); + + // A decoy: symbols named after framework attributes, in another file. + fs.writeFileSync( + path.join(tmpDir, 'decoy/Decoy.ets'), + 'export class Decoy {\n' + + ' width: number = 0;\n' + + '}\n' + + 'export function height(v: number): number {\n' + + ' return v * 2;\n' + + '}\n' + ); + + fs.writeFileSync( + path.join(tmpDir, 'pages/Home.ets'), + '@Extend(Text) function titleStyle(size: number) {\n' + + ' .fontSize(size)\n' + + '}\n' + + '\n' + + '@Component\n' + + 'struct Home {\n' + + ' build() {\n' + + ' Column() {\n' + + ' Text("hello")\n' + + ' .titleStyle(24)\n' + + ' .width(100)\n' + + ' }\n' + + ' .height(50)\n' + + ' }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const fns = cg.getNodesByKind('function'); + const titleStyle = fns.find((n) => n.name === 'titleStyle'); + expect(titleStyle).toBeDefined(); + expect(titleStyle?.decorators).toContain('Extend'); + + const structs = cg.getNodesByKind('struct'); + const home = structs.find((n) => n.name === 'Home'); + expect(home).toBeDefined(); + + // build -> titleStyle via the decorator-gated attribute strategy. + const methods = cg.getNodesByKind('method'); + const build = methods.find((n) => n.qualifiedName === 'Home::build'); + expect(build).toBeDefined(); + const buildCallees = cg.getOutgoingEdges(build!.id).map((e) => e.target); + expect(buildCallees).toContain(titleStyle!.id); + + // The decoys named after framework attributes must have NO callers. + const decoyWidth = cg + .getNodesByKind('property') + .find((n) => n.name === 'width' && n.filePath.includes('Decoy')); + expect(decoyWidth).toBeDefined(); + expect(cg.getIncomingEdges(decoyWidth!.id).filter((e) => e.kind === 'calls')).toHaveLength(0); + + const decoyHeight = fns.find((n) => n.name === 'height' && n.filePath.includes('Decoy')); + expect(decoyHeight).toBeDefined(); + expect(cg.getIncomingEdges(decoyHeight!.id).filter((e) => e.kind === 'calls')).toHaveLength(0); + }); +}); + +describe('ArkTS ohpm workspace import resolution', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('resolves a bare workspace import through oh-package.json5 file: deps', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-')); + fs.mkdirSync(path.join(tmpDir, 'core/data/src/main/ets'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'feature/goods/src/main/ets'), { recursive: true }); + + // Member module "data" with an Index.ets barrel (ohpm entry convention). + fs.writeFileSync( + path.join(tmpDir, 'core/data/oh-package.json5'), + '{\n // ohpm module manifest\n "name": "data",\n "main": "Index.ets",\n}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'core/data/Index.ets'), + "export { CartRepository } from './src/main/ets/CartRepository';\n" + ); + fs.writeFileSync( + path.join(tmpDir, 'core/data/src/main/ets/CartRepository.ets'), + 'export class CartRepository {\n' + + ' addToCart(id: string): void {\n' + + ' console.log(id);\n' + + ' }\n' + + '}\n' + ); + + // Consumer module declares the sibling via a file: dependency and imports + // it by bare name. + fs.writeFileSync( + path.join(tmpDir, 'feature/goods/oh-package.json5'), + '{\n "name": "goods",\n "dependencies": {\n "data": "file:../../core/data", // local module\n },\n}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'feature/goods/src/main/ets/GoodsViewModel.ets'), + 'import { CartRepository } from "data";\n' + + '\n' + + 'export class GoodsViewModel {\n' + + ' private cart: CartRepository = new CartRepository();\n' + + '\n' + + ' add(id: string): void {\n' + + ' this.cart.addToCart(id);\n' + + ' }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const classes = cg.getNodesByKind('class'); + const repo = classes.find((n) => n.name === 'CartRepository'); + const vm = classes.find((n) => n.name === 'GoodsViewModel'); + expect(repo).toBeDefined(); + expect(vm).toBeDefined(); + + // add() -> addToCart() across the module boundary. + const methods = cg.getNodesByKind('method'); + const add = methods.find((n) => n.qualifiedName === 'GoodsViewModel::add'); + const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart'); + expect(add).toBeDefined(); + expect(addToCart).toBeDefined(); + const targets = cg.getOutgoingEdges(add!.id).map((e) => e.target); + expect(targets).toContain(addToCart!.id); + }); +}); + +describe('ArkUI state → build() re-render bridge (assignment-gated)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('links assigning methods to build(), but not read-only methods', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-state-')); + fs.writeFileSync( + path.join(tmpDir, 'Page.ets'), + '@Entry\n@Component\nstruct Page {\n' + + ' @State todos: string[] = [];\n' + + ' @State count: number = 0;\n' + + '\n' + + ' addTodo(t: string): void {\n' + + ' this.todos.push(t);\n' + + ' }\n' + + '\n' + + ' reset(): void {\n' + + ' this.count = 0;\n' + + ' }\n' + + '\n' + + ' describeCount(): string {\n' + + ' return `count is ${this.count}`;\n' + + ' }\n' + + '\n' + + ' build() {\n' + + ' Column() {\n' + + ' Text(this.describeCount())\n' + + ' }\n' + + ' }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const methods = cg.getNodesByKind('method'); + const build = methods.find((n) => n.qualifiedName === 'Page::build')!; + const addTodo = methods.find((n) => n.qualifiedName === 'Page::addTodo')!; + const reset = methods.find((n) => n.qualifiedName === 'Page::reset')!; + const describeCount = methods.find((n) => n.qualifiedName === 'Page::describeCount')!; + + const synthEdgesTo = (from: string) => + cg + .getOutgoingEdges(from) + .filter( + (e) => + e.target === build.id && + (e.metadata as Record | undefined)?.synthesizedBy === 'arkui-state' + ); + + // Array mutator and plain assignment both count as state writes. + expect(synthEdgesTo(addTodo.id)).toHaveLength(1); + expect(synthEdgesTo(reset.id)).toHaveLength(1); + // A read-only method gets NO re-render edge — the precision line. + expect(synthEdgesTo(describeCount.id)).toHaveLength(0); + }); +}); + +describe('ArkUI @ohos.events.emitter bridge', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('links emit → on through a shared named constant, chased through a local EventsId', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter-')); + fs.writeFileSync( + path.join(tmpDir, 'Bus.ets'), + "import emitter from '@ohos.events.emitter';\n" + + '\n' + + 'export class EmitterConst {\n' + + ' static readonly ADD_EVENT_ID: number = 2;\n' + + '}\n' + + '\n' + + 'class EventsId {\n' + + ' eventId: number;\n' + + ' constructor(eventId: number) {\n' + + ' this.eventId = eventId;\n' + + ' }\n' + + '}\n' + + '\n' + + 'export class Bus {\n' + + ' subscribeCart(callback: Function): void {\n' + + ' let addGoodDataId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' + + ' emitter.on(addGoodDataId, (eventData) => {\n' + + ' callback(eventData);\n' + + ' });\n' + + ' }\n' + + '\n' + + ' publishAdd(goodId: number): void {\n' + + ' let addToCartId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' + + ' emitter.emit(addToCartId);\n' + + ' }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const methods = cg.getNodesByKind('method'); + const publishAdd = methods.find((n) => n.qualifiedName === 'Bus::publishAdd')!; + const subscribeCart = methods.find((n) => n.qualifiedName === 'Bus::subscribeCart')!; + const bridged = cg + .getOutgoingEdges(publishAdd.id) + .filter( + (e) => + e.target === subscribeCart.id && + (e.metadata as Record | undefined)?.synthesizedBy === 'arkui-emitter' + ); + expect(bridged).toHaveLength(1); + }); + + it('numeric-literal event ids never pair across files', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter2-')); + fs.writeFileSync( + path.join(tmpDir, 'A.ets'), + "import emitter from '@ohos.events.emitter';\n" + + 'export function fireA(): void {\n' + + ' emitter.emit({ eventId: 1 });\n' + + '}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'B.ets'), + "import emitter from '@ohos.events.emitter';\n" + + 'export function listenB(): void {\n' + + ' emitter.on({ eventId: 1 }, () => {});\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const fns = cg.getNodesByKind('function'); + const fireA = fns.find((n) => n.name === 'fireA')!; + const listenB = fns.find((n) => n.name === 'listenB')!; + const bridged = cg + .getOutgoingEdges(fireA.id) + .filter((e) => e.target === listenB.id); + expect(bridged).toHaveLength(0); + }); +}); + +describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('links the navigating method to the target page struct, standard layout only', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-router-')); + fs.mkdirSync(path.join(tmpDir, 'entry/src/main/ets/pages'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'entry/src/main/ets/pages/Detail.ets'), + '@Entry\n@Component\nstruct Detail {\n build() {\n Column() {\n Text("detail")\n }\n }\n}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'entry/src/main/ets/pages/Home.ets'), + "import router from '@ohos.router';\n" + + '\n' + + '@Entry\n@Component\nstruct Home {\n' + + ' openDetail(id: string): void {\n' + + " router.pushUrl({ url: 'pages/Detail', params: { id: id } });\n" + + ' }\n' + + '\n' + + ' build() {\n' + + ' Column() {\n' + + " Button('go').onClick(this.openDetail)\n" + + ' }\n' + + ' }\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const methods = cg.getNodesByKind('method'); + const openDetail = methods.find((n) => n.qualifiedName === 'Home::openDetail')!; + const detail = cg.getNodesByKind('struct').find((n) => n.name === 'Detail')!; + const bridged = cg + .getOutgoingEdges(openDetail.id) + .filter( + (e) => + e.target === detail.id && + (e.metadata as Record | undefined)?.synthesizedBy === 'arkui-route' + ); + expect(bridged).toHaveLength(1); + }); +}); + +describe('ohpm main entry (custom barrel + .ts consumer)', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('resolves a bare import through a custom main, from an .ets AND a .ts consumer', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-main-')); + fs.mkdirSync(path.join(tmpDir, 'core/data/src'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'feature/goods/src'), { recursive: true }); + + // Custom entry — NOT the Index.ets convention. + fs.writeFileSync( + path.join(tmpDir, 'core/data/oh-package.json5'), + '{\n "name": "data",\n "main": "src/entry.ets",\n}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'core/data/src/entry.ets'), + "export { CartRepository } from './CartRepository';\n" + ); + fs.writeFileSync( + path.join(tmpDir, 'core/data/src/CartRepository.ets'), + 'export class CartRepository {\n addToCart(id: string): void {\n console.log(id);\n }\n}\n' + ); + + fs.writeFileSync( + path.join(tmpDir, 'feature/goods/oh-package.json5'), + '{\n "name": "goods",\n "dependencies": {\n "data": "file:../../core/data",\n },\n}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'feature/goods/src/GoodsVm.ets'), + 'import { CartRepository } from "data";\n' + + 'export class GoodsVm {\n' + + ' private cart: CartRepository = new CartRepository();\n' + + ' add(id: string): void {\n this.cart.addToCart(id);\n }\n' + + '}\n' + ); + // The .ts consumer — resolves through the manifest's entry, no `.ets` + // in the TypeScript candidate list required. + fs.writeFileSync( + path.join(tmpDir, 'feature/goods/src/report.ts'), + 'import { CartRepository } from "data";\n' + + 'export function report(cart: CartRepository): string {\n' + + ' return typeof cart;\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const classes = cg.getNodesByKind('class'); + const repo = classes.find((n) => n.name === 'CartRepository')!; + expect(repo).toBeDefined(); + + // .ets consumer: cross-module method call connects. + const methods = cg.getNodesByKind('method'); + const add = methods.find((n) => n.qualifiedName === 'GoodsVm::add')!; + const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart')!; + expect(cg.getOutgoingEdges(add.id).map((e) => e.target)).toContain(addToCart.id); + + // .ts consumer: the type annotation reference reaches the .ets class. + const report = cg.getNodesByKind('function').find((n) => n.name === 'report')!; + expect(cg.getOutgoingEdges(report.id).map((e) => e.target)).toContain(repo.id); + }); +}); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 61ffa64..2b9beca 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -138,6 +138,12 @@ describe('Language Detection', () => { expect(detectLanguage('versions.tofu')).toBe('terraform'); }); + it('should detect ArkTS files', () => { + expect(detectLanguage('entry/src/main/ets/pages/Index.ets')).toBe('arkts'); + // Plain `.ts` in a HarmonyOS project is still TypeScript. + expect(detectLanguage('entry/src/main/ets/common/utils.ts')).toBe('typescript'); + }); + it('should return unknown for unsupported extensions', () => { expect(detectLanguage('styles.css')).toBe('unknown'); expect(detectLanguage('data.json')).toBe('unknown'); @@ -10238,3 +10244,285 @@ resource "aws_instance" "x" { }); }); }); + +// ============================================================================= +// ArkTS (HarmonyOS / OpenHarmony declarative UI — `.ets`) +// ============================================================================= + +describe('ArkTS Extraction', () => { + it('reports ArkTS as supported', () => { + expect(isLanguageSupported('arkts')).toBe(true); + expect(getSupportedLanguages()).toContain('arkts'); + }); + + describe('@Component struct extraction', () => { + const code = ` +import { TodoItem } from '../model/TodoItem'; + +@Entry +@Component +struct Index { + @State message: string = 'Hello'; + @Prop count: number = 0; + @StorageLink('theme') theme: string = 'light'; + private service: TodoService = new TodoService(); + + aboutToAppear(): void { + this.load(); + } + + load(): void { + this.message = 'loaded'; + } + + build() { + Column() { + Text(this.message).fontSize(50) + } + .height('100%') + } +} +`; + + it('extracts the struct with its ArkUI decorators', () => { + const result = extractFromSource('pages/Index.ets', code); + const comp = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Index'); + expect(comp).toBeDefined(); + expect(comp?.language).toBe('arkts'); + expect(comp?.decorators).toEqual(expect.arrayContaining(['Entry', 'Component'])); + }); + + it('extracts an EXPORTED struct whose decorators sit on the export statement', () => { + const result = extractFromSource( + 'components/Card.ets', + `@Component\nexport struct Card {\n build() {\n Row() {}\n }\n}\n` + ); + const card = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Card'); + expect(card).toBeDefined(); + expect(card?.isExported).toBe(true); + expect(card?.decorators).toContain('Component'); + }); + + it('extracts struct members: build(), lifecycle + regular methods with qualified names', () => { + const result = extractFromSource('pages/Index.ets', code); + const methods = result.nodes.filter((n) => n.kind === 'method'); + expect(methods.find((m) => m.qualifiedName === 'Index::build')).toBeDefined(); + expect(methods.find((m) => m.qualifiedName === 'Index::aboutToAppear')).toBeDefined(); + expect(methods.find((m) => m.qualifiedName === 'Index::load')).toBeDefined(); + }); + + it('extracts @State/@Prop/@StorageLink members as properties with their decorators', () => { + const result = extractFromSource('pages/Index.ets', code); + const message = result.nodes.find((n) => n.kind === 'property' && n.qualifiedName === 'Index::message'); + expect(message).toBeDefined(); + expect(message?.decorators).toContain('State'); + const count = result.nodes.find((n) => n.kind === 'property' && n.qualifiedName === 'Index::count'); + expect(count?.decorators).toContain('Prop'); + // Decorator-with-args: the decorator NAME is captured, not its argument. + const theme = result.nodes.find((n) => n.kind === 'property' && n.qualifiedName === 'Index::theme'); + expect(theme?.decorators).toContain('StorageLink'); + }); + + it('emits intra-struct method call refs (this.load())', () => { + const result = extractFromSource('pages/Index.ets', code); + const call = result.unresolvedReferences.find( + (r) => r.referenceKind === 'calls' && r.referenceName === 'load' + ); + expect(call).toBeDefined(); + }); + }); + + describe('build() DSL call surface', () => { + const code = ` +@Extend(Text) function titleStyle(size: number) { + .fontSize(size) +} + +@Component +struct Page { + count: number = 0; + + handleTap(): void { + this.count += 1; + } + + @Builder + headerBar(title: string) { + Row() { + Text(title).titleStyle(24) + Button('Go').onClick(this.handleTap) + } + } + + build() { + Column({ space: 8 }) { + this.headerBar('Home') + ChildCard({ label: 'hi' }) + } + .height('100%') + } +} +`; + + function callRefsFrom(result: ReturnType, methodName: string): string[] { + const from = result.nodes.find((n) => n.kind === 'method' && n.name === methodName); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls' && r.fromNodeId === from?.id) + .map((r) => r.referenceName); + } + + it('emits a call ref for a custom component instantiation inside build()', () => { + const result = extractFromSource('pages/Page.ets', code); + expect(callRefsFrom(result, 'build')).toContain('ChildCard'); + }); + + it('emits dot-prefixed call refs for chained attributes (@Extend/@Styles-only resolution)', () => { + const result = extractFromSource('pages/Page.ets', code); + // `.titleStyle(24)` chains on the Text component — one node, repeated + // property/arguments field pairs, NOT nested call_expressions. The + // leading dot routes the ref to the decorator-gated matcher strategy so + // framework attributes (`.height` below) can never hit an arbitrary + // same-named symbol. + expect(callRefsFrom(result, 'headerBar')).toContain('.titleStyle'); + expect(callRefsFrom(result, 'build')).toContain('.height'); + expect(callRefsFrom(result, 'build')).not.toContain('height'); + }); + + it('recovers the detached-chain shape (chain on the line after a nested component)', () => { + // Inside arkui_children, a chain starting after the closing `}` is + // detached by the grammar into sibling leading_dot_expression + + // parenthesized_expression statements — the close-button idiom. + const detached = ` +@Component +struct Panel { + close(): void {} + + build() { + Column() { + Row() { + Text('x') + } + .width(10) + .onClick(this.close) + .id('close_button') + } + } +} +`; + const result = extractFromSource('components/Panel.ets', detached); + const refs = callRefsFrom(result, 'build'); + expect(refs).toContain('close'); + expect(refs).toContain('.width'); + expect(refs).not.toContain('width'); + }); + + it('dot-prefixes the innermost call of a proper-form detached chain', () => { + // `.alignItems(x).layoutWeight(1)` under a leading_dot_expression: the + // wrapper consumes the dot, so the innermost call has a bare identifier + // function and would otherwise emit as a plain `alignItems(...)` call. + const chained = ` +@Component +struct Card { + build() { + Column() { + List() { + Text('x') + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .height('100%') + } + } +} +`; + const result = extractFromSource('components/Card.ets', chained); + const refs = callRefsFrom(result, 'build'); + expect(refs).toContain('.alignItems'); + expect(refs).not.toContain('alignItems'); + expect(refs).toContain('.layoutWeight'); + expect(refs).not.toContain('layoutWeight'); + }); + + it('emits a call ref for an .onClick(this.handler) method-reference binding', () => { + const result = extractFromSource('pages/Page.ets', code); + expect(callRefsFrom(result, 'headerBar')).toContain('handleTap'); + }); + + it('emits a call ref for a @Builder method invoked as this.headerBar()', () => { + const result = extractFromSource('pages/Page.ets', code); + expect(callRefsFrom(result, 'build')).toContain('headerBar'); + }); + + it('extracts a global @Extend function with its decorator', () => { + const result = extractFromSource('pages/Page.ets', code); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'titleStyle'); + expect(fn).toBeDefined(); + expect(fn?.decorators).toContain('Extend'); + }); + }); + + describe('Global @Builder functions', () => { + it('extracts a decorated global @Builder function with signature and decorator', () => { + const result = extractFromSource( + 'common/builders.ets', + `@Builder\nfunction EmptyHint(message: string) {\n Column() {\n Text(message).fontSize(16)\n }\n}\n` + ); + const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'EmptyHint'); + expect(fn).toBeDefined(); + expect(fn?.signature).toBe('(message: string)'); + expect(fn?.decorators).toContain('Builder'); + }); + }); + + describe('Standard TypeScript constructs in .ets', () => { + it('extracts classes, interfaces, enums, type aliases and their members', () => { + const code = ` +export enum Priority { Low, Medium = 2, High } + +export interface Shape { + area(): number; +} + +export type Handler = (e: string) => void; + +export class Service { + private count: number = 0; + doWork(x: number): number { + return this.helper(x); + } + helper(n: number): number { return n * 2; } +} +`; + const result = extractFromSource('common/service.ets', code); + expect(result.nodes.find((n) => n.kind === 'class' && n.name === 'Service')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'enum' && n.name === 'Priority')).toBeDefined(); + const members = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.qualifiedName); + expect(members).toEqual(expect.arrayContaining(['Priority::Low', 'Priority::Medium', 'Priority::High'])); + expect(result.nodes.find((n) => n.kind === 'interface' && n.name === 'Shape')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'Handler')).toBeDefined(); + const doWork = result.nodes.find((n) => n.qualifiedName === 'Service::doWork'); + expect(doWork?.kind).toBe('method'); + expect(doWork?.signature).toBe('(x: number): number'); + expect( + result.unresolvedReferences.find((r) => r.referenceKind === 'calls' && r.referenceName === 'helper') + ).toBeDefined(); + }); + }); + + describe('Import extraction', () => { + it('extracts relative, SDK (@ohos/@kit) and default imports', () => { + const code = ` +import router from '@ohos.router'; +import { promptAction } from '@kit.ArkUI'; +import { TodoItem } from '../model/TodoItem'; +import DataStore from '../data/DataStore'; +`; + const result = extractFromSource('pages/imports.ets', code); + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(imports).toContain('@ohos.router'); + expect(imports).toContain('@kit.ArkUI'); + expect(imports).toContain('../model/TodoItem'); + expect(imports).toContain('../data/DataStore'); + }); + }); +}); diff --git a/__tests__/status-json.test.ts b/__tests__/status-json.test.ts index 772152f..292ca20 100644 --- a/__tests__/status-json.test.ts +++ b/__tests__/status-json.test.ts @@ -87,3 +87,61 @@ describe('codegraph status --json — CI fields (#329)', () => { expect(ms).toBeLessThanOrEqual(after + 1000); }); }); + +describe('index completeness marker (index_state)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-index-state-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('a clean full index stamps state=complete with reconciled counts', async () => { + fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export function f(): number { return 1; }\n'); + fs.writeFileSync(path.join(tempDir, 'b.ts'), 'import { f } from "./a";\nexport const y = f();\n'); + const cg = CodeGraph.initSync(tempDir); + const result = await cg.indexAll(); + + // The scan's ground truth is reported and fully accounted for. + expect(result.filesDiscovered).toBeDefined(); + expect(result.filesIndexed + result.filesSkipped + result.filesErrored).toBe( + result.filesDiscovered + ); + expect(result.errors.filter((e) => e.code === 'index_partial')).toHaveLength(0); + expect(cg.getIndexState()).toBe('complete'); + cg.close(); + + const out = runStatusJson(tempDir); + expect((out.index as Record).state).toBe('complete'); + }); + + it('a run killed mid-index leaves state=indexing, and status --json surfaces it', async () => { + fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export const x = 1;\n'); + const cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.close(); + + // Simulate a kill between the start-marker write and completion: the + // marker a dead process leaves behind is exactly 'indexing'. Written + // straight into the DB — the process that died can't have cleaned it up. + // (require, not import: vite tries to bundle a dynamic import specifier.) + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(path.join(tempDir, '.codegraph', 'codegraph.db')); + db.prepare( + "INSERT INTO project_metadata (key, value, updated_at) VALUES ('index_state', 'indexing', 0) " + + "ON CONFLICT(key) DO UPDATE SET value = 'indexing'" + ).run(); + db.close(); + + const out = runStatusJson(tempDir); + expect((out.index as Record).state).toBe('indexing'); + + const reopened = await CodeGraph.open(tempDir); + expect(reopened.getIndexState()).toBe('indexing'); + reopened.close(); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index a75a58a..561163b 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -354,6 +354,14 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`); } clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`); + // A PARTIAL index (files silently dropped mid-pipeline) must not pass + // as a clean run — it's the difference between "indexed the repo" and + // "indexed most of the repo, quietly". Only the completeness + // reconciliation warning; per-file extractor warnings stay in the + // error-code summary below. + for (const w of result.errors.filter((e) => e.code === 'index_partial')) { + clack.log.warn(w.message); + } } else if (hasErrors) { clack.log.error(`Indexing failed ${getGlyphs().dash} all ${formatNumber(result.filesErrored)} files had errors`); } else { @@ -798,6 +806,7 @@ program const buildInfo = cg.getIndexBuildInfo(); const reindexRecommended = cg.isIndexStale(); + const indexState = cg.getIndexState(); // JSON output mode if (options.json) { @@ -829,6 +838,10 @@ program builtWithExtractionVersion: buildInfo.extractionVersion, currentExtractionVersion: EXTRACTION_VERSION, reindexRecommended, + // 'complete' | 'partial' (files silently dropped) | 'indexing' + // (a run was killed mid-index — the index is truncated) | + // 'failed' | null (predates the marker). + state: indexState, }, })); cg.destroy(); @@ -842,6 +855,13 @@ program if (worktreeMismatch) { warn(worktreeMismatchWarning(worktreeMismatch)); } + if (indexState === 'indexing') { + warn('The last index run never finished (killed mid-index?) — the index is truncated. Re-run "codegraph index".'); + } else if (indexState === 'partial') { + warn('The last index run silently dropped files — the index is partial. Re-run "codegraph index".'); + } else if (indexState === 'failed') { + warn('The last index run failed — results may be incomplete. Re-run "codegraph index".'); + } console.log(); // Index stats diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index aef4cac..2d74b52 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -47,6 +47,7 @@ const WASM_GRAMMAR_FILES: Record = { erlang: 'tree-sitter-erlang.wasm', solidity: 'tree-sitter-solidity.wasm', terraform: 'tree-sitter-terraform.wasm', + arkts: 'tree-sitter-arkts.wasm', }; /** @@ -58,6 +59,10 @@ export const EXTENSION_MAP: Record = { // ESM/CJS TypeScript module extensions — parsed as TS (no JSX). (#366) '.mts': 'typescript', '.cts': 'typescript', + // ArkTS (HarmonyOS / OpenHarmony) — a TypeScript superset with declarative + // UI (`@Component struct` + `build()`). Own grammar (a tree-sitter-typescript + // -style fork); plain `.ts` in an ArkTS project stays TypeScript. (#648) + '.ets': 'arkts', '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', @@ -292,7 +297,13 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise { + for (let i = 0; i < dec.namedChildCount; i++) { + const child = dec.namedChild(i); + if (!child) continue; + if (child.type === 'identifier') return child.text; + if (child.type === 'call_expression') { + // `@StorageLink('theme')` / `@Extend(Text)` — the decorator name is + // the callee. + const fn = child.childForFieldName('function'); + if (fn?.type === 'identifier') return fn.text; + } + } + return undefined; + }; + + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'decorator') { + const n = nameOf(child); + if (n) names.push(n); + } + } + + const parent = node.parent; + if (parent) { + // Find this node among the parent's named children by start offset + // (wrapper identity is not stable across navigation), then walk backwards. + const start = node.startIndex; + let idx = -1; + for (let i = 0; i < parent.namedChildCount; i++) { + const sib = parent.namedChild(i); + if (sib && sib.startIndex === start) { + idx = i; + break; + } + } + for (let i = idx - 1; i >= 0; i--) { + const sib = parent.namedChild(i); + if (!sib || sib.type !== 'decorator') break; + const n = nameOf(sib); + if (n) names.unshift(n); + } + } + + return names.length > 0 ? names : undefined; +} + +export const arktsExtractor: LanguageExtractor = { + ...typescriptExtractor, + + // `@Component struct X { … }` — extractStruct handles it (kind `struct`, + // members extracted like class members, `this.m()` resolution and the + // class/struct containment gates in the name-matcher all apply as-is). The + // component-ness is preserved on the node's decorators (`Component`, + // `Entry`, `CustomDialog`, `Reusable`), captured by extractModifiers below. + structTypes: ['struct_declaration'], + + // build()-DSL component instantiations are call sites: `TodoRow({...})` + // inside a parent's build() is the parent→child component edge, resolved by + // the ordinary call pipeline against the child's struct node. The arkts + // branch in extractCall also lifts each chained `.attr(...)` (emitted + // dot-prefixed so it can ONLY resolve to `@Extend`/`@Styles`/`@Builder` + // attribute helpers — see matchReference) and `.onXxx(this.handler)` + // method-reference bindings. `leading_dot_expression` is the detached-chain + // shape the grammar produces when a nested component's chain starts on the + // line after its closing `}` inside arkui_children. + callTypes: ['call_expression', 'arkui_component_expression', 'leading_dot_expression'], + + // Surface ArkTS decorators on the node's `decorators` list (searchable, and + // the hook a future ArkUI state→build synthesizer keys off). Core paths + // already emit `decorates` REFERENCES for classes/methods/properties/ + // functions; this hook is what puts the names on struct nodes too — + // extractStruct has no extractDecoratorsFor call, and node.decorators is + // only populated via extractModifiers (see createNode). + extractModifiers: (node) => { + if (!DECORATED_MEMBER_TYPES.has(node.type)) return undefined; + return collectDecoratorNames(node); + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index ab8c8aa..708d475 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -34,6 +34,7 @@ import { vbnetExtractor } from './vbnet'; import { erlangExtractor } from './erlang'; import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; +import { arktsExtractor } from './arkts'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -65,4 +66,5 @@ export const EXTRACTORS: Partial> = { erlang: erlangExtractor, solidity: solidityExtractor, terraform: terraformExtractor, + arkts: arktsExtractor, }; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index ce72d13..e6520cc 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -373,7 +373,7 @@ export class TreeSitterExtractor { // Value-reference edges (default ON; set CODEGRAPH_VALUE_REFS=0 to disable; see flushValueRefs). // Same-file reads of file-scope const/var symbols → `references` edges so impact analysis catches // value consumers ("change this constant/table, affect its readers"). - private static readonly VALUE_REF_LANGS = new Set(['typescript', 'javascript', 'tsx', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift', 'dart', 'pascal']); + private static readonly VALUE_REF_LANGS = new Set(['typescript', 'javascript', 'tsx', 'arkts', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift', 'dart', 'pascal']); private static readonly MAX_VALUE_REF_NODES = 20_000; private readonly valueRefsEnabled = process.env.CODEGRAPH_VALUE_REFS !== '0'; private fileScopeValues = new Map(); @@ -1183,7 +1183,8 @@ export class TreeSitterExtractor { else if ( nodeType === 'export_statement' && (this.language === 'typescript' || this.language === 'tsx' || - this.language === 'javascript' || this.language === 'jsx') && + this.language === 'javascript' || this.language === 'jsx' || + this.language === 'arkts') && getChildByField(node, 'source') ) { const parentId = this.nodeStack[this.nodeStack.length - 1]; @@ -2487,7 +2488,8 @@ export class TreeSitterExtractor { // Extract variable declarators based on language if (this.language === 'typescript' || this.language === 'javascript' || - this.language === 'tsx' || this.language === 'jsx' || this.language === 'cfscript') { + this.language === 'tsx' || this.language === 'jsx' || this.language === 'cfscript' || + this.language === 'arkts') { // Handle lexical_declaration and variable_declaration // These contain one or more variable_declarator children for (let i = 0; i < node.namedChildCount; i++) { @@ -2916,7 +2918,7 @@ export class TreeSitterExtractor { // property/method nodes under the type alias so `recorder.stop()` // can attach the call edge to `RecorderHandle.stop` instead of // an unrelated class method picked by path-proximity (#359). - if (this.language === 'typescript' || this.language === 'tsx') { + if (this.language === 'typescript' || this.language === 'tsx' || this.language === 'arkts') { this.extractTsTypeAliasMembers(value, typeAliasNode); // `type List = [ Service<'name', Req, Resp>, … ]` — surface each // entry's string-literal name as a searchable member (issue #634). @@ -3132,7 +3134,8 @@ export class TreeSitterExtractor { // called/typed symbols still record a cross-file dependency (TS/JS only). if ( this.language === 'typescript' || this.language === 'tsx' || - this.language === 'javascript' || this.language === 'jsx' + this.language === 'javascript' || this.language === 'jsx' || + this.language === 'arkts' ) { const parentId = this.nodeStack[this.nodeStack.length - 1]; if (parentId) this.emitImportBindingRefs(node, parentId); @@ -3894,6 +3897,176 @@ export class TreeSitterExtractor { return; } + // ArkTS build()-DSL handling. Three shapes carry UI-attribute chains, and + // all of their attribute names are emitted with a LEADING DOT + // (`.titleStyle`, `.width`) — an impossible identifier that routes them to + // a dedicated matcher strategy resolving ONLY to decorator-marked + // attribute helpers (`@Extend`/`@Styles`/`@AnimatableExtend`/`@Builder` + // functions). Bare names would go through global name matching, where + // framework attributes (`.width`, `.fontSize`, appearing on nearly every + // UI line) hit arbitrary same-named symbols — measured on the OpenHarmony + // samples monorepo, that produced 36k wrong edges (17% of all calls), + // including single properties with 3,400+ false callers. + // + // 1. `Column({space:8}) { … }.height('100%')` — ONE + // arkui_component_expression: `function:` = the component, chained + // attributes as repeated `property:`/`arguments:` field pairs. + // The component ref (`Column`, `TodoRow`) stays a PLAIN name — it + // resolves to the child `@Component struct`, giving the parent→child + // component-tree edge the way JSX children do for React. + // 2. `Image(x).width(10).onClick(this.f)` — ordinary nested + // call_expressions whose `function:` is a member_expression chained + // on a CALL RESULT (never a named receiver, so `svc.save()` / + // `this.vm.load()` are untouched and fall through to the generic + // paths below). + // 3. A nested component whose chain starts on the line AFTER its + // closing `}` inside arkui_children — the grammar detaches the chain + // into sibling `leading_dot_expression(identifier)` + + // `parenthesized_expression(args)` statement pairs; reassemble from + // the siblings. + // + // `.onXxx(this.handler)` METHOD-REFERENCE bindings (no call parens, so + // nothing else records them) additionally emit a call ref to the bare + // handler name — same-class resolution links the tap→handler hop. + // Arrow-function handlers need nothing: their bodies' calls already + // attribute to the enclosing build(). Children/argument subtrees are + // still walked by the caller, so nested components extract normally. + if (this.language === 'arkts') { + const emitAttr = (nameNode: SyntaxNode): void => { + const attrName = getNodeText(nameNode, this.source); + if (!attrName) return; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: '.' + attrName, + referenceKind: 'calls', + line: nameNode.startPosition.row + 1, + column: nameNode.startPosition.column, + }); + }; + // Emit `handler` for each bare `this.handler` among an on-attribute's + // arguments. + const emitThisHandlers = (args: SyntaxNode | null): void => { + if (!args) return; + for (let j = 0; j < args.namedChildCount; j++) { + const arg = args.namedChild(j); + if (arg?.type !== 'member_expression') continue; + const obj = getChildByField(arg, 'object'); + const prop = getChildByField(arg, 'property'); + if (obj?.type === 'this' && prop) { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: getNodeText(prop, this.source), + referenceKind: 'calls', + line: arg.startPosition.row + 1, + column: arg.startPosition.column, + }); + } + } + }; + + // Shape 1: arkui_component_expression with property/arguments pairs. + if (node.type === 'arkui_component_expression') { + const componentField = getChildByField(node, 'function'); + if (componentField && componentField.type === 'identifier') { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: getNodeText(componentField, this.source), + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child || child.type !== 'property_identifier') continue; + emitAttr(child); + if (/^on[A-Z]/.test(getNodeText(child, this.source))) { + // The attribute's arguments node is the next `arguments`-typed + // child before the following attribute name. + let args: SyntaxNode | null = null; + for (let k = i + 1; k < node.childCount; k++) { + const next = node.child(k); + if (!next) continue; + if (next.type === 'property_identifier') break; + if (next.type === 'arguments') { + args = next; + break; + } + } + emitThisHandlers(args); + } + } + return; + } + + // Shape 2: fluent chain on a call result — + // call_expression(function: member_expression(object: )), or the + // grammar's DSL-specific arkui_dsl_decorator_member_expression (same + // object/property fields; produced e.g. by `Column() { … }.alignItems(x)` + // in some chain positions — it ONLY occurs in attribute chains). + if (node.type === 'call_expression') { + const fn = getChildByField(node, 'function'); + if (fn?.type === 'member_expression' || fn?.type === 'arkui_dsl_decorator_member_expression') { + const obj = getChildByField(fn, 'object'); + const prop = getChildByField(fn, 'property'); + if ( + prop && + (fn.type === 'arkui_dsl_decorator_member_expression' || + obj?.type === 'call_expression' || + obj?.type === 'arkui_component_expression') + ) { + emitAttr(prop); + if (/^on[A-Z]/.test(getNodeText(prop, this.source))) { + emitThisHandlers(getChildByField(node, 'arguments')); + } + return; + } + } + // The INNERMOST call of a proper-form detached chain + // (`.alignItems(x).layoutWeight(1)…` under a leading_dot_expression) + // has a BARE IDENTIFIER function — the leading dot was consumed by + // the wrapper, so it masquerades as a plain `alignItems(...)` call. + // Walk up the member/call alternation; topping out at + // leading_dot_expression means the dot belongs to this chain. + if (fn?.type === 'identifier') { + let p: SyntaxNode | null = node.parent; + while (p && (p.type === 'member_expression' || p.type === 'call_expression')) { + p = p.parent; + } + if (p?.type === 'leading_dot_expression') { + emitAttr(fn); + if (/^on[A-Z]/.test(getNodeText(fn, this.source))) { + emitThisHandlers(getChildByField(node, 'arguments')); + } + return; + } + } + // Not a chained attribute — fall through to the generic call paths. + } + + // Shape 3: detached chain segment — leading_dot_expression whose only + // named child is a bare identifier; its arguments sit in the NEXT + // sibling statement as a parenthesized_expression. + if (node.type === 'leading_dot_expression') { + const only = node.namedChildCount === 1 ? node.namedChild(0) : null; + if (only && only.type === 'identifier') { + emitAttr(only); + if (/^on[A-Z]/.test(getNodeText(only, this.source))) { + const stmt = node.parent; // expression_statement + const nextStmt = stmt?.nextNamedSibling; + const paren = nextStmt?.namedChild(0); + if (paren?.type === 'parenthesized_expression') { + emitThisHandlers(paren); + } + } + } + // The proper form (child is a call_expression chain, as inside + // `@Extend` bodies) needs nothing here — the walker descends into it + // and the inner call_expressions take the paths above. + return; + } + } + // Get the function/method being called let calleeName = ''; @@ -5442,7 +5615,7 @@ export class TreeSitterExtractor { * Languages that support type annotations (TypeScript, etc.) */ private readonly TYPE_ANNOTATION_LANGUAGES = new Set([ - 'typescript', 'tsx', 'dart', 'kotlin', 'swift', 'rust', 'go', 'java', 'csharp', 'scala', 'php', + 'typescript', 'tsx', 'arkts', 'dart', 'kotlin', 'swift', 'rust', 'go', 'java', 'csharp', 'scala', 'php', ]); /** diff --git a/src/extraction/wasm/tree-sitter-arkts.wasm b/src/extraction/wasm/tree-sitter-arkts.wasm new file mode 100644 index 0000000..4753f37 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-arkts.wasm differ diff --git a/src/index.ts b/src/index.ts index ae9b26c..518f798 100644 --- a/src/index.ts +++ b/src/index.ts @@ -437,6 +437,11 @@ export class CodeGraph { } try { const before = this.queries.getNodeAndEdgeCount(); + // Mark the index as in-flight BEFORE any writes: a run killed + // mid-index (OOM, SIGKILL, the #850 liveness watchdog) leaves this + // marker behind, so `codegraph status` can tell a truncated index + // from a completed one instead of silently serving partial results. + try { this.queries.setMetadata('index_state', 'indexing'); } catch { /* metadata is advisory */ } // Segment vocabulary starts empty and is repopulated by the node write // path as every file (re-)indexes below — so a full index is also the // orphan-cleanup pass for names deleted since the last one. @@ -513,6 +518,37 @@ export class CodeGraph { } catch { /* metadata is advisory — never fail an index over it */ } } + // Reconcile the scan's ground truth against what the pipeline + // accounted for. A shortfall means files were silently dropped + // (observed in the wild: a run under heavy load came up 37 files + // short with no error) — record it and tell the user, don't let the + // index pass as complete. + try { + if (!result.success) { + this.queries.setMetadata('index_state', 'failed'); + } else { + const accounted = result.filesIndexed + result.filesSkipped + result.filesErrored; + const discovered = result.filesDiscovered; + const shortfall = discovered !== undefined ? discovered - accounted : 0; + if (discovered !== undefined && shortfall > 0) { + this.queries.setMetadata('index_state', 'partial'); + this.queries.setMetadata('index_files_discovered', String(discovered)); + this.queries.setMetadata('index_files_accounted', String(accounted)); + result.errors.push({ + message: `Index is missing ${shortfall} of ${discovered} discovered files (indexed ${result.filesIndexed}, skipped ${result.filesSkipped}, errored ${result.filesErrored}). The index is PARTIAL — re-run \`codegraph index\`.`, + severity: 'warning', + code: 'index_partial', + }); + } else { + this.queries.setMetadata('index_state', 'complete'); + if (discovered !== undefined) { + this.queries.setMetadata('index_files_discovered', String(discovered)); + this.queries.setMetadata('index_files_accounted', String(accounted)); + } + } + } + } catch { /* metadata is advisory — never fail an index over it */ } + return result; } finally { this.fileLock.release(); @@ -761,6 +797,22 @@ export class CodeGraph { return this.queries.getLastIndexedAt(); } + /** + * Completeness of the last full index run. `'complete'` is the only good + * state. `'indexing'` after the fact means a run was killed mid-index (OOM, + * SIGKILL, liveness watchdog) and the on-disk index is truncated; + * `'partial'` means the run finished but silently dropped files + * (discovered > indexed+skipped+errored); `'failed'` means it reported + * failure. `null` = index predates this marker. Surfaced by + * `codegraph status`. + */ + getIndexState(): 'indexing' | 'complete' | 'partial' | 'failed' | null { + const raw = this.queries.getMetadata('index_state'); + return raw === 'indexing' || raw === 'complete' || raw === 'partial' || raw === 'failed' + ? raw + : null; + } + /** * Which engine built the current index: the package version + extraction * version stamped at the last full `indexAll`. Either field is null for an diff --git a/src/mcp/dynamic-boundaries.ts b/src/mcp/dynamic-boundaries.ts index a154769..2378dda 100644 --- a/src/mcp/dynamic-boundaries.ts +++ b/src/mcp/dynamic-boundaries.ts @@ -65,7 +65,7 @@ interface FormSpec { keyWindow?: number; } -const JS_FAMILY = new Set(['typescript', 'javascript', 'tsx', 'jsx', 'vue', 'svelte', 'astro']); +const JS_FAMILY = new Set(['typescript', 'javascript', 'tsx', 'jsx', 'vue', 'svelte', 'astro', 'arkts']); const PY = new Set(['python']); const RB = new Set(['ruby']); const PHP = new Set(['php']); @@ -201,6 +201,7 @@ function commentLang(language: string): CommentLang | null { case 'vue': case 'svelte': case 'astro': + case 'arkts': return 'typescript'; case 'java': case 'kotlin': diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 87edbb8..54e5494 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -417,6 +417,269 @@ function flutterBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[ return edges; } +/** + * Reactive ArkUI property decorators: assigning a property carrying one of + * these re-runs the owning struct's `build()`. Covers both state models — + * V1 (`@Component`: State/Prop/Link/Provide/Consume/Storage*) and V2 + * (`@ComponentV2`: Local/Provider/Consumer; `@Param` is read-only in V2 so + * the assignment gate never fires on it, and `@Trace` lives on `@ObservedV2` + * data classes, not struct properties). + */ +const ARKUI_REACTIVE_DECORATORS = new Set([ + 'State', 'Prop', 'Link', 'Provide', 'Consume', 'StorageLink', 'StorageProp', + 'LocalStorageLink', 'LocalStorageProp', 'ObjectLink', + 'Local', 'Provider', 'Consumer', +]); + +/** ArkUI-observed array mutators — `this.todos.push(x)` re-renders like an assignment. */ +const ARKUI_ARRAY_MUTATORS = 'push|pop|shift|unshift|splice|sort|reverse|fill'; + +/** + * Phase 4b-ets: ArkUI state → build (the ArkTS analog of react-render / + * flutter-build). Assigning a reactive-decorated property (`@State count`, + * `@Link selected`, …) re-runs the `@Component struct`'s `build()`, but that + * hop is framework-internal — no static edge — so "onClick → markAllDone → + * this.todos = […] → rebuilt list" dead-ends at the assignment. Bridge it: + * for each arkts struct with a `build()` method and at least one reactive + * property, link every sibling method whose body ASSIGNS (or array-mutates) + * one of those properties → `build`. Assignment-gated on the struct's OWN + * reactive property names — a method that merely reads state, or a struct + * with no reactive properties, gets nothing (this is the precision line the + * all-sibling-methods design would erase). + */ +function arkuiStateBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] { + const edges: Edge[] = []; + const seen = new Set(); + for (const struct of queries.getNodesByKind('struct')) { + if (struct.language !== 'arkts') continue; + const children = queries.getOutgoingEdges(struct.id, ['contains']) + .map((e) => queries.getNodeById(e.target)) + .filter((n): n is Node => !!n); + const build = children.find((n) => n.kind === 'method' && n.name === 'build'); + if (!build) continue; + const reactiveProps = children.filter( + (n) => n.kind === 'property' && (n.decorators ?? []).some((d) => ARKUI_REACTIVE_DECORATORS.has(d)) + ); + if (reactiveProps.length === 0) continue; + const propAlternation = reactiveProps + .map((p) => p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + // `this.count = …` / `+=` / `++` / `--` / `this.todos.push(…)`. The + // `=(?!=)` keeps `this.done == x` comparisons out. + const mutationRe = new RegExp( + `this\\.(?:${propAlternation})\\s*(?:=(?!=)|\\+\\+|--|[+\\-*/%&|^]=|\\.(?:${ARKUI_ARRAY_MUTATORS})\\s*\\()` + ); + let added = 0; + for (const m of children) { + if (added >= MAX_CALLBACKS_PER_CHANNEL) break; + if (m.kind !== 'method' || m.id === build.id) continue; + const content = ctx.readFile(m.filePath); + const src = content && sliceLines(content, m.startLine, m.endLine); + if (!src || !mutationRe.test(stripCommentsForRegex(src, 'typescript'))) continue; + const key = `${m.id}>${build.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: m.id, target: build.id, kind: 'calls', line: m.startLine, + provenance: 'heuristic', + metadata: { synthesizedBy: 'arkui-state', via: 'state assignment', registeredAt: `${build.filePath}:${build.startLine}` }, + }); + added++; + } + } + return edges; +} + +/** Emit/subscribe call sites of HarmonyOS's `@ohos.events.emitter` bus. */ +const ARKUI_EMITTER_CALL_RE = /\bemitter\s*\.\s*(emit|on|once)\s*\(\s*([A-Za-z_$][\w$.]*|\{[^)]{0,120}?\beventId\s*:\s*[^,}]+[^)]*?\})/g; + +/** Cap per event bucket — a generic key with many parties is dynamic routing, not a static pair. */ +const ARKUI_EMITTER_FANOUT_CAP = 8; + +/** + * Phase 4b-ets2: HarmonyOS `@ohos.events.emitter` bridge. The cross-component + * bus — `emitter.emit(eventId)` fires `emitter.on(eventId, cb)` — is + * framework-internal, so an order flow riding it (OrangeShopping's + * add-to-cart) dead-ends at the emit. Link emit-site enclosing + * function/method → on/once-site enclosing function/method when both + * reference the SAME statically-recoverable event key. + * + * Key recovery, per call site (comment-stripped enclosing-file source): the + * first argument is an `{ eventId: K }` literal, a `Names.Dotted` constant, or + * a local whose same-file declaration is `new EventsId(K)` / `= K` — chase one + * level. Precision scoping learned from the samples monorepo (thousands of + * unrelated samples, most using eventId 1): NUMERIC keys pair within the same + * FILE only; NAMED keys pair within the same workspace module directory (or + * the whole project when it declares no modules — the single-app case), both + * behind a fan-out cap. Inline `on(id, (e) => {…})` arrows need no special + * handling — their bodies' calls already attribute to the registering method, + * so targeting that method keeps the chain connected. + */ +function arkuiEmitterEdges(ctx: ResolutionContext): Edge[] { + interface Site { nodeId: string; file: string; line: number } + // bucket key -> emit sites / handler sites + const emits = new Map(); + const handlers = new Map(); + + const moduleDirs = (() => { + const ws = ctx.getWorkspacePackages?.(); + return ws ? [...new Set(ws.byName.values())].sort((a, b) => b.length - a.length) : []; + })(); + const moduleScopeOf = (file: string): string => { + for (const dir of moduleDirs) { + if (file === dir || file.startsWith(dir + '/')) return dir; + } + return ''; + }; + + for (const file of ctx.getAllFiles()) { + if (!file.endsWith('.ets')) continue; + const content = ctx.readFile(file); + if (!content || !content.includes('emitter.')) continue; + const safe = stripCommentsForRegex(content, 'typescript'); + const nodes = ctx.getNodesInFile(file) + .filter((n) => n.kind === 'method' || n.kind === 'function'); + + ARKUI_EMITTER_CALL_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ARKUI_EMITTER_CALL_RE.exec(safe))) { + const verb = m[1]!; + const arg = m[2]!.trim(); + const line = safe.slice(0, m.index).split('\n').length; + const encl = nodes + .filter((n) => n.startLine <= line && n.endLine >= line) + .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]; + if (!encl) continue; + + // Recover the event key from the first argument. + let key: string | null = null; + const idLit = arg.startsWith('{') ? arg.match(/\beventId\s*:\s*([\w$.]+)/)?.[1] : undefined; + const token = idLit ?? arg; + if (token !== undefined) { + if (/^\d+$/.test(token)) { + key = `num:${file}:${token}`; // numeric: same-file only + } else if (token.includes('.')) { + key = `name:${moduleScopeOf(file)}:${token}`; + } else { + // Local variable — chase its same-file declaration one level: + // `let x = new EventsId(K)` / `const x = K`. + const declRe = new RegExp( + `\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b\\s*(?::[^=\\n]+)?=\\s*(?:new\\s+[\\w$.]+\\(\\s*([^)\\n]+?)\\s*\\)|([\\w$.]+))` + ); + const decl = safe.match(declRe); + const inner = (decl?.[1] ?? decl?.[2])?.trim(); + if (inner && /^\d+$/.test(inner)) key = `num:${file}:${inner}`; + else if (inner && /^[\w$.]+$/.test(inner)) key = `name:${moduleScopeOf(file)}:${inner}`; + } + } + if (!key) continue; + + const site: Site = { nodeId: encl.id, file, line }; + if (verb === 'emit') { + (emits.get(key) ?? emits.set(key, []).get(key)!).push(site); + } else { + (handlers.get(key) ?? handlers.set(key, []).get(key)!).push(site); + } + } + } + + const edges: Edge[] = []; + const seen = new Set(); + for (const [key, emitSites] of emits) { + const handlerSites = handlers.get(key); + if (!handlerSites) continue; + if (emitSites.length > ARKUI_EMITTER_FANOUT_CAP || handlerSites.length > ARKUI_EMITTER_FANOUT_CAP) continue; + const eventLabel = key.slice(key.lastIndexOf(':') + 1); + for (const e of emitSites) for (const h of handlerSites) { + if (e.nodeId === h.nodeId) continue; + const dedupe = `${e.nodeId}>${h.nodeId}`; + if (seen.has(dedupe)) continue; + seen.add(dedupe); + edges.push({ + source: e.nodeId, target: h.nodeId, kind: 'calls', line: e.line, + provenance: 'heuristic', + metadata: { synthesizedBy: 'arkui-emitter', event: eventLabel, registeredAt: `${h.file}:${h.line}` }, + }); + } + } + return edges; +} + +/** `router.pushUrl({ url: 'pages/Detail' })` / replaceUrl — literal urls only. */ +const ARKUI_ROUTER_RE = /\brouter\s*\.\s*(?:pushUrl|replaceUrl)\s*\(\s*\{[^)]{0,200}?\burl\s*:\s*['"]([\w\-./]+)['"]/g; + +/** + * Phase 4b-ets3: HarmonyOS page navigation. `router.pushUrl({ url: + * 'pages/Detail' })` reaches the `@Entry struct` of + * `/src/main/ets/pages/Detail.ets`, but the hop is a string — no + * static edge — so "tap → openDetail → ???" ends at the router call. Bridge + * literal urls to the page struct: the url resolves against the standard + * `src/main/ets/` layout (what main_pages.json entries name); candidates + * prefer the caller's own workspace module (routes are module-scoped), and + * anything still ambiguous is dropped rather than guessed. Only `@Entry` + * structs qualify as targets — the decorator is what makes a file a page. + */ +function arkuiRouterEdges(ctx: ResolutionContext): Edge[] { + const edges: Edge[] = []; + const seen = new Set(); + + const allFiles = ctx.getAllFiles(); + const moduleDirs = (() => { + const ws = ctx.getWorkspacePackages?.(); + return ws ? [...new Set(ws.byName.values())].sort((a, b) => b.length - a.length) : []; + })(); + const moduleScopeOf = (file: string): string => { + for (const dir of moduleDirs) { + if (file === dir || file.startsWith(dir + '/')) return dir; + } + return ''; + }; + + for (const file of allFiles) { + if (!file.endsWith('.ets')) continue; + const content = ctx.readFile(file); + if (!content || !content.includes('router.')) continue; + const safe = stripCommentsForRegex(content, 'typescript'); + const nodes = ctx.getNodesInFile(file) + .filter((n) => n.kind === 'method' || n.kind === 'function'); + + ARKUI_ROUTER_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ARKUI_ROUTER_RE.exec(safe))) { + const url = m[1]!; + const line = safe.slice(0, m.index).split('\n').length; + const encl = nodes + .filter((n) => n.startLine <= line && n.endLine >= line) + .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]; + if (!encl) continue; + + const suffix = `/src/main/ets/${url}.ets`; + let candidates = allFiles.filter((f) => f.endsWith(suffix)); + if (candidates.length > 1) { + const scope = moduleScopeOf(file); + const sameModule = candidates.filter((f) => moduleScopeOf(f) === scope); + if (sameModule.length > 0) candidates = sameModule; + } + if (candidates.length !== 1) continue; // ambiguous or unresolved — never guess + + const page = ctx.getNodesInFile(candidates[0]!).find( + (n) => n.kind === 'struct' && (n.decorators ?? []).includes('Entry') + ); + if (!page) continue; + + const key = `${encl.id}>${page.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: encl.id, target: page.id, kind: 'calls', line, + provenance: 'heuristic', + metadata: { synthesizedBy: 'arkui-route', event: url, registeredAt: `${candidates[0]}:${page.startLine}` }, + }); + } + } + return edges; +} + /** * Phase 4c: C++ virtual override. A call through a base/interface pointer * (`db->Get(...)`, `iter->Next()`) dispatches at runtime to a subclass override, @@ -485,6 +748,7 @@ function cppOverrideEdges(queries: QueryBuilder): Edge[] { // or an `object` (Scala) so the loop also iterates those kinds. const IFACE_OVERRIDE_LANGS = new Set([ 'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', 'go', 'rust', + 'arkts', ]); /** * Go implicit interface satisfaction (#584). Go has no `implements` keyword — a @@ -2887,6 +3151,9 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu const svelteKitEdges = svelteKitLoadEdges(ctx); await yieldToLoop(); const pascalEdges = pascalFormEdges(ctx); await yieldToLoop(); const flutterEdges = flutterBuildEdges(queries, ctx); await yieldToLoop(); + const arkuiStateEdges = arkuiStateBuildEdges(queries, ctx); await yieldToLoop(); + const arkuiEmitter = arkuiEmitterEdges(ctx); await yieldToLoop(); + const arkuiRoutes = arkuiRouterEdges(ctx); await yieldToLoop(); const cppEdges = cppOverrideEdges(queries); await yieldToLoop(); const ifaceEdges = interfaceOverrideEdges(queries); await yieldToLoop(); const kotlinExpectActual = kotlinExpectActualEdges(queries); await yieldToLoop(); @@ -2923,6 +3190,9 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu ...svelteKitEdges, ...pascalEdges, ...flutterEdges, + ...arkuiStateEdges, + ...arkuiEmitter, + ...arkuiRoutes, ...cppEdges, ...ifaceEdges, ...kotlinExpectActual, diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 763dac1..a597279 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -16,6 +16,11 @@ import { resolveWorkspaceImport } from './workspace-packages'; */ const EXTENSION_RESOLUTION: Record = { typescript: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js'], + // ArkTS imports both `.ets` components and plain `.ts` logic modules — + // HarmonyOS projects are always a mix. `/Index.ets` (capital I) is ohpm's + // module-entry convention, hit when a bare workspace import ("data") is + // rewritten to the member's directory; lowercase variants for safety. + arkts: ['.ets', '.ts', '.d.ts', '.js', '/Index.ets', '/index.ets', '/index.ts', '/index.js'], javascript: ['.js', '.jsx', '.mjs', '.cjs', '/index.js', '/index.jsx'], tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js'], jsx: ['.jsx', '.js', '/index.jsx', '/index.js'], @@ -200,7 +205,7 @@ function isExternalImport( } // Common external patterns - if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') { + if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx' || language === 'arkts') { // Node built-ins if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) { return true; @@ -649,7 +654,7 @@ export function extractImportMappings( ): ImportMapping[] { const mappings: ImportMapping[] = []; - if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') { + if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx' || language === 'arkts') { mappings.push(...extractJSImports(content)); } else if (language === 'svelte' || language === 'vue' || language === 'astro') { // Svelte/Vue single-file components import via plain ES6 inside their @@ -1061,7 +1066,8 @@ export function extractReExports(content: string, language: Language): ReExport[ language !== 'typescript' && language !== 'javascript' && language !== 'tsx' && - language !== 'jsx' + language !== 'jsx' && + language !== 'arkts' ) { return []; } @@ -1355,7 +1361,8 @@ export function resolveViaImport( ref.language === 'typescript' || ref.language === 'tsx' || ref.language === 'javascript' || - ref.language === 'jsx' + ref.language === 'jsx' || + ref.language === 'arkts' ) { const moduleFile = resolveModuleImportToFile(ref, imports, context); if (moduleFile) return moduleFile; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index aa36fbf..094a2b2 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -543,7 +543,7 @@ export class ReferenceResolver { // `.ts` index barrel and silently break the chain (#629). Re-key // the parse on the barrel's extension so the chase works no matter // what kind of file imports through it. - const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?)$/i.test(filePath); + const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?|ets)$/i.test(filePath); const reExports = extractReExports(content, isJsFamily ? 'typescript' : language); this.reExportCache.set(filePath, reExports); return reExports; @@ -744,8 +744,15 @@ export class ReferenceResolver { // from './barrel'` where the barrel has `export { signIn as login } // from './auth'`) intentionally call a name that has no // declaration anywhere — only the renamed upstream symbol does. + // ArkTS chained-attribute refs carry a leading dot (`.titleStyle`) that + // routes them to the decorator-gated matcher; the symbol itself is + // indexed under the bare name, so the existence check strips the dot. + const existenceName = + ref.language === 'arkts' && ref.referenceName.startsWith('.') + ? ref.referenceName.slice(1) + : ref.referenceName; if ( - !this.hasAnyPossibleMatch(ref.referenceName) && + !this.hasAnyPossibleMatch(existenceName) && !this.matchesAnyImport(ref) && !this.frameworks.some((f) => f.claimsReference?.(ref.referenceName)) ) { @@ -1169,13 +1176,21 @@ export class ReferenceResolver { private isBuiltInOrExternal(ref: UnresolvedRef): boolean { const name = ref.referenceName; const isJsTs = ref.language === 'typescript' || ref.language === 'javascript' - || ref.language === 'tsx' || ref.language === 'jsx'; + || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'arkts'; // JavaScript/TypeScript built-ins if (isJsTs && JS_BUILT_INS.has(name)) { return true; } + // ArkTS resource-reference intrinsics — `$r('app.string.x')` / + // `$rawfile('x.png')` are framework-provided and appear dozens of times + // per UI file; without this they can resolve to a stray same-named + // symbol (e.g. a checked-in hvigor wrapper's `$r`). + if (ref.language === 'arkts' && (name === '$r' || name === '$rawfile')) { + return true; + } + // Common JS/TS library calls (console.log, Math.floor, JSON.parse) if (isJsTs && (name.startsWith('console.') || name.startsWith('Math.') || name.startsWith('JSON.'))) { return true; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e9fb645..5ebe62f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -140,7 +140,9 @@ function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node { const LANGUAGE_FAMILY: Record = { java: 'jvm', kotlin: 'jvm', scala: 'jvm', swift: 'apple', objc: 'apple', - typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', + // ArkTS is a TS superset — every HarmonyOS project mixes `.ets` UI with + // `.ts` logic modules, so refs must cross freely between them. + typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', arkts: 'web', c: 'c', cpp: 'c', // Razor/Blazor markup names C# types — same family so `@model Foo` / // `` resolve to their `.cs` class through the cross-family gate. @@ -226,6 +228,7 @@ export function matchFunctionRef( const bareFnOnly = ref.language === 'typescript' || ref.language === 'tsx' || ref.language === 'javascript' || ref.language === 'jsx' || + ref.language === 'arkts' || ref.language === 'cpp' || ref.language === 'python' || ref.language === 'php'; @@ -1079,6 +1082,7 @@ function localReceiverTypePatterns(language: Language, r: string): RegExp[] { case 'javascript': case 'tsx': case 'jsx': + case 'arkts': return [ new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger() // No keyword requirement, so this matches BOTH a local annotation @@ -1742,6 +1746,9 @@ export function matchFuzzy( /** * Match all strategies in order of confidence */ +/** ArkUI attribute-helper decorators a `.attr(...)` chain may resolve to. */ +const ARKUI_ATTRIBUTE_DECORATORS = new Set(['Extend', 'Styles', 'AnimatableExtend', 'Builder']); + export function matchReference( ref: UnresolvedRef, context: ResolutionContext @@ -1753,6 +1760,37 @@ export function matchReference( return matchFunctionRef(ref, context); } + // ArkTS chained UI attributes — emitted with a leading dot (`.titleStyle`, + // `.width`) by the extractor — resolve ONLY to decorator-marked attribute + // helpers: `@Extend`/`@Styles`/`@AnimatableExtend` functions (and global + // `@Builder`s used attribute-position). Framework attributes (`.width`, + // `.fontSize` — on nearly every UI line) match no such helper and stay + // unresolved, NEVER falling through to bare-name matching: on a samples + // monorepo that fallthrough manufactured 36k wrong edges, giving single + // same-named properties thousands of false callers. Ambiguity rule matches + // the rest of the file: several same-named helpers → prefer the call-site + // file, still ambiguous → drop the ref rather than guess. + if (ref.language === 'arkts' && ref.referenceName.startsWith('.')) { + const base = ref.referenceName.slice(1); + const candidates = context + .getNodesByName(base) + .filter( + (n) => + n.language === 'arkts' && + n.kind === 'function' && + (n.decorators ?? []).some((d) => ARKUI_ATTRIBUTE_DECORATORS.has(d)) + ); + const chosen = + candidates.length > 1 ? preferCallSiteFile(candidates, ref.filePath) : candidates; + if (chosen.length !== 1) return null; + return { + original: ref, + targetNodeId: chosen[0]!.id, + confidence: 0.85, + resolvedBy: 'exact-match', + }; + } + // Erlang `-behaviour(m)` refs target a MODULE. Letting them fall through to // bare-name matching grabs any same-named symbol — on emqx, // `-behaviour(supervisor)` resolved to a `-define(supervisor, …)` macro diff --git a/src/resolution/workspace-packages.ts b/src/resolution/workspace-packages.ts index b8f0657..386c3e4 100644 --- a/src/resolution/workspace-packages.ts +++ b/src/resolution/workspace-packages.ts @@ -31,6 +31,16 @@ import { logDebug } from '../errors'; export interface WorkspacePackages { /** Member package `name` → directory relative to projectRoot (posix). */ byName: Map; + /** + * Member package `name` → its declared ENTRY FILE relative to projectRoot + * (posix), when the member's manifest names one (ohpm's oh-package.json5 + * `"main": "Index.ets"`). Lets a bare `import { X } from "data"` resolve to + * the member's real barrel even when it doesn't follow an index-file + * convention — and independent of the CONSUMER's language (a `.ts` file + * importing an `.ets` barrel resolves without `.ets` in the TS candidate + * list). Absent for npm/pnpm members (their index conventions cover it). + */ + entryByName?: Map; } /** @@ -43,10 +53,9 @@ export interface WorkspacePackages { * the same way it does {@link loadProjectAliases} / {@link loadGoModule}. */ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | null { - const patterns = readWorkspaceGlobs(projectRoot); - if (patterns.length === 0) return null; - const byName = new Map(); + + const patterns = readWorkspaceGlobs(projectRoot); for (const pattern of patterns) { for (const dir of expandWorkspaceGlob(projectRoot, pattern)) { const pkgName = readPackageName(path.join(projectRoot, dir)); @@ -54,10 +63,138 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | if (pkgName && !byName.has(pkgName)) byName.set(pkgName, dir); } } + + // HarmonyOS/OpenHarmony (ArkTS) modular projects: every module's + // oh-package.json5 declares its local siblings as `"data": "file:../../ + // core/data"` dependencies, and code then imports the bare name + // (`import { CartRepository } from "data"`). Same monorepo problem as npm + // workspaces, different manifest. + const entryByName = new Map(); + for (const [name, dir] of collectOhpmFileDeps(projectRoot)) { + if (byName.has(name)) continue; + byName.set(name, dir); + const entry = readOhpmMain(projectRoot, dir); + if (entry) entryByName.set(name, entry); + } + if (byName.size === 0) return null; logDebug('workspace packages loaded', { count: byName.size }); - return { byName }; + return { byName, entryByName: entryByName.size > 0 ? entryByName : undefined }; +} + +/** + * Read an ohpm member's declared entry file: `/oh-package.json5`'s + * `main`, normalized to a projectRoot-relative posix path. Null when the + * manifest or field is missing/escaping. + */ +function readOhpmMain(projectRoot: string, dirRel: string): string | null { + let parsed: unknown; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + parsed = require('jsonc-parser').parse( + fs.readFileSync(path.join(projectRoot, dirRel, OHPM_MANIFEST), 'utf-8') + ); + } catch { + return null; + } + const main = (parsed as { main?: unknown } | null)?.main; + if (typeof main !== 'string' || !main.trim()) return null; + const entryAbs = path.resolve(projectRoot, dirRel, main.trim()); + const entryRel = path.relative(projectRoot, entryAbs).replace(/\\/g, '/'); + if (entryRel.startsWith('..')) return null; + return entryRel; +} + +/** + * Scan the project for `oh-package.json5` manifests and collect their + * `file:`-protocol dependencies as workspace members: dep name (what the + * source imports) → target directory (projectRoot-relative posix). + * + * Precision rule: a name declared with DIFFERENT target directories in + * different manifests (e.g. every sample in a samples monorepo has its own + * "common") is AMBIGUOUS and dropped entirely — a missing edge beats a wrong + * cross-module link. Registry dependencies (`@ohos/axios: "^2.0.0"`) don't + * use `file:` and are ignored, staying external. + * + * The walk is bounded (depth + directory budget) and prunes build/dependency + * dirs, so non-ArkTS projects pay one readdir at the root and nothing else + * (they have no oh-package.json5 anywhere shallow). + */ +const OHPM_MANIFEST = 'oh-package.json5'; +const OHPM_WALK_MAX_DEPTH = 6; +const OHPM_WALK_DIR_BUDGET = 8000; +const OHPM_SKIP_DIRS = new Set([ + 'node_modules', 'oh_modules', '.git', '.codegraph', '.hvigor', '.preview', + 'build', 'dist', 'out', 'oh-package-lock.json5', +]); + +function collectOhpmFileDeps(projectRoot: string): Map { + const byName = new Map(); + const ambiguous = new Set(); + + const queue: Array<{ rel: string; depth: number }> = [{ rel: '', depth: 0 }]; + let visited = 0; + while (queue.length > 0) { + const { rel, depth } = queue.shift()!; + if (++visited > OHPM_WALK_DIR_BUDGET) break; + const abs = path.join(projectRoot, rel); + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(abs, { withFileTypes: true }); + } catch { + continue; + } + + for (const e of entries) { + if (e.isDirectory()) { + if (depth >= OHPM_WALK_MAX_DEPTH) continue; + if (e.name.startsWith('.') || OHPM_SKIP_DIRS.has(e.name)) continue; + queue.push({ rel: rel ? `${rel}/${e.name}` : e.name, depth: depth + 1 }); + continue; + } + if (e.name !== OHPM_MANIFEST) continue; + + const deps = readOhpmFileDeps(path.join(abs, e.name)); + for (const [name, target] of deps) { + const targetAbs = path.resolve(abs, target); + const targetRel = path.relative(projectRoot, targetAbs).replace(/\\/g, '/'); + if (targetRel.startsWith('..')) continue; // escapes the project + const existing = byName.get(name); + if (existing === undefined) { + if (!ambiguous.has(name)) byName.set(name, targetRel); + } else if (existing !== targetRel) { + byName.delete(name); + ambiguous.add(name); + } + } + } + } + + return byName; +} + +/** Parse one oh-package.json5's dependencies → [name, file-target] pairs. */ +function readOhpmFileDeps(manifestAbs: string): Array<[string, string]> { + const out: Array<[string, string]> = []; + let parsed: unknown; + try { + // JSON5 tolerates comments and trailing commas; jsonc-parser (already a + // dependency, used by the opencode installer target) handles both. + // eslint-disable-next-line @typescript-eslint/no-require-imports + parsed = require('jsonc-parser').parse(fs.readFileSync(manifestAbs, 'utf-8')); + } catch { + return out; + } + const deps = (parsed as { dependencies?: Record } | null)?.dependencies; + if (!deps || typeof deps !== 'object') return out; + for (const [name, value] of Object.entries(deps)) { + if (typeof value !== 'string' || !value.startsWith('file:')) continue; + const target = value.slice('file:'.length).trim(); + if (target) out.push([name, target]); + } + return out; } /** @@ -82,6 +219,13 @@ export function resolveWorkspaceImport( if (!bestName) return null; const dir = ws.byName.get(bestName)!; const subpath = importPath.slice(bestName.length); // '' or '/widgets' + // A bare member import resolves straight to the member's declared entry + // file when the manifest names one (ohpm `main`) — the caller's exact-path + // check hits it without extension/index guessing. + if (!subpath) { + const entry = ws.entryByName?.get(bestName); + if (entry) return entry; + } return (dir + subpath).replace(/\/{2,}/g, '/'); } diff --git a/src/types.ts b/src/types.ts index 3681723..860b392 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,7 @@ export const LANGUAGES = [ 'javascript', 'tsx', 'jsx', + 'arkts', 'python', 'go', 'rust',