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 MchenryandClaude Fable 5 authored and GitHub committed 2026-07-06 09:07:15 -05:00
1 parent f8cdbe3c67
commit 99152212a9
21 files changed
+1695 -21

No files matched your search

+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, '/');
}