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:
Colby Mchenry
2026-07-06 09:07:15 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent f8cdbe3c67
commit 99152212a9
21 changed files with 1695 additions and 21 deletions
+20
View File
@@ -354,6 +354,14 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`);
}
clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`);
// A PARTIAL index (files silently dropped mid-pipeline) must not pass
// as a clean run — it's the difference between "indexed the repo" and
// "indexed most of the repo, quietly". Only the completeness
// reconciliation warning; per-file extractor warnings stay in the
// error-code summary below.
for (const w of result.errors.filter((e) => e.code === 'index_partial')) {
clack.log.warn(w.message);
}
} else if (hasErrors) {
clack.log.error(`Indexing failed ${getGlyphs().dash} all ${formatNumber(result.filesErrored)} files had errors`);
} else {
@@ -798,6 +806,7 @@ program
const buildInfo = cg.getIndexBuildInfo();
const reindexRecommended = cg.isIndexStale();
const indexState = cg.getIndexState();
// JSON output mode
if (options.json) {
@@ -829,6 +838,10 @@ program
builtWithExtractionVersion: buildInfo.extractionVersion,
currentExtractionVersion: EXTRACTION_VERSION,
reindexRecommended,
// 'complete' | 'partial' (files silently dropped) | 'indexing'
// (a run was killed mid-index — the index is truncated) |
// 'failed' | null (predates the marker).
state: indexState,
},
}));
cg.destroy();
@@ -842,6 +855,13 @@ program
if (worktreeMismatch) {
warn(worktreeMismatchWarning(worktreeMismatch));
}
if (indexState === 'indexing') {
warn('The last index run never finished (killed mid-index?) — the index is truncated. Re-run "codegraph index".');
} else if (indexState === 'partial') {
warn('The last index run silently dropped files — the index is partial. Re-run "codegraph index".');
} else if (indexState === 'failed') {
warn('The last index run failed — results may be incomplete. Re-run "codegraph index".');
}
console.log();
// Index stats
+13 -1
View File
@@ -47,6 +47,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
erlang: 'tree-sitter-erlang.wasm',
solidity: 'tree-sitter-solidity.wasm',
terraform: 'tree-sitter-terraform.wasm',
arkts: 'tree-sitter-arkts.wasm',
};
/**
@@ -58,6 +59,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
// ESM/CJS TypeScript module extensions — parsed as TS (no JSX). (#366)
'.mts': 'typescript',
'.cts': 'typescript',
// ArkTS (HarmonyOS / OpenHarmony) — a TypeScript superset with declarative
// UI (`@Component struct` + `build()`). Own grammar (a tree-sitter-typescript
// -style fork); plain `.ts` in an ArkTS project stays TypeScript. (#648)
'.ets': 'arkts',
'.js': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
@@ -292,7 +297,13 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// ship HCL/Terraform at all, so we vendor the prebuilt
// tree-sitter-terraform.wasm from @tree-sitter-grammars/tree-sitter-hcl
// 1.2.0 (Apache-2.0) — byte-identical to the npm package's artifact.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform')
// ArkTS: tree-sitter-wasms doesn't ship it either; we vendor the prebuilt
// tree-sitter-arkts.wasm from the tree-sitter-arkts 0.2.0 npm package
// (harmony-contrib/tree-sitter-arkts, MIT) — byte-identical to the npm
// tarball's artifact. It extends the tree-sitter-javascript grammar the
// same way tree-sitter-typescript does, adding `struct_declaration` and
// the `arkui_component_expression` build() DSL.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform' || lang === 'arkts')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
@@ -518,6 +529,7 @@ export function getLanguageDisplayName(language: Language): string {
vbnet: 'Visual Basic .NET',
erlang: 'Erlang',
terraform: 'Terraform',
arkts: 'ArkTS',
unknown: 'Unknown',
};
return names[language] || language;
+10
View File
@@ -83,6 +83,14 @@ export interface IndexResult {
filesIndexed: number;
filesSkipped: number;
filesErrored: number;
/**
* How many indexable files the scan discovered — the ground truth the
* indexed/skipped/errored tallies must add up to. A shortfall means files
* were silently dropped mid-pipeline (e.g. a killed worker under load) and
* the index is PARTIAL; callers surface that rather than trusting the
* counts. Only set by full-index runs (indexAll), not indexFiles/sync.
*/
filesDiscovered?: number;
nodesCreated: number;
edgesCreated: number;
errors: ExtractionError[];
@@ -1512,6 +1520,7 @@ export class ExtractionOrchestrator {
filesIndexed,
filesSkipped,
filesErrored,
filesDiscovered: total,
nodesCreated: totalNodes,
edgesCreated: totalEdges,
errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
@@ -1645,6 +1654,7 @@ export class ExtractionOrchestrator {
filesIndexed,
filesSkipped,
filesErrored,
filesDiscovered: total,
nodesCreated: totalNodes,
edgesCreated: totalEdges,
errors,
+128
View File
@@ -0,0 +1,128 @@
import type { LanguageExtractor } from '../tree-sitter-types';
import { typescriptExtractor } from './typescript';
import type { Node as SyntaxNode } from 'web-tree-sitter';
/**
* ArkTS (HarmonyOS / OpenHarmony, `.ets`) — a TypeScript superset whose
* headline feature is declarative UI: an `@Component struct` with a `build()`
* method describing the view tree, `@State`/`@Prop`/`@Link` reactive
* properties, and global `@Builder`/`@Extend`/`@Styles` functions.
*
* The vendored grammar (harmony-contrib/tree-sitter-arkts) extends
* tree-sitter-javascript exactly the way tree-sitter-typescript does, so every
* TS node type — and therefore the whole typescriptExtractor — applies
* verbatim. ArkTS-specific shapes it adds:
*
* - `struct_declaration` / `struct_body` — the `@Component struct`. Same
* `name:`/`body:` fields as class_declaration; members are ordinary
* `method_definition` / `public_field_definition` nodes, so struct members
* extract through the standard class-member paths.
* - `arkui_component_expression` — a build()-DSL component instantiation
* (`Column() { … }`). Carries a `function:` field (the component), an
* optional `children:` block, and — unlike TS — the CHAINED ATTRIBUTES as
* repeated `property:`/`arguments:` field pairs on the SAME node
* (`Text(x).fontSize(16).opacity(0.6)` is ONE node, not nested calls).
* Handled by the arkts branch in extractCall (tree-sitter.ts).
* - Decorators on functions (`@Builder function F() {}`) — invalid in TS,
* first-class here (a `decorator:` field on function_declaration), so the
* core's existing extractDecoratorsFor path captures them.
*/
/** Reactive/state decorators that make a member worth flagging (searchable). */
const DECORATED_MEMBER_TYPES = new Set([
'struct_declaration',
'public_field_definition',
'method_definition',
'function_declaration',
]);
/**
* Collect decorator names for a declaration from BOTH positions the grammar
* produces: direct `decorator` children (`@Entry @Component struct X`,
* `@State count` on a field) and preceding `decorator` siblings (`@Builder`
* before a method_definition inside struct_body; `@Component` on the
* export_statement wrapping `export struct X`). The backwards sibling walk
* stops at the first non-decorator so an earlier declaration's decorators
* never leak in (mirrors extractDecoratorsFor's sibling pass).
*/
function collectDecoratorNames(node: SyntaxNode): string[] | undefined {
const names: string[] = [];
const nameOf = (dec: SyntaxNode): string | undefined => {
for (let i = 0; i < dec.namedChildCount; i++) {
const child = dec.namedChild(i);
if (!child) continue;
if (child.type === 'identifier') return child.text;
if (child.type === 'call_expression') {
// `@StorageLink('theme')` / `@Extend(Text)` — the decorator name is
// the callee.
const fn = child.childForFieldName('function');
if (fn?.type === 'identifier') return fn.text;
}
}
return undefined;
};
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'decorator') {
const n = nameOf(child);
if (n) names.push(n);
}
}
const parent = node.parent;
if (parent) {
// Find this node among the parent's named children by start offset
// (wrapper identity is not stable across navigation), then walk backwards.
const start = node.startIndex;
let idx = -1;
for (let i = 0; i < parent.namedChildCount; i++) {
const sib = parent.namedChild(i);
if (sib && sib.startIndex === start) {
idx = i;
break;
}
}
for (let i = idx - 1; i >= 0; i--) {
const sib = parent.namedChild(i);
if (!sib || sib.type !== 'decorator') break;
const n = nameOf(sib);
if (n) names.unshift(n);
}
}
return names.length > 0 ? names : undefined;
}
export const arktsExtractor: LanguageExtractor = {
...typescriptExtractor,
// `@Component struct X { … }` — extractStruct handles it (kind `struct`,
// members extracted like class members, `this.m()` resolution and the
// class/struct containment gates in the name-matcher all apply as-is). The
// component-ness is preserved on the node's decorators (`Component`,
// `Entry`, `CustomDialog`, `Reusable`), captured by extractModifiers below.
structTypes: ['struct_declaration'],
// build()-DSL component instantiations are call sites: `TodoRow({...})`
// inside a parent's build() is the parent→child component edge, resolved by
// the ordinary call pipeline against the child's struct node. The arkts
// branch in extractCall also lifts each chained `.attr(...)` (emitted
// dot-prefixed so it can ONLY resolve to `@Extend`/`@Styles`/`@Builder`
// attribute helpers — see matchReference) and `.onXxx(this.handler)`
// method-reference bindings. `leading_dot_expression` is the detached-chain
// shape the grammar produces when a nested component's chain starts on the
// line after its closing `}` inside arkui_children.
callTypes: ['call_expression', 'arkui_component_expression', 'leading_dot_expression'],
// Surface ArkTS decorators on the node's `decorators` list (searchable, and
// the hook a future ArkUI state→build synthesizer keys off). Core paths
// already emit `decorates` REFERENCES for classes/methods/properties/
// functions; this hook is what puts the names on struct nodes too —
// extractStruct has no extractDecoratorsFor call, and node.decorators is
// only populated via extractModifiers (see createNode).
extractModifiers: (node) => {
if (!DECORATED_MEMBER_TYPES.has(node.type)) return undefined;
return collectDecoratorNames(node);
},
};
+2
View File
@@ -34,6 +34,7 @@ import { vbnetExtractor } from './vbnet';
import { erlangExtractor } from './erlang';
import { solidityExtractor } from './solidity';
import { terraformExtractor } from './terraform';
import { arktsExtractor } from './arkts';
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
@@ -65,4 +66,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
erlang: erlangExtractor,
solidity: solidityExtractor,
terraform: terraformExtractor,
arkts: arktsExtractor,
};
+179 -6
View File
@@ -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.
+52
View File
@@ -437,6 +437,11 @@ export class CodeGraph {
}
try {
const before = this.queries.getNodeAndEdgeCount();
// Mark the index as in-flight BEFORE any writes: a run killed
// mid-index (OOM, SIGKILL, the #850 liveness watchdog) leaves this
// marker behind, so `codegraph status` can tell a truncated index
// from a completed one instead of silently serving partial results.
try { this.queries.setMetadata('index_state', 'indexing'); } catch { /* metadata is advisory */ }
// Segment vocabulary starts empty and is repopulated by the node write
// path as every file (re-)indexes below — so a full index is also the
// orphan-cleanup pass for names deleted since the last one.
@@ -513,6 +518,37 @@ export class CodeGraph {
} catch { /* metadata is advisory — never fail an index over it */ }
}
// Reconcile the scan's ground truth against what the pipeline
// accounted for. A shortfall means files were silently dropped
// (observed in the wild: a run under heavy load came up 37 files
// short with no error) — record it and tell the user, don't let the
// index pass as complete.
try {
if (!result.success) {
this.queries.setMetadata('index_state', 'failed');
} else {
const accounted = result.filesIndexed + result.filesSkipped + result.filesErrored;
const discovered = result.filesDiscovered;
const shortfall = discovered !== undefined ? discovered - accounted : 0;
if (discovered !== undefined && shortfall > 0) {
this.queries.setMetadata('index_state', 'partial');
this.queries.setMetadata('index_files_discovered', String(discovered));
this.queries.setMetadata('index_files_accounted', String(accounted));
result.errors.push({
message: `Index is missing ${shortfall} of ${discovered} discovered files (indexed ${result.filesIndexed}, skipped ${result.filesSkipped}, errored ${result.filesErrored}). The index is PARTIAL — re-run \`codegraph index\`.`,
severity: 'warning',
code: 'index_partial',
});
} else {
this.queries.setMetadata('index_state', 'complete');
if (discovered !== undefined) {
this.queries.setMetadata('index_files_discovered', String(discovered));
this.queries.setMetadata('index_files_accounted', String(accounted));
}
}
}
} catch { /* metadata is advisory — never fail an index over it */ }
return result;
} finally {
this.fileLock.release();
@@ -761,6 +797,22 @@ export class CodeGraph {
return this.queries.getLastIndexedAt();
}
/**
* Completeness of the last full index run. `'complete'` is the only good
* state. `'indexing'` after the fact means a run was killed mid-index (OOM,
* SIGKILL, liveness watchdog) and the on-disk index is truncated;
* `'partial'` means the run finished but silently dropped files
* (discovered > indexed+skipped+errored); `'failed'` means it reported
* failure. `null` = index predates this marker. Surfaced by
* `codegraph status`.
*/
getIndexState(): 'indexing' | 'complete' | 'partial' | 'failed' | null {
const raw = this.queries.getMetadata('index_state');
return raw === 'indexing' || raw === 'complete' || raw === 'partial' || raw === 'failed'
? raw
: null;
}
/**
* Which engine built the current index: the package version + extraction
* version stamped at the last full `indexAll`. Either field is null for an
+2 -1
View File
@@ -65,7 +65,7 @@ interface FormSpec {
keyWindow?: number;
}
const JS_FAMILY = new Set(['typescript', 'javascript', 'tsx', 'jsx', 'vue', 'svelte', 'astro']);
const JS_FAMILY = new Set(['typescript', 'javascript', 'tsx', 'jsx', 'vue', 'svelte', 'astro', 'arkts']);
const PY = new Set(['python']);
const RB = new Set(['ruby']);
const PHP = new Set(['php']);
@@ -201,6 +201,7 @@ function commentLang(language: string): CommentLang | null {
case 'vue':
case 'svelte':
case 'astro':
case 'arkts':
return 'typescript';
case 'java':
case 'kotlin':
+270
View File
@@ -417,6 +417,269 @@ function flutterBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[
return edges;
}
/**
* Reactive ArkUI property decorators: assigning a property carrying one of
* these re-runs the owning struct's `build()`. Covers both state models —
* V1 (`@Component`: State/Prop/Link/Provide/Consume/Storage*) and V2
* (`@ComponentV2`: Local/Provider/Consumer; `@Param` is read-only in V2 so
* the assignment gate never fires on it, and `@Trace` lives on `@ObservedV2`
* data classes, not struct properties).
*/
const ARKUI_REACTIVE_DECORATORS = new Set([
'State', 'Prop', 'Link', 'Provide', 'Consume', 'StorageLink', 'StorageProp',
'LocalStorageLink', 'LocalStorageProp', 'ObjectLink',
'Local', 'Provider', 'Consumer',
]);
/** ArkUI-observed array mutators — `this.todos.push(x)` re-renders like an assignment. */
const ARKUI_ARRAY_MUTATORS = 'push|pop|shift|unshift|splice|sort|reverse|fill';
/**
* Phase 4b-ets: ArkUI state → build (the ArkTS analog of react-render /
* flutter-build). Assigning a reactive-decorated property (`@State count`,
* `@Link selected`, …) re-runs the `@Component struct`'s `build()`, but that
* hop is framework-internal — no static edge — so "onClick → markAllDone →
* this.todos = […] → rebuilt list" dead-ends at the assignment. Bridge it:
* for each arkts struct with a `build()` method and at least one reactive
* property, link every sibling method whose body ASSIGNS (or array-mutates)
* one of those properties → `build`. Assignment-gated on the struct's OWN
* reactive property names — a method that merely reads state, or a struct
* with no reactive properties, gets nothing (this is the precision line the
* all-sibling-methods design would erase).
*/
function arkuiStateBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
for (const struct of queries.getNodesByKind('struct')) {
if (struct.language !== 'arkts') continue;
const children = queries.getOutgoingEdges(struct.id, ['contains'])
.map((e) => queries.getNodeById(e.target))
.filter((n): n is Node => !!n);
const build = children.find((n) => n.kind === 'method' && n.name === 'build');
if (!build) continue;
const reactiveProps = children.filter(
(n) => n.kind === 'property' && (n.decorators ?? []).some((d) => ARKUI_REACTIVE_DECORATORS.has(d))
);
if (reactiveProps.length === 0) continue;
const propAlternation = reactiveProps
.map((p) => p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|');
// `this.count = …` / `+=` / `++` / `--` / `this.todos.push(…)`. The
// `=(?!=)` keeps `this.done == x` comparisons out.
const mutationRe = new RegExp(
`this\\.(?:${propAlternation})\\s*(?:=(?!=)|\\+\\+|--|[+\\-*/%&|^]=|\\.(?:${ARKUI_ARRAY_MUTATORS})\\s*\\()`
);
let added = 0;
for (const m of children) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
if (m.kind !== 'method' || m.id === build.id) continue;
const content = ctx.readFile(m.filePath);
const src = content && sliceLines(content, m.startLine, m.endLine);
if (!src || !mutationRe.test(stripCommentsForRegex(src, 'typescript'))) continue;
const key = `${m.id}>${build.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: m.id, target: build.id, kind: 'calls', line: m.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'arkui-state', via: 'state assignment', registeredAt: `${build.filePath}:${build.startLine}` },
});
added++;
}
}
return edges;
}
/** Emit/subscribe call sites of HarmonyOS's `@ohos.events.emitter` bus. */
const ARKUI_EMITTER_CALL_RE = /\bemitter\s*\.\s*(emit|on|once)\s*\(\s*([A-Za-z_$][\w$.]*|\{[^)]{0,120}?\beventId\s*:\s*[^,}]+[^)]*?\})/g;
/** Cap per event bucket — a generic key with many parties is dynamic routing, not a static pair. */
const ARKUI_EMITTER_FANOUT_CAP = 8;
/**
* Phase 4b-ets2: HarmonyOS `@ohos.events.emitter` bridge. The cross-component
* bus — `emitter.emit(eventId)` fires `emitter.on(eventId, cb)` — is
* framework-internal, so an order flow riding it (OrangeShopping's
* add-to-cart) dead-ends at the emit. Link emit-site enclosing
* function/method → on/once-site enclosing function/method when both
* reference the SAME statically-recoverable event key.
*
* Key recovery, per call site (comment-stripped enclosing-file source): the
* first argument is an `{ eventId: K }` literal, a `Names.Dotted` constant, or
* a local whose same-file declaration is `new EventsId(K)` / `= K` — chase one
* level. Precision scoping learned from the samples monorepo (thousands of
* unrelated samples, most using eventId 1): NUMERIC keys pair within the same
* FILE only; NAMED keys pair within the same workspace module directory (or
* the whole project when it declares no modules — the single-app case), both
* behind a fan-out cap. Inline `on(id, (e) => {…})` arrows need no special
* handling — their bodies' calls already attribute to the registering method,
* so targeting that method keeps the chain connected.
*/
function arkuiEmitterEdges(ctx: ResolutionContext): Edge[] {
interface Site { nodeId: string; file: string; line: number }
// bucket key -> emit sites / handler sites
const emits = new Map<string, Site[]>();
const handlers = new Map<string, Site[]>();
const moduleDirs = (() => {
const ws = ctx.getWorkspacePackages?.();
return ws ? [...new Set(ws.byName.values())].sort((a, b) => b.length - a.length) : [];
})();
const moduleScopeOf = (file: string): string => {
for (const dir of moduleDirs) {
if (file === dir || file.startsWith(dir + '/')) return dir;
}
return '';
};
for (const file of ctx.getAllFiles()) {
if (!file.endsWith('.ets')) continue;
const content = ctx.readFile(file);
if (!content || !content.includes('emitter.')) continue;
const safe = stripCommentsForRegex(content, 'typescript');
const nodes = ctx.getNodesInFile(file)
.filter((n) => n.kind === 'method' || n.kind === 'function');
ARKUI_EMITTER_CALL_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ARKUI_EMITTER_CALL_RE.exec(safe))) {
const verb = m[1]!;
const arg = m[2]!.trim();
const line = safe.slice(0, m.index).split('\n').length;
const encl = nodes
.filter((n) => n.startLine <= line && n.endLine >= line)
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
if (!encl) continue;
// Recover the event key from the first argument.
let key: string | null = null;
const idLit = arg.startsWith('{') ? arg.match(/\beventId\s*:\s*([\w$.]+)/)?.[1] : undefined;
const token = idLit ?? arg;
if (token !== undefined) {
if (/^\d+$/.test(token)) {
key = `num:${file}:${token}`; // numeric: same-file only
} else if (token.includes('.')) {
key = `name:${moduleScopeOf(file)}:${token}`;
} else {
// Local variable — chase its same-file declaration one level:
// `let x = new EventsId(K)` / `const x = K`.
const declRe = new RegExp(
`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b\\s*(?::[^=\\n]+)?=\\s*(?:new\\s+[\\w$.]+\\(\\s*([^)\\n]+?)\\s*\\)|([\\w$.]+))`
);
const decl = safe.match(declRe);
const inner = (decl?.[1] ?? decl?.[2])?.trim();
if (inner && /^\d+$/.test(inner)) key = `num:${file}:${inner}`;
else if (inner && /^[\w$.]+$/.test(inner)) key = `name:${moduleScopeOf(file)}:${inner}`;
}
}
if (!key) continue;
const site: Site = { nodeId: encl.id, file, line };
if (verb === 'emit') {
(emits.get(key) ?? emits.set(key, []).get(key)!).push(site);
} else {
(handlers.get(key) ?? handlers.set(key, []).get(key)!).push(site);
}
}
}
const edges: Edge[] = [];
const seen = new Set<string>();
for (const [key, emitSites] of emits) {
const handlerSites = handlers.get(key);
if (!handlerSites) continue;
if (emitSites.length > ARKUI_EMITTER_FANOUT_CAP || handlerSites.length > ARKUI_EMITTER_FANOUT_CAP) continue;
const eventLabel = key.slice(key.lastIndexOf(':') + 1);
for (const e of emitSites) for (const h of handlerSites) {
if (e.nodeId === h.nodeId) continue;
const dedupe = `${e.nodeId}>${h.nodeId}`;
if (seen.has(dedupe)) continue;
seen.add(dedupe);
edges.push({
source: e.nodeId, target: h.nodeId, kind: 'calls', line: e.line,
provenance: 'heuristic',
metadata: { synthesizedBy: 'arkui-emitter', event: eventLabel, registeredAt: `${h.file}:${h.line}` },
});
}
}
return edges;
}
/** `router.pushUrl({ url: 'pages/Detail' })` / replaceUrl — literal urls only. */
const ARKUI_ROUTER_RE = /\brouter\s*\.\s*(?:pushUrl|replaceUrl)\s*\(\s*\{[^)]{0,200}?\burl\s*:\s*['"]([\w\-./]+)['"]/g;
/**
* Phase 4b-ets3: HarmonyOS page navigation. `router.pushUrl({ url:
* 'pages/Detail' })` reaches the `@Entry struct` of
* `<module>/src/main/ets/pages/Detail.ets`, but the hop is a string — no
* static edge — so "tap → openDetail → ???" ends at the router call. Bridge
* literal urls to the page struct: the url resolves against the standard
* `src/main/ets/` layout (what main_pages.json entries name); candidates
* prefer the caller's own workspace module (routes are module-scoped), and
* anything still ambiguous is dropped rather than guessed. Only `@Entry`
* structs qualify as targets — the decorator is what makes a file a page.
*/
function arkuiRouterEdges(ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const allFiles = ctx.getAllFiles();
const moduleDirs = (() => {
const ws = ctx.getWorkspacePackages?.();
return ws ? [...new Set(ws.byName.values())].sort((a, b) => b.length - a.length) : [];
})();
const moduleScopeOf = (file: string): string => {
for (const dir of moduleDirs) {
if (file === dir || file.startsWith(dir + '/')) return dir;
}
return '';
};
for (const file of allFiles) {
if (!file.endsWith('.ets')) continue;
const content = ctx.readFile(file);
if (!content || !content.includes('router.')) continue;
const safe = stripCommentsForRegex(content, 'typescript');
const nodes = ctx.getNodesInFile(file)
.filter((n) => n.kind === 'method' || n.kind === 'function');
ARKUI_ROUTER_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ARKUI_ROUTER_RE.exec(safe))) {
const url = m[1]!;
const line = safe.slice(0, m.index).split('\n').length;
const encl = nodes
.filter((n) => n.startLine <= line && n.endLine >= line)
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
if (!encl) continue;
const suffix = `/src/main/ets/${url}.ets`;
let candidates = allFiles.filter((f) => f.endsWith(suffix));
if (candidates.length > 1) {
const scope = moduleScopeOf(file);
const sameModule = candidates.filter((f) => moduleScopeOf(f) === scope);
if (sameModule.length > 0) candidates = sameModule;
}
if (candidates.length !== 1) continue; // ambiguous or unresolved — never guess
const page = ctx.getNodesInFile(candidates[0]!).find(
(n) => n.kind === 'struct' && (n.decorators ?? []).includes('Entry')
);
if (!page) continue;
const key = `${encl.id}>${page.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: encl.id, target: page.id, kind: 'calls', line,
provenance: 'heuristic',
metadata: { synthesizedBy: 'arkui-route', event: url, registeredAt: `${candidates[0]}:${page.startLine}` },
});
}
}
return edges;
}
/**
* Phase 4c: C++ virtual override. A call through a base/interface pointer
* (`db->Get(...)`, `iter->Next()`) dispatches at runtime to a subclass override,
@@ -485,6 +748,7 @@ function cppOverrideEdges(queries: QueryBuilder): Edge[] {
// or an `object` (Scala) so the loop also iterates those kinds.
const IFACE_OVERRIDE_LANGS = new Set([
'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', 'go', 'rust',
'arkts',
]);
/**
* Go implicit interface satisfaction (#584). Go has no `implements` keyword — a
@@ -2887,6 +3151,9 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
const svelteKitEdges = svelteKitLoadEdges(ctx); await yieldToLoop();
const pascalEdges = pascalFormEdges(ctx); await yieldToLoop();
const flutterEdges = flutterBuildEdges(queries, ctx); await yieldToLoop();
const arkuiStateEdges = arkuiStateBuildEdges(queries, ctx); await yieldToLoop();
const arkuiEmitter = arkuiEmitterEdges(ctx); await yieldToLoop();
const arkuiRoutes = arkuiRouterEdges(ctx); await yieldToLoop();
const cppEdges = cppOverrideEdges(queries); await yieldToLoop();
const ifaceEdges = interfaceOverrideEdges(queries); await yieldToLoop();
const kotlinExpectActual = kotlinExpectActualEdges(queries); await yieldToLoop();
@@ -2923,6 +3190,9 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
...svelteKitEdges,
...pascalEdges,
...flutterEdges,
...arkuiStateEdges,
...arkuiEmitter,
...arkuiRoutes,
...cppEdges,
...ifaceEdges,
...kotlinExpectActual,
+11 -4
View File
@@ -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
View File
@@ -543,7 +543,7 @@ export class ReferenceResolver {
// `.ts` index barrel and silently break the chain (#629). Re-key
// the parse on the barrel's extension so the chase works no matter
// what kind of file imports through it.
const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?)$/i.test(filePath);
const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?|ets)$/i.test(filePath);
const reExports = extractReExports(content, isJsFamily ? 'typescript' : language);
this.reExportCache.set(filePath, reExports);
return reExports;
@@ -744,8 +744,15 @@ export class ReferenceResolver {
// from './barrel'` where the barrel has `export { signIn as login }
// from './auth'`) intentionally call a name that has no
// declaration anywhere — only the renamed upstream symbol does.
// ArkTS chained-attribute refs carry a leading dot (`.titleStyle`) that
// routes them to the decorator-gated matcher; the symbol itself is
// indexed under the bare name, so the existence check strips the dot.
const existenceName =
ref.language === 'arkts' && ref.referenceName.startsWith('.')
? ref.referenceName.slice(1)
: ref.referenceName;
if (
!this.hasAnyPossibleMatch(ref.referenceName) &&
!this.hasAnyPossibleMatch(existenceName) &&
!this.matchesAnyImport(ref) &&
!this.frameworks.some((f) => f.claimsReference?.(ref.referenceName))
) {
@@ -1169,13 +1176,21 @@ export class ReferenceResolver {
private isBuiltInOrExternal(ref: UnresolvedRef): boolean {
const name = ref.referenceName;
const isJsTs = ref.language === 'typescript' || ref.language === 'javascript'
|| ref.language === 'tsx' || ref.language === 'jsx';
|| ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'arkts';
// JavaScript/TypeScript built-ins
if (isJsTs && JS_BUILT_INS.has(name)) {
return true;
}
// ArkTS resource-reference intrinsics — `$r('app.string.x')` /
// `$rawfile('x.png')` are framework-provided and appear dozens of times
// per UI file; without this they can resolve to a stray same-named
// symbol (e.g. a checked-in hvigor wrapper's `$r`).
if (ref.language === 'arkts' && (name === '$r' || name === '$rawfile')) {
return true;
}
// Common JS/TS library calls (console.log, Math.floor, JSON.parse)
if (isJsTs && (name.startsWith('console.') || name.startsWith('Math.') || name.startsWith('JSON.'))) {
return true;
+39 -1
View File
@@ -140,7 +140,9 @@ function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node {
const LANGUAGE_FAMILY: Record<string, string> = {
java: 'jvm', kotlin: 'jvm', scala: 'jvm',
swift: 'apple', objc: 'apple',
typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web',
// ArkTS is a TS superset — every HarmonyOS project mixes `.ets` UI with
// `.ts` logic modules, so refs must cross freely between them.
typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', arkts: 'web',
c: 'c', cpp: 'c',
// Razor/Blazor markup names C# types — same family so `@model Foo` /
// `<MyComponent/>` resolve to their `.cs` class through the cross-family gate.
@@ -226,6 +228,7 @@ export function matchFunctionRef(
const bareFnOnly =
ref.language === 'typescript' || ref.language === 'tsx' ||
ref.language === 'javascript' || ref.language === 'jsx' ||
ref.language === 'arkts' ||
ref.language === 'cpp' || ref.language === 'python' ||
ref.language === 'php';
@@ -1079,6 +1082,7 @@ function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
case 'javascript':
case 'tsx':
case 'jsx':
case 'arkts':
return [
new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger()
// No keyword requirement, so this matches BOTH a local annotation
@@ -1742,6 +1746,9 @@ export function matchFuzzy(
/**
* Match all strategies in order of confidence
*/
/** ArkUI attribute-helper decorators a `.attr(...)` chain may resolve to. */
const ARKUI_ATTRIBUTE_DECORATORS = new Set(['Extend', 'Styles', 'AnimatableExtend', 'Builder']);
export function matchReference(
ref: UnresolvedRef,
context: ResolutionContext
@@ -1753,6 +1760,37 @@ export function matchReference(
return matchFunctionRef(ref, context);
}
// ArkTS chained UI attributes — emitted with a leading dot (`.titleStyle`,
// `.width`) by the extractor — resolve ONLY to decorator-marked attribute
// helpers: `@Extend`/`@Styles`/`@AnimatableExtend` functions (and global
// `@Builder`s used attribute-position). Framework attributes (`.width`,
// `.fontSize` — on nearly every UI line) match no such helper and stay
// unresolved, NEVER falling through to bare-name matching: on a samples
// monorepo that fallthrough manufactured 36k wrong edges, giving single
// same-named properties thousands of false callers. Ambiguity rule matches
// the rest of the file: several same-named helpers → prefer the call-site
// file, still ambiguous → drop the ref rather than guess.
if (ref.language === 'arkts' && ref.referenceName.startsWith('.')) {
const base = ref.referenceName.slice(1);
const candidates = context
.getNodesByName(base)
.filter(
(n) =>
n.language === 'arkts' &&
n.kind === 'function' &&
(n.decorators ?? []).some((d) => ARKUI_ATTRIBUTE_DECORATORS.has(d))
);
const chosen =
candidates.length > 1 ? preferCallSiteFile(candidates, ref.filePath) : candidates;
if (chosen.length !== 1) return null;
return {
original: ref,
targetNodeId: chosen[0]!.id,
confidence: 0.85,
resolvedBy: 'exact-match',
};
}
// Erlang `-behaviour(m)` refs target a MODULE. Letting them fall through to
// bare-name matching grabs any same-named symbol — on emqx,
// `-behaviour(supervisor)` resolved to a `-define(supervisor, …)` macro
+148 -4
View File
@@ -31,6 +31,16 @@ import { logDebug } from '../errors';
export interface WorkspacePackages {
/** Member package `name` → directory relative to projectRoot (posix). */
byName: Map<string, string>;
/**
* Member package `name` → its declared ENTRY FILE relative to projectRoot
* (posix), when the member's manifest names one (ohpm's oh-package.json5
* `"main": "Index.ets"`). Lets a bare `import { X } from "data"` resolve to
* the member's real barrel even when it doesn't follow an index-file
* convention — and independent of the CONSUMER's language (a `.ts` file
* importing an `.ets` barrel resolves without `.ets` in the TS candidate
* list). Absent for npm/pnpm members (their index conventions cover it).
*/
entryByName?: Map<string, string>;
}
/**
@@ -43,10 +53,9 @@ export interface WorkspacePackages {
* the same way it does {@link loadProjectAliases} / {@link loadGoModule}.
*/
export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | null {
const patterns = readWorkspaceGlobs(projectRoot);
if (patterns.length === 0) return null;
const byName = new Map<string, string>();
const patterns = readWorkspaceGlobs(projectRoot);
for (const pattern of patterns) {
for (const dir of expandWorkspaceGlob(projectRoot, pattern)) {
const pkgName = readPackageName(path.join(projectRoot, dir));
@@ -54,10 +63,138 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages |
if (pkgName && !byName.has(pkgName)) byName.set(pkgName, dir);
}
}
// HarmonyOS/OpenHarmony (ArkTS) modular projects: every module's
// oh-package.json5 declares its local siblings as `"data": "file:../../
// core/data"` dependencies, and code then imports the bare name
// (`import { CartRepository } from "data"`). Same monorepo problem as npm
// workspaces, different manifest.
const entryByName = new Map<string, string>();
for (const [name, dir] of collectOhpmFileDeps(projectRoot)) {
if (byName.has(name)) continue;
byName.set(name, dir);
const entry = readOhpmMain(projectRoot, dir);
if (entry) entryByName.set(name, entry);
}
if (byName.size === 0) return null;
logDebug('workspace packages loaded', { count: byName.size });
return { byName };
return { byName, entryByName: entryByName.size > 0 ? entryByName : undefined };
}
/**
* Read an ohpm member's declared entry file: `<dir>/oh-package.json5`'s
* `main`, normalized to a projectRoot-relative posix path. Null when the
* manifest or field is missing/escaping.
*/
function readOhpmMain(projectRoot: string, dirRel: string): string | null {
let parsed: unknown;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
parsed = require('jsonc-parser').parse(
fs.readFileSync(path.join(projectRoot, dirRel, OHPM_MANIFEST), 'utf-8')
);
} catch {
return null;
}
const main = (parsed as { main?: unknown } | null)?.main;
if (typeof main !== 'string' || !main.trim()) return null;
const entryAbs = path.resolve(projectRoot, dirRel, main.trim());
const entryRel = path.relative(projectRoot, entryAbs).replace(/\\/g, '/');
if (entryRel.startsWith('..')) return null;
return entryRel;
}
/**
* Scan the project for `oh-package.json5` manifests and collect their
* `file:`-protocol dependencies as workspace members: dep name (what the
* source imports) → target directory (projectRoot-relative posix).
*
* Precision rule: a name declared with DIFFERENT target directories in
* different manifests (e.g. every sample in a samples monorepo has its own
* "common") is AMBIGUOUS and dropped entirely — a missing edge beats a wrong
* cross-module link. Registry dependencies (`@ohos/axios: "^2.0.0"`) don't
* use `file:` and are ignored, staying external.
*
* The walk is bounded (depth + directory budget) and prunes build/dependency
* dirs, so non-ArkTS projects pay one readdir at the root and nothing else
* (they have no oh-package.json5 anywhere shallow).
*/
const OHPM_MANIFEST = 'oh-package.json5';
const OHPM_WALK_MAX_DEPTH = 6;
const OHPM_WALK_DIR_BUDGET = 8000;
const OHPM_SKIP_DIRS = new Set([
'node_modules', 'oh_modules', '.git', '.codegraph', '.hvigor', '.preview',
'build', 'dist', 'out', 'oh-package-lock.json5',
]);
function collectOhpmFileDeps(projectRoot: string): Map<string, string> {
const byName = new Map<string, string>();
const ambiguous = new Set<string>();
const queue: Array<{ rel: string; depth: number }> = [{ rel: '', depth: 0 }];
let visited = 0;
while (queue.length > 0) {
const { rel, depth } = queue.shift()!;
if (++visited > OHPM_WALK_DIR_BUDGET) break;
const abs = path.join(projectRoot, rel);
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(abs, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
if (e.isDirectory()) {
if (depth >= OHPM_WALK_MAX_DEPTH) continue;
if (e.name.startsWith('.') || OHPM_SKIP_DIRS.has(e.name)) continue;
queue.push({ rel: rel ? `${rel}/${e.name}` : e.name, depth: depth + 1 });
continue;
}
if (e.name !== OHPM_MANIFEST) continue;
const deps = readOhpmFileDeps(path.join(abs, e.name));
for (const [name, target] of deps) {
const targetAbs = path.resolve(abs, target);
const targetRel = path.relative(projectRoot, targetAbs).replace(/\\/g, '/');
if (targetRel.startsWith('..')) continue; // escapes the project
const existing = byName.get(name);
if (existing === undefined) {
if (!ambiguous.has(name)) byName.set(name, targetRel);
} else if (existing !== targetRel) {
byName.delete(name);
ambiguous.add(name);
}
}
}
}
return byName;
}
/** Parse one oh-package.json5's dependencies → [name, file-target] pairs. */
function readOhpmFileDeps(manifestAbs: string): Array<[string, string]> {
const out: Array<[string, string]> = [];
let parsed: unknown;
try {
// JSON5 tolerates comments and trailing commas; jsonc-parser (already a
// dependency, used by the opencode installer target) handles both.
// eslint-disable-next-line @typescript-eslint/no-require-imports
parsed = require('jsonc-parser').parse(fs.readFileSync(manifestAbs, 'utf-8'));
} catch {
return out;
}
const deps = (parsed as { dependencies?: Record<string, unknown> } | null)?.dependencies;
if (!deps || typeof deps !== 'object') return out;
for (const [name, value] of Object.entries(deps)) {
if (typeof value !== 'string' || !value.startsWith('file:')) continue;
const target = value.slice('file:'.length).trim();
if (target) out.push([name, target]);
}
return out;
}
/**
@@ -82,6 +219,13 @@ export function resolveWorkspaceImport(
if (!bestName) return null;
const dir = ws.byName.get(bestName)!;
const subpath = importPath.slice(bestName.length); // '' or '/widgets'
// A bare member import resolves straight to the member's declared entry
// file when the manifest names one (ohpm `main`) — the caller's exact-path
// check hits it without extension/index guessing.
if (!subpath) {
const entry = ws.entryByName?.get(bestName);
if (entry) return entry;
}
return (dir + subpath).replace(/\/{2,}/g, '/');
}
+1
View File
@@ -68,6 +68,7 @@ export const LANGUAGES = [
'javascript',
'tsx',
'jsx',
'arkts',
'python',
'go',
'rust',