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>
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
/**
|
|
* JS/TS workspace (monorepo) package resolution.
|
|
*
|
|
* npm / yarn / bun read member packages from the root `package.json`
|
|
* `workspaces` field; pnpm from `pnpm-workspace.yaml`. A cross-package
|
|
* import like `@scope/ui/widgets` is LOCAL to the monorepo, but to a
|
|
* single-package resolver it looks exactly like a third-party npm
|
|
* specifier — so `isExternalImport` flags it external and the
|
|
* consumer↔definition edge is never created. For component barrels
|
|
* (`export { default as X } from './x.svelte'`) that surfaces as a false
|
|
* `0 callers` on a live component (issue #629).
|
|
*
|
|
* This module maps each member package's declared `name` to its
|
|
* directory so the resolver can rewrite `@scope/ui/widgets` →
|
|
* `packages/ui/widgets` and then run normal extension/index resolution.
|
|
*
|
|
* Scope deliberately small for v1 (mirrors path-aliases.ts):
|
|
* - reads `workspaces` (array OR `{ packages: [...] }`) from package.json,
|
|
* plus a minimal `pnpm-workspace.yaml` `packages:` list
|
|
* - expands one level of `*` / `**` globs (`packages/*`, `apps/*`)
|
|
* - subpath resolution is directory-based (`@scope/ui/sub` → `<ui>/sub`);
|
|
* it does NOT yet honour a member's `exports` map or `main` field
|
|
* - returns null when the project declares no workspaces, so single-
|
|
* package repos pay nothing and see no behaviour change.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
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>;
|
|
}
|
|
|
|
/**
|
|
* Load workspace member packages for `projectRoot`. Returns `null` when
|
|
* the project declares no workspaces (the common single-package case) —
|
|
* callers then skip all workspace logic.
|
|
*
|
|
* Cheap to call repeatedly only via the resolver's per-instance cache;
|
|
* this function itself touches the filesystem, so the resolver memoises it
|
|
* the same way it does {@link loadProjectAliases} / {@link loadGoModule}.
|
|
*/
|
|
export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | 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));
|
|
// First declaration wins — workspace patterns are tried in order.
|
|
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, 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;
|
|
}
|
|
|
|
/**
|
|
* Rewrite a bare workspace import to a path relative to projectRoot,
|
|
* WITHOUT an extension — the caller applies the language's extension/index
|
|
* resolution. `@scope/ui/widgets` → `packages/ui/widgets`; the bare package
|
|
* name `@scope/ui` → its directory. Returns `null` when no member package
|
|
* name matches.
|
|
*/
|
|
export function resolveWorkspaceImport(
|
|
importPath: string,
|
|
ws: WorkspacePackages
|
|
): string | null {
|
|
// Longest matching package name wins, so `@scope/ui/core` prefers a
|
|
// `@scope/ui/core` package over a `@scope/ui` one when both exist.
|
|
let bestName: string | null = null;
|
|
for (const name of ws.byName.keys()) {
|
|
if (importPath === name || importPath.startsWith(name + '/')) {
|
|
if (!bestName || name.length > bestName.length) bestName = name;
|
|
}
|
|
}
|
|
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, '/');
|
|
}
|
|
|
|
/** Read workspace glob patterns from package.json + pnpm-workspace.yaml. */
|
|
function readWorkspaceGlobs(projectRoot: string): string[] {
|
|
const out: string[] = [];
|
|
|
|
// package.json `workspaces` (npm / yarn / bun): array, or Yarn's
|
|
// `{ packages: [...], nohoist: [...] }` object form.
|
|
try {
|
|
const pkg = JSON.parse(
|
|
fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')
|
|
);
|
|
const ws = pkg?.workspaces;
|
|
if (Array.isArray(ws)) {
|
|
out.push(...ws.filter((w: unknown): w is string => typeof w === 'string'));
|
|
} else if (ws && Array.isArray(ws.packages)) {
|
|
out.push(...ws.packages.filter((w: unknown): w is string => typeof w === 'string'));
|
|
}
|
|
} catch {
|
|
/* no / invalid package.json — not a workspace root */
|
|
}
|
|
|
|
// pnpm-workspace.yaml `packages:` list. Parsed with a minimal line
|
|
// scanner so we don't pull in a YAML dependency.
|
|
try {
|
|
const yaml = fs.readFileSync(path.join(projectRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
|
out.push(...parsePnpmPackages(yaml));
|
|
} catch {
|
|
/* no pnpm-workspace.yaml */
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Minimal pnpm-workspace.yaml `packages:` extractor. Handles the only shape
|
|
* pnpm actually uses:
|
|
* packages:
|
|
* - 'packages/*'
|
|
* - "apps/*"
|
|
* - tools/build
|
|
*/
|
|
function parsePnpmPackages(yaml: string): string[] {
|
|
const out: string[] = [];
|
|
const lines = yaml.split(/\r?\n/);
|
|
let inPackages = false;
|
|
for (const line of lines) {
|
|
if (/^\s*packages\s*:/.test(line)) {
|
|
inPackages = true;
|
|
continue;
|
|
}
|
|
if (inPackages) {
|
|
const item = line.match(/^\s*-\s*(.+?)\s*$/);
|
|
if (item) {
|
|
out.push(item[1]!.replace(/^['"]|['"]$/g, ''));
|
|
continue;
|
|
}
|
|
// A non-list, non-blank line ends the `packages:` block.
|
|
if (line.trim() !== '' && !/^\s/.test(line)) inPackages = false;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Expand one level of a `packages/*` / `apps/**` glob to member dirs. */
|
|
function expandWorkspaceGlob(projectRoot: string, pattern: string): string[] {
|
|
const norm = pattern.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
const star = norm.indexOf('*');
|
|
if (star === -1) return [norm]; // exact directory
|
|
|
|
// Everything before the wildcard segment is the base to enumerate.
|
|
const base = norm.slice(0, star).replace(/\/+$/, '');
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(path.join(projectRoot, base), { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
const out: string[] = [];
|
|
for (const e of entries) {
|
|
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
out.push(base ? `${base}/${e.name}` : e.name);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Read the `name` field from a member directory's package.json. */
|
|
function readPackageName(dirAbs: string): string | null {
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(dirAbs, 'package.json'), 'utf-8'));
|
|
return typeof pkg?.name === 'string' && pkg.name ? pkg.name : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|