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