feat(extraction): add ArkTS language support with ArkUI dispatch bridges (#396, #512, #890 via #648) (#1186)
Adds ArkTS (.ets, HarmonyOS/OpenHarmony) as a first-class language: full TypeScript-grade extraction via the harmony-contrib tree-sitter grammar (MIT, vendored byte-identical from the tree-sitter-arkts 0.2.0 npm tarball), plus the ArkUI constructs that make HarmonyOS apps traceable: - @Component/@ComponentV2 structs with decorators from both grammar positions; members extract as class members with qualified names. - build() component trees: child instantiation edges via arkui_component_expression, no synthesizer needed. - Attribute chains emitted dot-prefixed and resolved ONLY against @Extend/@Styles/@AnimatableExtend/@Builder helpers (unique-or-drop) — bare-name fallthrough produced 36,840 wrong edges (17% of calls) on the OpenHarmony samples monorepo. All four grammar chain shapes handled, including the detached-chain forms. - .onClick(this.handler) method-reference bindings. - ohpm workspace modules: bare imports follow oh-package.json5 file: deps (ambiguous names dropped), honoring each module's main entry — which also lets .ts consumers resolve .ets modules. - ArkUI dynamic-dispatch bridges, all provenance:'heuristic' with wiring-site metadata: assignment-gated state->build() re-render (V1 @State family + V2 @Local/@Provider/@Consumer), @ohos.events.emitter emit->subscriber pairing on static event keys (numeric ids same-file, named constants same-module, fan-out capped), and router.pushUrl literal urls -> the target page's @Entry struct. - $r/$rawfile resource intrinsics treated as built-ins; arkts joins the web language family, value-reference edges, re-export chase, and the other TS-applicable gates. Also ships a language-agnostic index-completeness guard: indexAll stamps index_state (indexing -> complete/partial/failed), reconciles discovered vs accounted files (a loaded run silently dropped 37 files), and codegraph status surfaces truncated/partial indexes in human and --json output. Validated on HarmoneyOpenEye (82 files), CoolMallArkTS (528, modular ohpm + ArkUI V2), and openharmony/applications_app_samples (11,693 files, 202,890 nodes stable across re-index, attribute false-positive audit 36,840 -> 588 residual all-plausible). Supersedes PRs #656 and #988 with credit — both informed this implementation. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f8cdbe3c67
commit
99152212a9
@@ -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, '/');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user