feat(extraction): add ArkTS language support with ArkUI dispatch bridges (#396, #512, #890 via #648) (#1186)
Adds ArkTS (.ets, HarmonyOS/OpenHarmony) as a first-class language: full TypeScript-grade extraction via the harmony-contrib tree-sitter grammar (MIT, vendored byte-identical from the tree-sitter-arkts 0.2.0 npm tarball), plus the ArkUI constructs that make HarmonyOS apps traceable: - @Component/@ComponentV2 structs with decorators from both grammar positions; members extract as class members with qualified names. - build() component trees: child instantiation edges via arkui_component_expression, no synthesizer needed. - Attribute chains emitted dot-prefixed and resolved ONLY against @Extend/@Styles/@AnimatableExtend/@Builder helpers (unique-or-drop) — bare-name fallthrough produced 36,840 wrong edges (17% of calls) on the OpenHarmony samples monorepo. All four grammar chain shapes handled, including the detached-chain forms. - .onClick(this.handler) method-reference bindings. - ohpm workspace modules: bare imports follow oh-package.json5 file: deps (ambiguous names dropped), honoring each module's main entry — which also lets .ts consumers resolve .ets modules. - ArkUI dynamic-dispatch bridges, all provenance:'heuristic' with wiring-site metadata: assignment-gated state->build() re-render (V1 @State family + V2 @Local/@Provider/@Consumer), @ohos.events.emitter emit->subscriber pairing on static event keys (numeric ids same-file, named constants same-module, fan-out capped), and router.pushUrl literal urls -> the target page's @Entry struct. - $r/$rawfile resource intrinsics treated as built-ins; arkts joins the web language family, value-reference edges, re-export chase, and the other TS-applicable gates. Also ships a language-agnostic index-completeness guard: indexAll stamps index_state (indexing -> complete/partial/failed), reconciles discovered vs accounted files (a loaded run silently dropped 37 files), and codegraph status surfaces truncated/partial indexes in human and --json output. Validated on HarmoneyOpenEye (82 files), CoolMallArkTS (528, modular ohpm + ArkUI V2), and openharmony/applications_app_samples (11,693 files, 202,890 nodes stable across re-index, attribute false-positive audit 36,840 -> 588 residual all-plausible). Supersedes PRs #656 and #988 with credit — both informed this implementation. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f8cdbe3c67
commit
99152212a9
@@ -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
|
||||
|
||||
@@ -47,6 +47,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
|
||||
erlang: 'tree-sitter-erlang.wasm',
|
||||
solidity: 'tree-sitter-solidity.wasm',
|
||||
terraform: 'tree-sitter-terraform.wasm',
|
||||
arkts: 'tree-sitter-arkts.wasm',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
|
||||
// ESM/CJS TypeScript module extensions — parsed as TS (no JSX). (#366)
|
||||
'.mts': 'typescript',
|
||||
'.cts': 'typescript',
|
||||
// ArkTS (HarmonyOS / OpenHarmony) — a TypeScript superset with declarative
|
||||
// UI (`@Component struct` + `build()`). Own grammar (a tree-sitter-typescript
|
||||
// -style fork); plain `.ts` in an ArkTS project stays TypeScript. (#648)
|
||||
'.ets': 'arkts',
|
||||
'.js': 'javascript',
|
||||
'.mjs': 'javascript',
|
||||
'.cjs': 'javascript',
|
||||
@@ -292,7 +297,13 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
|
||||
// ship HCL/Terraform at all, so we vendor the prebuilt
|
||||
// tree-sitter-terraform.wasm from @tree-sitter-grammars/tree-sitter-hcl
|
||||
// 1.2.0 (Apache-2.0) — byte-identical to the npm package's artifact.
|
||||
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform')
|
||||
// ArkTS: tree-sitter-wasms doesn't ship it either; we vendor the prebuilt
|
||||
// tree-sitter-arkts.wasm from the tree-sitter-arkts 0.2.0 npm package
|
||||
// (harmony-contrib/tree-sitter-arkts, MIT) — byte-identical to the npm
|
||||
// tarball's artifact. It extends the tree-sitter-javascript grammar the
|
||||
// same way tree-sitter-typescript does, adding `struct_declaration` and
|
||||
// the `arkui_component_expression` build() DSL.
|
||||
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform' || lang === 'arkts')
|
||||
? path.join(__dirname, 'wasm', wasmFile)
|
||||
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
|
||||
const language = await WasmLanguage.load(wasmPath);
|
||||
@@ -518,6 +529,7 @@ export function getLanguageDisplayName(language: Language): string {
|
||||
vbnet: 'Visual Basic .NET',
|
||||
erlang: 'Erlang',
|
||||
terraform: 'Terraform',
|
||||
arkts: 'ArkTS',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
return names[language] || language;
|
||||
|
||||
@@ -83,6 +83,14 @@ export interface IndexResult {
|
||||
filesIndexed: number;
|
||||
filesSkipped: number;
|
||||
filesErrored: number;
|
||||
/**
|
||||
* How many indexable files the scan discovered — the ground truth the
|
||||
* indexed/skipped/errored tallies must add up to. A shortfall means files
|
||||
* were silently dropped mid-pipeline (e.g. a killed worker under load) and
|
||||
* the index is PARTIAL; callers surface that rather than trusting the
|
||||
* counts. Only set by full-index runs (indexAll), not indexFiles/sync.
|
||||
*/
|
||||
filesDiscovered?: number;
|
||||
nodesCreated: number;
|
||||
edgesCreated: number;
|
||||
errors: ExtractionError[];
|
||||
@@ -1512,6 +1520,7 @@ export class ExtractionOrchestrator {
|
||||
filesIndexed,
|
||||
filesSkipped,
|
||||
filesErrored,
|
||||
filesDiscovered: total,
|
||||
nodesCreated: totalNodes,
|
||||
edgesCreated: totalEdges,
|
||||
errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
|
||||
@@ -1645,6 +1654,7 @@ export class ExtractionOrchestrator {
|
||||
filesIndexed,
|
||||
filesSkipped,
|
||||
filesErrored,
|
||||
filesDiscovered: total,
|
||||
nodesCreated: totalNodes,
|
||||
edgesCreated: totalEdges,
|
||||
errors,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { LanguageExtractor } from '../tree-sitter-types';
|
||||
import { typescriptExtractor } from './typescript';
|
||||
import type { Node as SyntaxNode } from 'web-tree-sitter';
|
||||
|
||||
/**
|
||||
* ArkTS (HarmonyOS / OpenHarmony, `.ets`) — a TypeScript superset whose
|
||||
* headline feature is declarative UI: an `@Component struct` with a `build()`
|
||||
* method describing the view tree, `@State`/`@Prop`/`@Link` reactive
|
||||
* properties, and global `@Builder`/`@Extend`/`@Styles` functions.
|
||||
*
|
||||
* The vendored grammar (harmony-contrib/tree-sitter-arkts) extends
|
||||
* tree-sitter-javascript exactly the way tree-sitter-typescript does, so every
|
||||
* TS node type — and therefore the whole typescriptExtractor — applies
|
||||
* verbatim. ArkTS-specific shapes it adds:
|
||||
*
|
||||
* - `struct_declaration` / `struct_body` — the `@Component struct`. Same
|
||||
* `name:`/`body:` fields as class_declaration; members are ordinary
|
||||
* `method_definition` / `public_field_definition` nodes, so struct members
|
||||
* extract through the standard class-member paths.
|
||||
* - `arkui_component_expression` — a build()-DSL component instantiation
|
||||
* (`Column() { … }`). Carries a `function:` field (the component), an
|
||||
* optional `children:` block, and — unlike TS — the CHAINED ATTRIBUTES as
|
||||
* repeated `property:`/`arguments:` field pairs on the SAME node
|
||||
* (`Text(x).fontSize(16).opacity(0.6)` is ONE node, not nested calls).
|
||||
* Handled by the arkts branch in extractCall (tree-sitter.ts).
|
||||
* - Decorators on functions (`@Builder function F() {}`) — invalid in TS,
|
||||
* first-class here (a `decorator:` field on function_declaration), so the
|
||||
* core's existing extractDecoratorsFor path captures them.
|
||||
*/
|
||||
|
||||
/** Reactive/state decorators that make a member worth flagging (searchable). */
|
||||
const DECORATED_MEMBER_TYPES = new Set([
|
||||
'struct_declaration',
|
||||
'public_field_definition',
|
||||
'method_definition',
|
||||
'function_declaration',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Collect decorator names for a declaration from BOTH positions the grammar
|
||||
* produces: direct `decorator` children (`@Entry @Component struct X`,
|
||||
* `@State count` on a field) and preceding `decorator` siblings (`@Builder`
|
||||
* before a method_definition inside struct_body; `@Component` on the
|
||||
* export_statement wrapping `export struct X`). The backwards sibling walk
|
||||
* stops at the first non-decorator so an earlier declaration's decorators
|
||||
* never leak in (mirrors extractDecoratorsFor's sibling pass).
|
||||
*/
|
||||
function collectDecoratorNames(node: SyntaxNode): string[] | undefined {
|
||||
const names: string[] = [];
|
||||
const nameOf = (dec: SyntaxNode): string | undefined => {
|
||||
for (let i = 0; i < dec.namedChildCount; i++) {
|
||||
const child = dec.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (child.type === 'identifier') return child.text;
|
||||
if (child.type === 'call_expression') {
|
||||
// `@StorageLink('theme')` / `@Extend(Text)` — the decorator name is
|
||||
// the callee.
|
||||
const fn = child.childForFieldName('function');
|
||||
if (fn?.type === 'identifier') return fn.text;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'decorator') {
|
||||
const n = nameOf(child);
|
||||
if (n) names.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
const parent = node.parent;
|
||||
if (parent) {
|
||||
// Find this node among the parent's named children by start offset
|
||||
// (wrapper identity is not stable across navigation), then walk backwards.
|
||||
const start = node.startIndex;
|
||||
let idx = -1;
|
||||
for (let i = 0; i < parent.namedChildCount; i++) {
|
||||
const sib = parent.namedChild(i);
|
||||
if (sib && sib.startIndex === start) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
const sib = parent.namedChild(i);
|
||||
if (!sib || sib.type !== 'decorator') break;
|
||||
const n = nameOf(sib);
|
||||
if (n) names.unshift(n);
|
||||
}
|
||||
}
|
||||
|
||||
return names.length > 0 ? names : undefined;
|
||||
}
|
||||
|
||||
export const arktsExtractor: LanguageExtractor = {
|
||||
...typescriptExtractor,
|
||||
|
||||
// `@Component struct X { … }` — extractStruct handles it (kind `struct`,
|
||||
// members extracted like class members, `this.m()` resolution and the
|
||||
// class/struct containment gates in the name-matcher all apply as-is). The
|
||||
// component-ness is preserved on the node's decorators (`Component`,
|
||||
// `Entry`, `CustomDialog`, `Reusable`), captured by extractModifiers below.
|
||||
structTypes: ['struct_declaration'],
|
||||
|
||||
// build()-DSL component instantiations are call sites: `TodoRow({...})`
|
||||
// inside a parent's build() is the parent→child component edge, resolved by
|
||||
// the ordinary call pipeline against the child's struct node. The arkts
|
||||
// branch in extractCall also lifts each chained `.attr(...)` (emitted
|
||||
// dot-prefixed so it can ONLY resolve to `@Extend`/`@Styles`/`@Builder`
|
||||
// attribute helpers — see matchReference) and `.onXxx(this.handler)`
|
||||
// method-reference bindings. `leading_dot_expression` is the detached-chain
|
||||
// shape the grammar produces when a nested component's chain starts on the
|
||||
// line after its closing `}` inside arkui_children.
|
||||
callTypes: ['call_expression', 'arkui_component_expression', 'leading_dot_expression'],
|
||||
|
||||
// Surface ArkTS decorators on the node's `decorators` list (searchable, and
|
||||
// the hook a future ArkUI state→build synthesizer keys off). Core paths
|
||||
// already emit `decorates` REFERENCES for classes/methods/properties/
|
||||
// functions; this hook is what puts the names on struct nodes too —
|
||||
// extractStruct has no extractDecoratorsFor call, and node.decorators is
|
||||
// only populated via extractModifiers (see createNode).
|
||||
extractModifiers: (node) => {
|
||||
if (!DECORATED_MEMBER_TYPES.has(node.type)) return undefined;
|
||||
return collectDecoratorNames(node);
|
||||
},
|
||||
};
|
||||
@@ -34,6 +34,7 @@ import { vbnetExtractor } from './vbnet';
|
||||
import { erlangExtractor } from './erlang';
|
||||
import { solidityExtractor } from './solidity';
|
||||
import { terraformExtractor } from './terraform';
|
||||
import { arktsExtractor } from './arkts';
|
||||
|
||||
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
|
||||
typescript: typescriptExtractor,
|
||||
@@ -65,4 +66,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
|
||||
erlang: erlangExtractor,
|
||||
solidity: solidityExtractor,
|
||||
terraform: terraformExtractor,
|
||||
arkts: arktsExtractor,
|
||||
};
|
||||
|
||||
@@ -373,7 +373,7 @@ export class TreeSitterExtractor {
|
||||
// Value-reference edges (default ON; set CODEGRAPH_VALUE_REFS=0 to disable; see flushValueRefs).
|
||||
// Same-file reads of file-scope const/var symbols → `references` edges so impact analysis catches
|
||||
// value consumers ("change this constant/table, affect its readers").
|
||||
private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift', 'dart', 'pascal']);
|
||||
private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx', 'arkts', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift', 'dart', 'pascal']);
|
||||
private static readonly MAX_VALUE_REF_NODES = 20_000;
|
||||
private readonly valueRefsEnabled = process.env.CODEGRAPH_VALUE_REFS !== '0';
|
||||
private fileScopeValues = new Map<string, string>();
|
||||
@@ -1183,7 +1183,8 @@ export class TreeSitterExtractor {
|
||||
else if (
|
||||
nodeType === 'export_statement' &&
|
||||
(this.language === 'typescript' || this.language === 'tsx' ||
|
||||
this.language === 'javascript' || this.language === 'jsx') &&
|
||||
this.language === 'javascript' || this.language === 'jsx' ||
|
||||
this.language === 'arkts') &&
|
||||
getChildByField(node, 'source')
|
||||
) {
|
||||
const parentId = this.nodeStack[this.nodeStack.length - 1];
|
||||
@@ -2487,7 +2488,8 @@ export class TreeSitterExtractor {
|
||||
|
||||
// Extract variable declarators based on language
|
||||
if (this.language === 'typescript' || this.language === 'javascript' ||
|
||||
this.language === 'tsx' || this.language === 'jsx' || this.language === 'cfscript') {
|
||||
this.language === 'tsx' || this.language === 'jsx' || this.language === 'cfscript' ||
|
||||
this.language === 'arkts') {
|
||||
// Handle lexical_declaration and variable_declaration
|
||||
// These contain one or more variable_declarator children
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
@@ -2916,7 +2918,7 @@ export class TreeSitterExtractor {
|
||||
// property/method nodes under the type alias so `recorder.stop()`
|
||||
// can attach the call edge to `RecorderHandle.stop` instead of
|
||||
// an unrelated class method picked by path-proximity (#359).
|
||||
if (this.language === 'typescript' || this.language === 'tsx') {
|
||||
if (this.language === 'typescript' || this.language === 'tsx' || this.language === 'arkts') {
|
||||
this.extractTsTypeAliasMembers(value, typeAliasNode);
|
||||
// `type List = [ Service<'name', Req, Resp>, … ]` — surface each
|
||||
// entry's string-literal name as a searchable member (issue #634).
|
||||
@@ -3132,7 +3134,8 @@ export class TreeSitterExtractor {
|
||||
// called/typed symbols still record a cross-file dependency (TS/JS only).
|
||||
if (
|
||||
this.language === 'typescript' || this.language === 'tsx' ||
|
||||
this.language === 'javascript' || this.language === 'jsx'
|
||||
this.language === 'javascript' || this.language === 'jsx' ||
|
||||
this.language === 'arkts'
|
||||
) {
|
||||
const parentId = this.nodeStack[this.nodeStack.length - 1];
|
||||
if (parentId) this.emitImportBindingRefs(node, parentId);
|
||||
@@ -3894,6 +3897,176 @@ export class TreeSitterExtractor {
|
||||
return;
|
||||
}
|
||||
|
||||
// ArkTS build()-DSL handling. Three shapes carry UI-attribute chains, and
|
||||
// all of their attribute names are emitted with a LEADING DOT
|
||||
// (`.titleStyle`, `.width`) — an impossible identifier that routes them to
|
||||
// a dedicated matcher strategy resolving ONLY to decorator-marked
|
||||
// attribute helpers (`@Extend`/`@Styles`/`@AnimatableExtend`/`@Builder`
|
||||
// functions). Bare names would go through global name matching, where
|
||||
// framework attributes (`.width`, `.fontSize`, appearing on nearly every
|
||||
// UI line) hit arbitrary same-named symbols — measured on the OpenHarmony
|
||||
// samples monorepo, that produced 36k wrong edges (17% of all calls),
|
||||
// including single properties with 3,400+ false callers.
|
||||
//
|
||||
// 1. `Column({space:8}) { … }.height('100%')` — ONE
|
||||
// arkui_component_expression: `function:` = the component, chained
|
||||
// attributes as repeated `property:`/`arguments:` field pairs.
|
||||
// The component ref (`Column`, `TodoRow`) stays a PLAIN name — it
|
||||
// resolves to the child `@Component struct`, giving the parent→child
|
||||
// component-tree edge the way JSX children do for React.
|
||||
// 2. `Image(x).width(10).onClick(this.f)` — ordinary nested
|
||||
// call_expressions whose `function:` is a member_expression chained
|
||||
// on a CALL RESULT (never a named receiver, so `svc.save()` /
|
||||
// `this.vm.load()` are untouched and fall through to the generic
|
||||
// paths below).
|
||||
// 3. A nested component whose chain starts on the line AFTER its
|
||||
// closing `}` inside arkui_children — the grammar detaches the chain
|
||||
// into sibling `leading_dot_expression(identifier)` +
|
||||
// `parenthesized_expression(args)` statement pairs; reassemble from
|
||||
// the siblings.
|
||||
//
|
||||
// `.onXxx(this.handler)` METHOD-REFERENCE bindings (no call parens, so
|
||||
// nothing else records them) additionally emit a call ref to the bare
|
||||
// handler name — same-class resolution links the tap→handler hop.
|
||||
// Arrow-function handlers need nothing: their bodies' calls already
|
||||
// attribute to the enclosing build(). Children/argument subtrees are
|
||||
// still walked by the caller, so nested components extract normally.
|
||||
if (this.language === 'arkts') {
|
||||
const emitAttr = (nameNode: SyntaxNode): void => {
|
||||
const attrName = getNodeText(nameNode, this.source);
|
||||
if (!attrName) return;
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: callerId,
|
||||
referenceName: '.' + attrName,
|
||||
referenceKind: 'calls',
|
||||
line: nameNode.startPosition.row + 1,
|
||||
column: nameNode.startPosition.column,
|
||||
});
|
||||
};
|
||||
// Emit `handler` for each bare `this.handler` among an on-attribute's
|
||||
// arguments.
|
||||
const emitThisHandlers = (args: SyntaxNode | null): void => {
|
||||
if (!args) return;
|
||||
for (let j = 0; j < args.namedChildCount; j++) {
|
||||
const arg = args.namedChild(j);
|
||||
if (arg?.type !== 'member_expression') continue;
|
||||
const obj = getChildByField(arg, 'object');
|
||||
const prop = getChildByField(arg, 'property');
|
||||
if (obj?.type === 'this' && prop) {
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: callerId,
|
||||
referenceName: getNodeText(prop, this.source),
|
||||
referenceKind: 'calls',
|
||||
line: arg.startPosition.row + 1,
|
||||
column: arg.startPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Shape 1: arkui_component_expression with property/arguments pairs.
|
||||
if (node.type === 'arkui_component_expression') {
|
||||
const componentField = getChildByField(node, 'function');
|
||||
if (componentField && componentField.type === 'identifier') {
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: callerId,
|
||||
referenceName: getNodeText(componentField, this.source),
|
||||
referenceKind: 'calls',
|
||||
line: node.startPosition.row + 1,
|
||||
column: node.startPosition.column,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child || child.type !== 'property_identifier') continue;
|
||||
emitAttr(child);
|
||||
if (/^on[A-Z]/.test(getNodeText(child, this.source))) {
|
||||
// The attribute's arguments node is the next `arguments`-typed
|
||||
// child before the following attribute name.
|
||||
let args: SyntaxNode | null = null;
|
||||
for (let k = i + 1; k < node.childCount; k++) {
|
||||
const next = node.child(k);
|
||||
if (!next) continue;
|
||||
if (next.type === 'property_identifier') break;
|
||||
if (next.type === 'arguments') {
|
||||
args = next;
|
||||
break;
|
||||
}
|
||||
}
|
||||
emitThisHandlers(args);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Shape 2: fluent chain on a call result —
|
||||
// call_expression(function: member_expression(object: <call>)), or the
|
||||
// grammar's DSL-specific arkui_dsl_decorator_member_expression (same
|
||||
// object/property fields; produced e.g. by `Column() { … }.alignItems(x)`
|
||||
// in some chain positions — it ONLY occurs in attribute chains).
|
||||
if (node.type === 'call_expression') {
|
||||
const fn = getChildByField(node, 'function');
|
||||
if (fn?.type === 'member_expression' || fn?.type === 'arkui_dsl_decorator_member_expression') {
|
||||
const obj = getChildByField(fn, 'object');
|
||||
const prop = getChildByField(fn, 'property');
|
||||
if (
|
||||
prop &&
|
||||
(fn.type === 'arkui_dsl_decorator_member_expression' ||
|
||||
obj?.type === 'call_expression' ||
|
||||
obj?.type === 'arkui_component_expression')
|
||||
) {
|
||||
emitAttr(prop);
|
||||
if (/^on[A-Z]/.test(getNodeText(prop, this.source))) {
|
||||
emitThisHandlers(getChildByField(node, 'arguments'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The INNERMOST call of a proper-form detached chain
|
||||
// (`.alignItems(x).layoutWeight(1)…` under a leading_dot_expression)
|
||||
// has a BARE IDENTIFIER function — the leading dot was consumed by
|
||||
// the wrapper, so it masquerades as a plain `alignItems(...)` call.
|
||||
// Walk up the member/call alternation; topping out at
|
||||
// leading_dot_expression means the dot belongs to this chain.
|
||||
if (fn?.type === 'identifier') {
|
||||
let p: SyntaxNode | null = node.parent;
|
||||
while (p && (p.type === 'member_expression' || p.type === 'call_expression')) {
|
||||
p = p.parent;
|
||||
}
|
||||
if (p?.type === 'leading_dot_expression') {
|
||||
emitAttr(fn);
|
||||
if (/^on[A-Z]/.test(getNodeText(fn, this.source))) {
|
||||
emitThisHandlers(getChildByField(node, 'arguments'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Not a chained attribute — fall through to the generic call paths.
|
||||
}
|
||||
|
||||
// Shape 3: detached chain segment — leading_dot_expression whose only
|
||||
// named child is a bare identifier; its arguments sit in the NEXT
|
||||
// sibling statement as a parenthesized_expression.
|
||||
if (node.type === 'leading_dot_expression') {
|
||||
const only = node.namedChildCount === 1 ? node.namedChild(0) : null;
|
||||
if (only && only.type === 'identifier') {
|
||||
emitAttr(only);
|
||||
if (/^on[A-Z]/.test(getNodeText(only, this.source))) {
|
||||
const stmt = node.parent; // expression_statement
|
||||
const nextStmt = stmt?.nextNamedSibling;
|
||||
const paren = nextStmt?.namedChild(0);
|
||||
if (paren?.type === 'parenthesized_expression') {
|
||||
emitThisHandlers(paren);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The proper form (child is a call_expression chain, as inside
|
||||
// `@Extend` bodies) needs nothing here — the walker descends into it
|
||||
// and the inner call_expressions take the paths above.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the function/method being called
|
||||
let calleeName = '';
|
||||
|
||||
@@ -5442,7 +5615,7 @@ export class TreeSitterExtractor {
|
||||
* Languages that support type annotations (TypeScript, etc.)
|
||||
*/
|
||||
private readonly TYPE_ANNOTATION_LANGUAGES = new Set([
|
||||
'typescript', 'tsx', 'dart', 'kotlin', 'swift', 'rust', 'go', 'java', 'csharp', 'scala', 'php',
|
||||
'typescript', 'tsx', 'arkts', 'dart', 'kotlin', 'swift', 'rust', 'go', 'java', 'csharp', 'scala', 'php',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -417,6 +417,269 @@ function flutterBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive ArkUI property decorators: assigning a property carrying one of
|
||||
* these re-runs the owning struct's `build()`. Covers both state models —
|
||||
* V1 (`@Component`: State/Prop/Link/Provide/Consume/Storage*) and V2
|
||||
* (`@ComponentV2`: Local/Provider/Consumer; `@Param` is read-only in V2 so
|
||||
* the assignment gate never fires on it, and `@Trace` lives on `@ObservedV2`
|
||||
* data classes, not struct properties).
|
||||
*/
|
||||
const ARKUI_REACTIVE_DECORATORS = new Set([
|
||||
'State', 'Prop', 'Link', 'Provide', 'Consume', 'StorageLink', 'StorageProp',
|
||||
'LocalStorageLink', 'LocalStorageProp', 'ObjectLink',
|
||||
'Local', 'Provider', 'Consumer',
|
||||
]);
|
||||
|
||||
/** ArkUI-observed array mutators — `this.todos.push(x)` re-renders like an assignment. */
|
||||
const ARKUI_ARRAY_MUTATORS = 'push|pop|shift|unshift|splice|sort|reverse|fill';
|
||||
|
||||
/**
|
||||
* Phase 4b-ets: ArkUI state → build (the ArkTS analog of react-render /
|
||||
* flutter-build). Assigning a reactive-decorated property (`@State count`,
|
||||
* `@Link selected`, …) re-runs the `@Component struct`'s `build()`, but that
|
||||
* hop is framework-internal — no static edge — so "onClick → markAllDone →
|
||||
* this.todos = […] → rebuilt list" dead-ends at the assignment. Bridge it:
|
||||
* for each arkts struct with a `build()` method and at least one reactive
|
||||
* property, link every sibling method whose body ASSIGNS (or array-mutates)
|
||||
* one of those properties → `build`. Assignment-gated on the struct's OWN
|
||||
* reactive property names — a method that merely reads state, or a struct
|
||||
* with no reactive properties, gets nothing (this is the precision line the
|
||||
* all-sibling-methods design would erase).
|
||||
*/
|
||||
function arkuiStateBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const struct of queries.getNodesByKind('struct')) {
|
||||
if (struct.language !== 'arkts') continue;
|
||||
const children = queries.getOutgoingEdges(struct.id, ['contains'])
|
||||
.map((e) => queries.getNodeById(e.target))
|
||||
.filter((n): n is Node => !!n);
|
||||
const build = children.find((n) => n.kind === 'method' && n.name === 'build');
|
||||
if (!build) continue;
|
||||
const reactiveProps = children.filter(
|
||||
(n) => n.kind === 'property' && (n.decorators ?? []).some((d) => ARKUI_REACTIVE_DECORATORS.has(d))
|
||||
);
|
||||
if (reactiveProps.length === 0) continue;
|
||||
const propAlternation = reactiveProps
|
||||
.map((p) => p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('|');
|
||||
// `this.count = …` / `+=` / `++` / `--` / `this.todos.push(…)`. The
|
||||
// `=(?!=)` keeps `this.done == x` comparisons out.
|
||||
const mutationRe = new RegExp(
|
||||
`this\\.(?:${propAlternation})\\s*(?:=(?!=)|\\+\\+|--|[+\\-*/%&|^]=|\\.(?:${ARKUI_ARRAY_MUTATORS})\\s*\\()`
|
||||
);
|
||||
let added = 0;
|
||||
for (const m of children) {
|
||||
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
|
||||
if (m.kind !== 'method' || m.id === build.id) continue;
|
||||
const content = ctx.readFile(m.filePath);
|
||||
const src = content && sliceLines(content, m.startLine, m.endLine);
|
||||
if (!src || !mutationRe.test(stripCommentsForRegex(src, 'typescript'))) continue;
|
||||
const key = `${m.id}>${build.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: m.id, target: build.id, kind: 'calls', line: m.startLine,
|
||||
provenance: 'heuristic',
|
||||
metadata: { synthesizedBy: 'arkui-state', via: 'state assignment', registeredAt: `${build.filePath}:${build.startLine}` },
|
||||
});
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** Emit/subscribe call sites of HarmonyOS's `@ohos.events.emitter` bus. */
|
||||
const ARKUI_EMITTER_CALL_RE = /\bemitter\s*\.\s*(emit|on|once)\s*\(\s*([A-Za-z_$][\w$.]*|\{[^)]{0,120}?\beventId\s*:\s*[^,}]+[^)]*?\})/g;
|
||||
|
||||
/** Cap per event bucket — a generic key with many parties is dynamic routing, not a static pair. */
|
||||
const ARKUI_EMITTER_FANOUT_CAP = 8;
|
||||
|
||||
/**
|
||||
* Phase 4b-ets2: HarmonyOS `@ohos.events.emitter` bridge. The cross-component
|
||||
* bus — `emitter.emit(eventId)` fires `emitter.on(eventId, cb)` — is
|
||||
* framework-internal, so an order flow riding it (OrangeShopping's
|
||||
* add-to-cart) dead-ends at the emit. Link emit-site enclosing
|
||||
* function/method → on/once-site enclosing function/method when both
|
||||
* reference the SAME statically-recoverable event key.
|
||||
*
|
||||
* Key recovery, per call site (comment-stripped enclosing-file source): the
|
||||
* first argument is an `{ eventId: K }` literal, a `Names.Dotted` constant, or
|
||||
* a local whose same-file declaration is `new EventsId(K)` / `= K` — chase one
|
||||
* level. Precision scoping learned from the samples monorepo (thousands of
|
||||
* unrelated samples, most using eventId 1): NUMERIC keys pair within the same
|
||||
* FILE only; NAMED keys pair within the same workspace module directory (or
|
||||
* the whole project when it declares no modules — the single-app case), both
|
||||
* behind a fan-out cap. Inline `on(id, (e) => {…})` arrows need no special
|
||||
* handling — their bodies' calls already attribute to the registering method,
|
||||
* so targeting that method keeps the chain connected.
|
||||
*/
|
||||
function arkuiEmitterEdges(ctx: ResolutionContext): Edge[] {
|
||||
interface Site { nodeId: string; file: string; line: number }
|
||||
// bucket key -> emit sites / handler sites
|
||||
const emits = new Map<string, Site[]>();
|
||||
const handlers = new Map<string, Site[]>();
|
||||
|
||||
const moduleDirs = (() => {
|
||||
const ws = ctx.getWorkspacePackages?.();
|
||||
return ws ? [...new Set(ws.byName.values())].sort((a, b) => b.length - a.length) : [];
|
||||
})();
|
||||
const moduleScopeOf = (file: string): string => {
|
||||
for (const dir of moduleDirs) {
|
||||
if (file === dir || file.startsWith(dir + '/')) return dir;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
for (const file of ctx.getAllFiles()) {
|
||||
if (!file.endsWith('.ets')) continue;
|
||||
const content = ctx.readFile(file);
|
||||
if (!content || !content.includes('emitter.')) continue;
|
||||
const safe = stripCommentsForRegex(content, 'typescript');
|
||||
const nodes = ctx.getNodesInFile(file)
|
||||
.filter((n) => n.kind === 'method' || n.kind === 'function');
|
||||
|
||||
ARKUI_EMITTER_CALL_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ARKUI_EMITTER_CALL_RE.exec(safe))) {
|
||||
const verb = m[1]!;
|
||||
const arg = m[2]!.trim();
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const encl = nodes
|
||||
.filter((n) => n.startLine <= line && n.endLine >= line)
|
||||
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
|
||||
if (!encl) continue;
|
||||
|
||||
// Recover the event key from the first argument.
|
||||
let key: string | null = null;
|
||||
const idLit = arg.startsWith('{') ? arg.match(/\beventId\s*:\s*([\w$.]+)/)?.[1] : undefined;
|
||||
const token = idLit ?? arg;
|
||||
if (token !== undefined) {
|
||||
if (/^\d+$/.test(token)) {
|
||||
key = `num:${file}:${token}`; // numeric: same-file only
|
||||
} else if (token.includes('.')) {
|
||||
key = `name:${moduleScopeOf(file)}:${token}`;
|
||||
} else {
|
||||
// Local variable — chase its same-file declaration one level:
|
||||
// `let x = new EventsId(K)` / `const x = K`.
|
||||
const declRe = new RegExp(
|
||||
`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b\\s*(?::[^=\\n]+)?=\\s*(?:new\\s+[\\w$.]+\\(\\s*([^)\\n]+?)\\s*\\)|([\\w$.]+))`
|
||||
);
|
||||
const decl = safe.match(declRe);
|
||||
const inner = (decl?.[1] ?? decl?.[2])?.trim();
|
||||
if (inner && /^\d+$/.test(inner)) key = `num:${file}:${inner}`;
|
||||
else if (inner && /^[\w$.]+$/.test(inner)) key = `name:${moduleScopeOf(file)}:${inner}`;
|
||||
}
|
||||
}
|
||||
if (!key) continue;
|
||||
|
||||
const site: Site = { nodeId: encl.id, file, line };
|
||||
if (verb === 'emit') {
|
||||
(emits.get(key) ?? emits.set(key, []).get(key)!).push(site);
|
||||
} else {
|
||||
(handlers.get(key) ?? handlers.set(key, []).get(key)!).push(site);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [key, emitSites] of emits) {
|
||||
const handlerSites = handlers.get(key);
|
||||
if (!handlerSites) continue;
|
||||
if (emitSites.length > ARKUI_EMITTER_FANOUT_CAP || handlerSites.length > ARKUI_EMITTER_FANOUT_CAP) continue;
|
||||
const eventLabel = key.slice(key.lastIndexOf(':') + 1);
|
||||
for (const e of emitSites) for (const h of handlerSites) {
|
||||
if (e.nodeId === h.nodeId) continue;
|
||||
const dedupe = `${e.nodeId}>${h.nodeId}`;
|
||||
if (seen.has(dedupe)) continue;
|
||||
seen.add(dedupe);
|
||||
edges.push({
|
||||
source: e.nodeId, target: h.nodeId, kind: 'calls', line: e.line,
|
||||
provenance: 'heuristic',
|
||||
metadata: { synthesizedBy: 'arkui-emitter', event: eventLabel, registeredAt: `${h.file}:${h.line}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** `router.pushUrl({ url: 'pages/Detail' })` / replaceUrl — literal urls only. */
|
||||
const ARKUI_ROUTER_RE = /\brouter\s*\.\s*(?:pushUrl|replaceUrl)\s*\(\s*\{[^)]{0,200}?\burl\s*:\s*['"]([\w\-./]+)['"]/g;
|
||||
|
||||
/**
|
||||
* Phase 4b-ets3: HarmonyOS page navigation. `router.pushUrl({ url:
|
||||
* 'pages/Detail' })` reaches the `@Entry struct` of
|
||||
* `<module>/src/main/ets/pages/Detail.ets`, but the hop is a string — no
|
||||
* static edge — so "tap → openDetail → ???" ends at the router call. Bridge
|
||||
* literal urls to the page struct: the url resolves against the standard
|
||||
* `src/main/ets/` layout (what main_pages.json entries name); candidates
|
||||
* prefer the caller's own workspace module (routes are module-scoped), and
|
||||
* anything still ambiguous is dropped rather than guessed. Only `@Entry`
|
||||
* structs qualify as targets — the decorator is what makes a file a page.
|
||||
*/
|
||||
function arkuiRouterEdges(ctx: ResolutionContext): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const allFiles = ctx.getAllFiles();
|
||||
const moduleDirs = (() => {
|
||||
const ws = ctx.getWorkspacePackages?.();
|
||||
return ws ? [...new Set(ws.byName.values())].sort((a, b) => b.length - a.length) : [];
|
||||
})();
|
||||
const moduleScopeOf = (file: string): string => {
|
||||
for (const dir of moduleDirs) {
|
||||
if (file === dir || file.startsWith(dir + '/')) return dir;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
for (const file of allFiles) {
|
||||
if (!file.endsWith('.ets')) continue;
|
||||
const content = ctx.readFile(file);
|
||||
if (!content || !content.includes('router.')) continue;
|
||||
const safe = stripCommentsForRegex(content, 'typescript');
|
||||
const nodes = ctx.getNodesInFile(file)
|
||||
.filter((n) => n.kind === 'method' || n.kind === 'function');
|
||||
|
||||
ARKUI_ROUTER_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ARKUI_ROUTER_RE.exec(safe))) {
|
||||
const url = m[1]!;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const encl = nodes
|
||||
.filter((n) => n.startLine <= line && n.endLine >= line)
|
||||
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
|
||||
if (!encl) continue;
|
||||
|
||||
const suffix = `/src/main/ets/${url}.ets`;
|
||||
let candidates = allFiles.filter((f) => f.endsWith(suffix));
|
||||
if (candidates.length > 1) {
|
||||
const scope = moduleScopeOf(file);
|
||||
const sameModule = candidates.filter((f) => moduleScopeOf(f) === scope);
|
||||
if (sameModule.length > 0) candidates = sameModule;
|
||||
}
|
||||
if (candidates.length !== 1) continue; // ambiguous or unresolved — never guess
|
||||
|
||||
const page = ctx.getNodesInFile(candidates[0]!).find(
|
||||
(n) => n.kind === 'struct' && (n.decorators ?? []).includes('Entry')
|
||||
);
|
||||
if (!page) continue;
|
||||
|
||||
const key = `${encl.id}>${page.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: encl.id, target: page.id, kind: 'calls', line,
|
||||
provenance: 'heuristic',
|
||||
metadata: { synthesizedBy: 'arkui-route', event: url, registeredAt: `${candidates[0]}:${page.startLine}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4c: C++ virtual override. A call through a base/interface pointer
|
||||
* (`db->Get(...)`, `iter->Next()`) dispatches at runtime to a subclass override,
|
||||
@@ -485,6 +748,7 @@ function cppOverrideEdges(queries: QueryBuilder): Edge[] {
|
||||
// or an `object` (Scala) so the loop also iterates those kinds.
|
||||
const IFACE_OVERRIDE_LANGS = new Set([
|
||||
'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', 'go', 'rust',
|
||||
'arkts',
|
||||
]);
|
||||
/**
|
||||
* Go implicit interface satisfaction (#584). Go has no `implements` keyword — a
|
||||
@@ -2887,6 +3151,9 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
|
||||
const svelteKitEdges = svelteKitLoadEdges(ctx); await yieldToLoop();
|
||||
const pascalEdges = pascalFormEdges(ctx); await yieldToLoop();
|
||||
const flutterEdges = flutterBuildEdges(queries, ctx); await yieldToLoop();
|
||||
const arkuiStateEdges = arkuiStateBuildEdges(queries, ctx); await yieldToLoop();
|
||||
const arkuiEmitter = arkuiEmitterEdges(ctx); await yieldToLoop();
|
||||
const arkuiRoutes = arkuiRouterEdges(ctx); await yieldToLoop();
|
||||
const cppEdges = cppOverrideEdges(queries); await yieldToLoop();
|
||||
const ifaceEdges = interfaceOverrideEdges(queries); await yieldToLoop();
|
||||
const kotlinExpectActual = kotlinExpectActualEdges(queries); await yieldToLoop();
|
||||
@@ -2923,6 +3190,9 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
|
||||
...svelteKitEdges,
|
||||
...pascalEdges,
|
||||
...flutterEdges,
|
||||
...arkuiStateEdges,
|
||||
...arkuiEmitter,
|
||||
...arkuiRoutes,
|
||||
...cppEdges,
|
||||
...ifaceEdges,
|
||||
...kotlinExpectActual,
|
||||
|
||||
@@ -16,6 +16,11 @@ import { resolveWorkspaceImport } from './workspace-packages';
|
||||
*/
|
||||
const EXTENSION_RESOLUTION: Record<string, string[]> = {
|
||||
typescript: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js'],
|
||||
// ArkTS imports both `.ets` components and plain `.ts` logic modules —
|
||||
// HarmonyOS projects are always a mix. `/Index.ets` (capital I) is ohpm's
|
||||
// module-entry convention, hit when a bare workspace import ("data") is
|
||||
// rewritten to the member's directory; lowercase variants for safety.
|
||||
arkts: ['.ets', '.ts', '.d.ts', '.js', '/Index.ets', '/index.ets', '/index.ts', '/index.js'],
|
||||
javascript: ['.js', '.jsx', '.mjs', '.cjs', '/index.js', '/index.jsx'],
|
||||
tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js'],
|
||||
jsx: ['.jsx', '.js', '/index.jsx', '/index.js'],
|
||||
@@ -200,7 +205,7 @@ function isExternalImport(
|
||||
}
|
||||
|
||||
// Common external patterns
|
||||
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
|
||||
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx' || language === 'arkts') {
|
||||
// Node built-ins
|
||||
if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) {
|
||||
return true;
|
||||
@@ -649,7 +654,7 @@ export function extractImportMappings(
|
||||
): ImportMapping[] {
|
||||
const mappings: ImportMapping[] = [];
|
||||
|
||||
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
|
||||
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx' || language === 'arkts') {
|
||||
mappings.push(...extractJSImports(content));
|
||||
} else if (language === 'svelte' || language === 'vue' || language === 'astro') {
|
||||
// Svelte/Vue single-file components import via plain ES6 inside their
|
||||
@@ -1061,7 +1066,8 @@ export function extractReExports(content: string, language: Language): ReExport[
|
||||
language !== 'typescript' &&
|
||||
language !== 'javascript' &&
|
||||
language !== 'tsx' &&
|
||||
language !== 'jsx'
|
||||
language !== 'jsx' &&
|
||||
language !== 'arkts'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
@@ -1355,7 +1361,8 @@ export function resolveViaImport(
|
||||
ref.language === 'typescript' ||
|
||||
ref.language === 'tsx' ||
|
||||
ref.language === 'javascript' ||
|
||||
ref.language === 'jsx'
|
||||
ref.language === 'jsx' ||
|
||||
ref.language === 'arkts'
|
||||
) {
|
||||
const moduleFile = resolveModuleImportToFile(ref, imports, context);
|
||||
if (moduleFile) return moduleFile;
|
||||
|
||||
+18
-3
@@ -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;
|
||||
|
||||
@@ -140,7 +140,9 @@ function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node {
|
||||
const LANGUAGE_FAMILY: Record<string, string> = {
|
||||
java: 'jvm', kotlin: 'jvm', scala: 'jvm',
|
||||
swift: 'apple', objc: 'apple',
|
||||
typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web',
|
||||
// ArkTS is a TS superset — every HarmonyOS project mixes `.ets` UI with
|
||||
// `.ts` logic modules, so refs must cross freely between them.
|
||||
typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', arkts: 'web',
|
||||
c: 'c', cpp: 'c',
|
||||
// Razor/Blazor markup names C# types — same family so `@model Foo` /
|
||||
// `<MyComponent/>` resolve to their `.cs` class through the cross-family gate.
|
||||
@@ -226,6 +228,7 @@ export function matchFunctionRef(
|
||||
const bareFnOnly =
|
||||
ref.language === 'typescript' || ref.language === 'tsx' ||
|
||||
ref.language === 'javascript' || ref.language === 'jsx' ||
|
||||
ref.language === 'arkts' ||
|
||||
ref.language === 'cpp' || ref.language === 'python' ||
|
||||
ref.language === 'php';
|
||||
|
||||
@@ -1079,6 +1082,7 @@ function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
|
||||
case 'javascript':
|
||||
case 'tsx':
|
||||
case 'jsx':
|
||||
case 'arkts':
|
||||
return [
|
||||
new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger()
|
||||
// No keyword requirement, so this matches BOTH a local annotation
|
||||
@@ -1742,6 +1746,9 @@ export function matchFuzzy(
|
||||
/**
|
||||
* Match all strategies in order of confidence
|
||||
*/
|
||||
/** ArkUI attribute-helper decorators a `.attr(...)` chain may resolve to. */
|
||||
const ARKUI_ATTRIBUTE_DECORATORS = new Set(['Extend', 'Styles', 'AnimatableExtend', 'Builder']);
|
||||
|
||||
export function matchReference(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
@@ -1753,6 +1760,37 @@ export function matchReference(
|
||||
return matchFunctionRef(ref, context);
|
||||
}
|
||||
|
||||
// ArkTS chained UI attributes — emitted with a leading dot (`.titleStyle`,
|
||||
// `.width`) by the extractor — resolve ONLY to decorator-marked attribute
|
||||
// helpers: `@Extend`/`@Styles`/`@AnimatableExtend` functions (and global
|
||||
// `@Builder`s used attribute-position). Framework attributes (`.width`,
|
||||
// `.fontSize` — on nearly every UI line) match no such helper and stay
|
||||
// unresolved, NEVER falling through to bare-name matching: on a samples
|
||||
// monorepo that fallthrough manufactured 36k wrong edges, giving single
|
||||
// same-named properties thousands of false callers. Ambiguity rule matches
|
||||
// the rest of the file: several same-named helpers → prefer the call-site
|
||||
// file, still ambiguous → drop the ref rather than guess.
|
||||
if (ref.language === 'arkts' && ref.referenceName.startsWith('.')) {
|
||||
const base = ref.referenceName.slice(1);
|
||||
const candidates = context
|
||||
.getNodesByName(base)
|
||||
.filter(
|
||||
(n) =>
|
||||
n.language === 'arkts' &&
|
||||
n.kind === 'function' &&
|
||||
(n.decorators ?? []).some((d) => ARKUI_ATTRIBUTE_DECORATORS.has(d))
|
||||
);
|
||||
const chosen =
|
||||
candidates.length > 1 ? preferCallSiteFile(candidates, ref.filePath) : candidates;
|
||||
if (chosen.length !== 1) return null;
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: chosen[0]!.id,
|
||||
confidence: 0.85,
|
||||
resolvedBy: 'exact-match',
|
||||
};
|
||||
}
|
||||
|
||||
// Erlang `-behaviour(m)` refs target a MODULE. Letting them fall through to
|
||||
// bare-name matching grabs any same-named symbol — on emqx,
|
||||
// `-behaviour(supervisor)` resolved to a `-define(supervisor, …)` macro
|
||||
|
||||
@@ -31,6 +31,16 @@ import { logDebug } from '../errors';
|
||||
export interface WorkspacePackages {
|
||||
/** Member package `name` → directory relative to projectRoot (posix). */
|
||||
byName: Map<string, string>;
|
||||
/**
|
||||
* Member package `name` → its declared ENTRY FILE relative to projectRoot
|
||||
* (posix), when the member's manifest names one (ohpm's oh-package.json5
|
||||
* `"main": "Index.ets"`). Lets a bare `import { X } from "data"` resolve to
|
||||
* the member's real barrel even when it doesn't follow an index-file
|
||||
* convention — and independent of the CONSUMER's language (a `.ts` file
|
||||
* importing an `.ets` barrel resolves without `.ets` in the TS candidate
|
||||
* list). Absent for npm/pnpm members (their index conventions cover it).
|
||||
*/
|
||||
entryByName?: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,10 +53,9 @@ export interface WorkspacePackages {
|
||||
* the same way it does {@link loadProjectAliases} / {@link loadGoModule}.
|
||||
*/
|
||||
export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | null {
|
||||
const patterns = readWorkspaceGlobs(projectRoot);
|
||||
if (patterns.length === 0) return null;
|
||||
|
||||
const byName = new Map<string, string>();
|
||||
|
||||
const patterns = readWorkspaceGlobs(projectRoot);
|
||||
for (const pattern of patterns) {
|
||||
for (const dir of expandWorkspaceGlob(projectRoot, pattern)) {
|
||||
const pkgName = readPackageName(path.join(projectRoot, dir));
|
||||
@@ -54,10 +63,138 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages |
|
||||
if (pkgName && !byName.has(pkgName)) byName.set(pkgName, dir);
|
||||
}
|
||||
}
|
||||
|
||||
// HarmonyOS/OpenHarmony (ArkTS) modular projects: every module's
|
||||
// oh-package.json5 declares its local siblings as `"data": "file:../../
|
||||
// core/data"` dependencies, and code then imports the bare name
|
||||
// (`import { CartRepository } from "data"`). Same monorepo problem as npm
|
||||
// workspaces, different manifest.
|
||||
const entryByName = new Map<string, string>();
|
||||
for (const [name, dir] of collectOhpmFileDeps(projectRoot)) {
|
||||
if (byName.has(name)) continue;
|
||||
byName.set(name, dir);
|
||||
const entry = readOhpmMain(projectRoot, dir);
|
||||
if (entry) entryByName.set(name, entry);
|
||||
}
|
||||
|
||||
if (byName.size === 0) return null;
|
||||
|
||||
logDebug('workspace packages loaded', { count: byName.size });
|
||||
return { byName };
|
||||
return { byName, entryByName: entryByName.size > 0 ? entryByName : undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an ohpm member's declared entry file: `<dir>/oh-package.json5`'s
|
||||
* `main`, normalized to a projectRoot-relative posix path. Null when the
|
||||
* manifest or field is missing/escaping.
|
||||
*/
|
||||
function readOhpmMain(projectRoot: string, dirRel: string): string | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
parsed = require('jsonc-parser').parse(
|
||||
fs.readFileSync(path.join(projectRoot, dirRel, OHPM_MANIFEST), 'utf-8')
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const main = (parsed as { main?: unknown } | null)?.main;
|
||||
if (typeof main !== 'string' || !main.trim()) return null;
|
||||
const entryAbs = path.resolve(projectRoot, dirRel, main.trim());
|
||||
const entryRel = path.relative(projectRoot, entryAbs).replace(/\\/g, '/');
|
||||
if (entryRel.startsWith('..')) return null;
|
||||
return entryRel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the project for `oh-package.json5` manifests and collect their
|
||||
* `file:`-protocol dependencies as workspace members: dep name (what the
|
||||
* source imports) → target directory (projectRoot-relative posix).
|
||||
*
|
||||
* Precision rule: a name declared with DIFFERENT target directories in
|
||||
* different manifests (e.g. every sample in a samples monorepo has its own
|
||||
* "common") is AMBIGUOUS and dropped entirely — a missing edge beats a wrong
|
||||
* cross-module link. Registry dependencies (`@ohos/axios: "^2.0.0"`) don't
|
||||
* use `file:` and are ignored, staying external.
|
||||
*
|
||||
* The walk is bounded (depth + directory budget) and prunes build/dependency
|
||||
* dirs, so non-ArkTS projects pay one readdir at the root and nothing else
|
||||
* (they have no oh-package.json5 anywhere shallow).
|
||||
*/
|
||||
const OHPM_MANIFEST = 'oh-package.json5';
|
||||
const OHPM_WALK_MAX_DEPTH = 6;
|
||||
const OHPM_WALK_DIR_BUDGET = 8000;
|
||||
const OHPM_SKIP_DIRS = new Set([
|
||||
'node_modules', 'oh_modules', '.git', '.codegraph', '.hvigor', '.preview',
|
||||
'build', 'dist', 'out', 'oh-package-lock.json5',
|
||||
]);
|
||||
|
||||
function collectOhpmFileDeps(projectRoot: string): Map<string, string> {
|
||||
const byName = new Map<string, string>();
|
||||
const ambiguous = new Set<string>();
|
||||
|
||||
const queue: Array<{ rel: string; depth: number }> = [{ rel: '', depth: 0 }];
|
||||
let visited = 0;
|
||||
while (queue.length > 0) {
|
||||
const { rel, depth } = queue.shift()!;
|
||||
if (++visited > OHPM_WALK_DIR_BUDGET) break;
|
||||
const abs = path.join(projectRoot, rel);
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(abs, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory()) {
|
||||
if (depth >= OHPM_WALK_MAX_DEPTH) continue;
|
||||
if (e.name.startsWith('.') || OHPM_SKIP_DIRS.has(e.name)) continue;
|
||||
queue.push({ rel: rel ? `${rel}/${e.name}` : e.name, depth: depth + 1 });
|
||||
continue;
|
||||
}
|
||||
if (e.name !== OHPM_MANIFEST) continue;
|
||||
|
||||
const deps = readOhpmFileDeps(path.join(abs, e.name));
|
||||
for (const [name, target] of deps) {
|
||||
const targetAbs = path.resolve(abs, target);
|
||||
const targetRel = path.relative(projectRoot, targetAbs).replace(/\\/g, '/');
|
||||
if (targetRel.startsWith('..')) continue; // escapes the project
|
||||
const existing = byName.get(name);
|
||||
if (existing === undefined) {
|
||||
if (!ambiguous.has(name)) byName.set(name, targetRel);
|
||||
} else if (existing !== targetRel) {
|
||||
byName.delete(name);
|
||||
ambiguous.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return byName;
|
||||
}
|
||||
|
||||
/** Parse one oh-package.json5's dependencies → [name, file-target] pairs. */
|
||||
function readOhpmFileDeps(manifestAbs: string): Array<[string, string]> {
|
||||
const out: Array<[string, string]> = [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
// JSON5 tolerates comments and trailing commas; jsonc-parser (already a
|
||||
// dependency, used by the opencode installer target) handles both.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
parsed = require('jsonc-parser').parse(fs.readFileSync(manifestAbs, 'utf-8'));
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
const deps = (parsed as { dependencies?: Record<string, unknown> } | null)?.dependencies;
|
||||
if (!deps || typeof deps !== 'object') return out;
|
||||
for (const [name, value] of Object.entries(deps)) {
|
||||
if (typeof value !== 'string' || !value.startsWith('file:')) continue;
|
||||
const target = value.slice('file:'.length).trim();
|
||||
if (target) out.push([name, target]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,6 +219,13 @@ export function resolveWorkspaceImport(
|
||||
if (!bestName) return null;
|
||||
const dir = ws.byName.get(bestName)!;
|
||||
const subpath = importPath.slice(bestName.length); // '' or '/widgets'
|
||||
// A bare member import resolves straight to the member's declared entry
|
||||
// file when the manifest names one (ohpm `main`) — the caller's exact-path
|
||||
// check hits it without extension/index guessing.
|
||||
if (!subpath) {
|
||||
const entry = ws.entryByName?.get(bestName);
|
||||
if (entry) return entry;
|
||||
}
|
||||
return (dir + subpath).replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export const LANGUAGES = [
|
||||
'javascript',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'arkts',
|
||||
'python',
|
||||
'go',
|
||||
'rust',
|
||||
|
||||
Reference in New Issue
Block a user