From 3eb68046b472c94fe1914438cd9bacdfdb504878 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 20 Jul 2026 23:34:39 -0700 Subject: [PATCH 1/4] feat(js): invent-free tsconfig and package.json import path mapping Resolve JS/TS path aliases only from proven tsconfig paths and package imports (Wave 29). --- cli/ucli/analyzers/js_analyzer.py | 17 +- cli/ucli/analyzers/js_ast/README.md | 4 + cli/ucli/analyzers/js_ast/parse_worker.mjs | 287 +++++++++++++++++++-- 3 files changed, 280 insertions(+), 28 deletions(-) diff --git a/cli/ucli/analyzers/js_analyzer.py b/cli/ucli/analyzers/js_analyzer.py index 5b01d7b..72aa1dd 100644 --- a/cli/ucli/analyzers/js_analyzer.py +++ b/cli/ucli/analyzers/js_analyzer.py @@ -9,9 +9,11 @@ Override with ``UF_JS_ANALYZER=regex|ast|auto`` (default ``auto``). Call edges: same-file unique short-name qualification. AST path may also -resolve high-confidence relative imports (``./foo`` / ``../foo``) to unique -exported names — never bare package imports. Ambiguous or dotted callees stay -bare. Not Python-parity (no typed ``obj.method`` resolution). +resolve high-confidence relative imports (``./foo`` / ``../foo``), nearest +``package.json`` ``"exports"`` / ``"imports"``, and nearest ``tsconfig`` / +``jsconfig`` ``paths`` aliases when uniquely mapped — never bare npm packages +or typechecked resolution. Ambiguous or dotted callees stay bare. Not +Python-parity (no typed ``obj.method`` resolution). """ from __future__ import annotations @@ -723,8 +725,10 @@ def _build_ast_map( f"Complexity is ast-cyclomatic (structural decision points): {formula}. " "Call edges: same-file unique names, plus high-confidence relative " "imports (./ / ../) to unique exports when resolved, including " - "one/two-hop re-export barrels (export { x } from './mod'). " - "No typed obj.method resolution; not Python-parity. " + "one/two-hop re-export barrels (export { x } from './mod'), nearest " + 'package.json "exports"/"imports", and nearest tsconfig/jsconfig ' + "paths aliases when uniquely mapped. No typed obj.method; no bare npm " + "invent; not typechecked; not Python-parity. " "Falls back to *-best-effort regex when Node/typescript unavailable." ), } @@ -772,7 +776,8 @@ class JavaScriptAdapter: "Prefers Node + TypeScript compiler API (javascript-ast, ast-cyclomatic); " "degrades to regex (javascript-best-effort, keyword-heuristic) if Node or " "cli/ucli/analyzers/js_ast deps are missing. Same-file call edges; optional " - "relative-import edges on the AST path. TypeScript types are not typechecked." + "relative-import / package.json exports|imports / tsconfig paths edges on " + "the AST path when uniquely resolved. TypeScript types are not typechecked." ) def build_map(self, root: pathlib.Path) -> dict[str, Any]: diff --git a/cli/ucli/analyzers/js_ast/README.md b/cli/ucli/analyzers/js_ast/README.md index 116de09..0ae6f02 100644 --- a/cli/ucli/analyzers/js_ast/README.md +++ b/cli/ucli/analyzers/js_ast/README.md @@ -12,6 +12,10 @@ The Python analyzer invokes `parse_worker.mjs` via `node` when `typescript` package is missing, Understand-First falls back to the regex adapter (`javascript-best-effort`). +Wave 29 (AST path): nearest `tsconfig.json` / `jsconfig.json` `paths` and +`package.json` `"imports"` (`#…`) resolve only when the mapped file is unique. +Still parse-only — not typechecked; bare npm packages are never invented. + Force paths: - `UF_JS_ANALYZER=regex` — always regex diff --git a/cli/ucli/analyzers/js_ast/parse_worker.mjs b/cli/ucli/analyzers/js_ast/parse_worker.mjs index e3a2192..26b84f4 100644 --- a/cli/ucli/analyzers/js_ast/parse_worker.mjs +++ b/cli/ucli/analyzers/js_ast/parse_worker.mjs @@ -199,10 +199,9 @@ function collectExports(sourceFile, root, filePath) { stmt.moduleSpecifier && ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : null; - const resolved = - fromSpec && (fromSpec.startsWith("./") || fromSpec.startsWith("../")) - ? resolveRelativeModule(root, filePath, fromSpec) - : null; + const resolved = fromSpec + ? resolveMappedModule(root, filePath, fromSpec) + : null; for (const el of stmt.exportClause.elements) { const exportName = el.name.text; const imported = el.propertyName ? el.propertyName.text : exportName; @@ -258,31 +257,265 @@ function collectExports(sourceFile, root, filePath) { return { names: [...names], reexports }; } -function resolveRelativeModule(root, fromFile, spec) { - const base = path.resolve(path.dirname(fromFile), spec); +function tryFileCandidates(root, absBase) { const candidates = [ - base, - base + ".js", - base + ".mjs", - base + ".cjs", - base + ".ts", - base + ".tsx", - base + ".jsx", - path.join(base, "index.js"), - path.join(base, "index.ts"), - path.join(base, "index.mjs"), + absBase, + absBase + ".js", + absBase + ".mjs", + absBase + ".cjs", + absBase + ".ts", + absBase + ".tsx", + absBase + ".jsx", + path.join(absBase, "index.js"), + path.join(absBase, "index.ts"), + path.join(absBase, "index.mjs"), + path.join(absBase, "index.tsx"), + path.join(absBase, "index.jsx"), ]; + const hits = []; + const seen = new Set(); for (const c of candidates) { - if (fs.existsSync(c) && fs.statSync(c).isFile()) { - return posixRel(root, c); + const norm = path.resolve(c); + if (seen.has(norm)) continue; + seen.add(norm); + if (fs.existsSync(norm) && fs.statSync(norm).isFile()) { + hits.push(norm); } } + if (hits.length !== 1) return null; + return posixRel(root, hits[0]); +} + +function resolveRelativeModule(root, fromFile, spec) { + const base = path.resolve(path.dirname(fromFile), spec); + const hit = tryFileCandidates(root, base); + if (hit) return hit; // Wave 22: nearest package.json "exports" for relative subpaths only. const viaExports = resolveViaPackageExports(root, fromFile, spec); if (viaExports) return viaExports; return null; } +/** + * Wave 29: resolve non-relative specs via nearest package.json "imports" + * and/or nearest tsconfig/jsconfig "paths" when the target file is unique. + * Still parse-only — no typechecking, no node_modules invent. + */ +function resolveMappedModule(root, fromFile, spec) { + if (!spec || typeof spec !== "string") return null; + if (spec.startsWith("./") || spec.startsWith("../")) { + return resolveRelativeModule(root, fromFile, spec); + } + // Bare relative without ./ is not supported (ambiguous with package names). + if (spec.startsWith("#")) { + return resolveViaPackageImports(root, fromFile, spec); + } + // tsconfig/jsconfig paths (e.g. @lib/foo); never bare npm registry packages. + return resolveViaTsconfigPaths(root, fromFile, spec); +} + +function findNearestConfig(root, fromFile, names) { + const rootAbs = path.resolve(root); + let dir = path.dirname(path.resolve(fromFile)); + while (true) { + for (const name of names) { + const cand = path.join(dir, name); + if (fs.existsSync(cand) && fs.statSync(cand).isFile()) { + return cand; + } + } + if (path.resolve(dir) === rootAbs) break; + const parent = path.dirname(dir); + if (parent === dir) break; + if (!dir.startsWith(rootAbs) && dir !== rootAbs) break; + dir = parent; + if ( + !path.resolve(dir).startsWith(rootAbs) && + path.resolve(dir) !== rootAbs + ) { + break; + } + } + return null; +} + +/** + * Nearest package.json "imports" (#internal, #lib/*, …) — unique file only. + * Does not invent node_modules or conditional multi-target maps. + */ +function resolveViaPackageImports(root, fromFile, spec) { + if (!spec.startsWith("#")) return null; + const pkgJsonPath = findNearestConfig(root, fromFile, ["package.json"]); + if (!pkgJsonPath) return null; + let pkg; + try { + pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")); + } catch { + return null; + } + if (!pkg || !pkg.imports || typeof pkg.imports !== "object") return null; + const pkgDir = path.dirname(pkgJsonPath); + const mapped = applyPathPatterns(pkg.imports, spec); + if (!mapped || mapped.length === 0) return null; + const hits = []; + const seen = new Set(); + for (const target of mapped) { + if (typeof target !== "string" || !target.startsWith("./")) continue; + const abs = path.resolve(pkgDir, target); + const rel = tryFileCandidates(root, abs); + if (rel && !seen.has(rel)) { + seen.add(rel); + hits.push(rel); + } + } + return hits.length === 1 ? hits[0] : null; +} + +/** + * Nearest tsconfig.json / jsconfig.json compilerOptions.paths (+ baseUrl). + * Only exact / single-star patterns; omit when multiple patterns yield + * distinct files. Does not merge ``extends`` and does not typecheck. + */ +function resolveViaTsconfigPaths(root, fromFile, spec) { + const cfgPath = findNearestConfig(root, fromFile, [ + "tsconfig.json", + "jsconfig.json", + ]); + if (!cfgPath) return null; + let cfg; + try { + cfg = JSON.parse(stripJsonc(fs.readFileSync(cfgPath, "utf8"))); + } catch { + return null; + } + const opts = (cfg && cfg.compilerOptions) || {}; + const paths = opts.paths; + if (!paths || typeof paths !== "object") return null; + const cfgDir = path.dirname(cfgPath); + const baseUrl = opts.baseUrl + ? path.resolve(cfgDir, String(opts.baseUrl)) + : cfgDir; + const mapped = applyPathPatterns(paths, spec); + if (!mapped || mapped.length === 0) return null; + const hits = []; + const seen = new Set(); + for (const target of mapped) { + if (typeof target !== "string") continue; + // Refuse escaping scan root via .. beyond baseUrl when under root. + const abs = path.resolve(baseUrl, target); + const rootAbs = path.resolve(root); + if (!abs.startsWith(rootAbs) && abs !== rootAbs) continue; + const rel = tryFileCandidates(root, abs); + if (rel && !seen.has(rel)) { + seen.add(rel); + hits.push(rel); + } + } + return hits.length === 1 ? hits[0] : null; +} + +/** Best-effort strip of line and block comments for tsconfig JSONC. */ +function stripJsonc(text) { + let out = ""; + let i = 0; + const n = text.length; + while (i < n) { + const ch = text[i]; + if (ch === '"' || ch === "'") { + const quote = ch; + out += ch; + i += 1; + while (i < n) { + const c = text[i]; + out += c; + i += 1; + if (c === "\\" && i < n) { + out += text[i]; + i += 1; + continue; + } + if (c === quote) break; + } + continue; + } + if (ch === "/" && i + 1 < n && text[i + 1] === "/") { + i += 2; + while (i < n && text[i] !== "\n" && text[i] !== "\r") i += 1; + continue; + } + if (ch === "/" && i + 1 < n && text[i + 1] === "*") { + i += 2; + while (i + 1 < n && !(text[i] === "*" && text[i + 1] === "/")) i += 1; + i = Math.min(n, i + 2); + continue; + } + out += ch; + i += 1; + } + return out; +} + +/** + * Match Node/TS path-map patterns. Prefer longest pattern prefix when several + * match; return candidate target strings (with ``*`` substituted). + * Values may be a string or string[]. + */ +function applyPathPatterns(mapField, spec) { + const entries = []; + for (const [pattern, raw] of Object.entries(mapField)) { + if (typeof pattern !== "string") continue; + const targets = Array.isArray(raw) + ? raw.filter((t) => typeof t === "string") + : typeof raw === "string" + ? [raw] + : typeof raw === "object" && raw !== null + ? // Conditional import map: unique string only + (() => { + const s = unwrapExportValue(raw); + return s ? [s] : []; + })() + : []; + if (targets.length === 0) continue; + const star = pattern.indexOf("*"); + if (star >= 0) { + const prefix = pattern.slice(0, star); + const suffix = pattern.slice(star + 1); + if ( + spec.startsWith(prefix) && + spec.endsWith(suffix) && + spec.length >= prefix.length + suffix.length + ) { + const mid = spec.slice(prefix.length, spec.length - suffix.length); + entries.push({ + pattern, + prefixLen: prefix.length, + targets: targets.map((t) => t.split("*").join(mid)), + }); + } + } else if (pattern === spec) { + entries.push({ pattern, prefixLen: pattern.length, targets }); + } + } + if (entries.length === 0) return []; + // Longest prefix wins (TypeScript-ish); if ties with different targets, fail closed. + entries.sort((a, b) => b.prefixLen - a.prefixLen); + const bestLen = entries[0].prefixLen; + const best = entries.filter((e) => e.prefixLen === bestLen); + const allTargets = []; + const seen = new Set(); + for (const e of best) { + for (const t of e.targets) { + if (!seen.has(t)) { + seen.add(t); + allTargets.push(t); + } + } + } + // Multiple distinct pattern ties → ambiguous unless they share one target. + if (best.length > 1 && allTargets.length > 1) return []; + return allTargets; +} + /** * High-confidence package.json exports resolve (relative specs only). * Supports string exports, map entries, and a single conditional @@ -400,13 +633,22 @@ function unwrapExportValue(val) { return null; } +function trackableSpecifier(spec, resolved) { + if (spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("#")) { + return true; + } + // Non-relative aliases only when path-mapping uniquely resolved a file. + return Boolean(resolved); +} + function collectRelativeImports(sourceFile, root, filePath) { const imports = []; for (const stmt of sourceFile.statements) { if (!ts.isImportDeclaration(stmt) || !stmt.moduleSpecifier) continue; if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue; const spec = stmt.moduleSpecifier.text; - if (!spec.startsWith("./") && !spec.startsWith("../")) continue; + const resolved = resolveMappedModule(root, filePath, spec); + if (!trackableSpecifier(spec, resolved)) continue; const bindings = {}; const clause = stmt.importClause; if (clause) { @@ -421,7 +663,7 @@ function collectRelativeImports(sourceFile, root, filePath) { } imports.push({ specifier: spec, - resolved: resolveRelativeModule(root, filePath, spec), + resolved, bindings, }); } @@ -434,7 +676,8 @@ function collectRelativeImports(sourceFile, root, filePath) { ts.isStringLiteral(node.arguments[0]) ) { const spec = node.arguments[0].text; - if (spec.startsWith("./") || spec.startsWith("../")) { + const resolved = resolveMappedModule(root, filePath, spec); + if (trackableSpecifier(spec, resolved)) { const parent = node.parent; const bindings = {}; if ( @@ -446,7 +689,7 @@ function collectRelativeImports(sourceFile, root, filePath) { } imports.push({ specifier: spec, - resolved: resolveRelativeModule(root, filePath, spec), + resolved, bindings, }); } From cc10b977712ab6c3881c6ef8dae139e9457585e2 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 20 Jul 2026 23:34:39 -0700 Subject: [PATCH 2/4] test(fixtures): add JS/TS path-mapping alias samples Cover hash-mapped and tsconfig path imports for invent-free resolution. --- examples/js_toy/aliases/hash_mapped.js | 4 ++++ examples/js_toy/aliases/pathmapped_util.ts | 4 ++++ examples/js_toy/package.json | 3 +++ examples/js_toy/pathmap_hash_importer.js | 6 ++++++ examples/js_toy/pathmap_importer.ts | 6 ++++++ examples/js_toy/tsconfig.json | 8 ++++++++ 6 files changed, 31 insertions(+) create mode 100644 examples/js_toy/aliases/hash_mapped.js create mode 100644 examples/js_toy/aliases/pathmapped_util.ts create mode 100644 examples/js_toy/pathmap_hash_importer.js create mode 100644 examples/js_toy/pathmap_importer.ts create mode 100644 examples/js_toy/tsconfig.json diff --git a/examples/js_toy/aliases/hash_mapped.js b/examples/js_toy/aliases/hash_mapped.js new file mode 100644 index 0000000..1262ef8 --- /dev/null +++ b/examples/js_toy/aliases/hash_mapped.js @@ -0,0 +1,4 @@ +/** Target of package.json imports #hashMapped (Wave 29). */ +export function hashMappedUtil(x) { + return x * 3; +} diff --git a/examples/js_toy/aliases/pathmapped_util.ts b/examples/js_toy/aliases/pathmapped_util.ts new file mode 100644 index 0000000..be3c832 --- /dev/null +++ b/examples/js_toy/aliases/pathmapped_util.ts @@ -0,0 +1,4 @@ +/** Target of tsconfig paths @lib/* (Wave 29). */ +export function pathMappedUtil(x: number): number { + return x + 1; +} diff --git a/examples/js_toy/package.json b/examples/js_toy/package.json index 028eebb..247ed0d 100644 --- a/examples/js_toy/package.json +++ b/examples/js_toy/package.json @@ -5,5 +5,8 @@ "exports": { ".": "./math.js", "./mapped": "./exports_target.js" + }, + "imports": { + "#hashMapped": "./aliases/hash_mapped.js" } } diff --git a/examples/js_toy/pathmap_hash_importer.js b/examples/js_toy/pathmap_hash_importer.js new file mode 100644 index 0000000..e6380a2 --- /dev/null +++ b/examples/js_toy/pathmap_hash_importer.js @@ -0,0 +1,6 @@ +/** Imports via nearest package.json "imports" #hashMapped (Wave 29). */ +import { hashMappedUtil } from "#hashMapped"; + +export function runHashMapped(x) { + return hashMappedUtil(x); +} diff --git a/examples/js_toy/pathmap_importer.ts b/examples/js_toy/pathmap_importer.ts new file mode 100644 index 0000000..fb323f4 --- /dev/null +++ b/examples/js_toy/pathmap_importer.ts @@ -0,0 +1,6 @@ +/** Imports via nearest tsconfig paths @lib/* → lib/* (Wave 29). */ +import { pathMappedUtil } from "@lib/pathmapped_util"; + +export function runPathMapped(x: number): number { + return pathMappedUtil(x); +} diff --git a/examples/js_toy/tsconfig.json b/examples/js_toy/tsconfig.json new file mode 100644 index 0000000..3af2572 --- /dev/null +++ b/examples/js_toy/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@lib/*": ["aliases/*"] + } + } +} From ec32dce44074812176356f6b6a516110d6963005 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 20 Jul 2026 23:34:39 -0700 Subject: [PATCH 3/4] test(wave29): lock JS/TS path-mapping contracts Prevent regressions in alias resolution and unmapped negative cases. --- tests/test_wave29.py | 123 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/test_wave29.py diff --git a/tests/test_wave29.py b/tests/test_wave29.py new file mode 100644 index 0000000..c74fd65 --- /dev/null +++ b/tests/test_wave29.py @@ -0,0 +1,123 @@ +"""Wave 29: JS/TS invent-free path-mapping (tsconfig paths + package.json imports).""" + +from __future__ import annotations + +import pathlib + +import pytest +from cli.ucli.analyzers.js_analyzer import build_js_map +from cli.ucli.analyzers.js_ast_bridge import ast_backend_available + + +@pytest.fixture +def force_js_ast(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("UF_JS_ANALYZER", "ast") + + +@pytest.fixture +def force_js_regex(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("UF_JS_ANALYZER", "regex") + + +@pytest.mark.skipif(not ast_backend_available(), reason="Node+typescript AST worker not installed") +def test_tsconfig_paths_alias_unique(force_js_ast): + m = build_js_map(pathlib.Path("examples/js_toy")) + run = next(k for k in m["functions"] if k.endswith(":runPathMapped")) + assert any( + isinstance(c, str) and c.endswith(":pathMappedUtil") for c in m["functions"][run]["calls"] + ) + assert any( + isinstance(c, str) and "pathmapped_util" in c and c.endswith(":pathMappedUtil") + for c in m["functions"][run]["calls"] + ) + + +@pytest.mark.skipif(not ast_backend_available(), reason="Node+typescript AST worker not installed") +def test_package_json_imports_hash_unique(force_js_ast): + m = build_js_map(pathlib.Path("examples/js_toy")) + run = next(k for k in m["functions"] if k.endswith(":runHashMapped")) + assert any( + isinstance(c, str) and c.endswith(":hashMappedUtil") for c in m["functions"][run]["calls"] + ) + assert any( + isinstance(c, str) and "hash_mapped" in c and c.endswith(":hashMappedUtil") + for c in m["functions"][run]["calls"] + ) + + +@pytest.mark.skipif(not ast_backend_available(), reason="Node+typescript AST worker not installed") +def test_ambiguous_tsconfig_paths_do_not_invent( + tmp_path: pathlib.Path, force_js_ast, monkeypatch: pytest.MonkeyPatch +): + """Two path targets that both exist → omit cross-file edge (fail closed).""" + monkeypatch.chdir(tmp_path) + (tmp_path / "tsconfig.json").write_text( + '{\n "compilerOptions": {\n "baseUrl": ".",\n' + ' "paths": { "@dup/*": ["a/*", "b/*"] }\n }\n}\n', + encoding="utf-8", + ) + (tmp_path / "a").mkdir() + (tmp_path / "b").mkdir() + (tmp_path / "a" / "util.ts").write_text( + "export function shared() { return 1; }\n", + encoding="utf-8", + ) + (tmp_path / "b" / "util.ts").write_text( + "export function shared() { return 2; }\n", + encoding="utf-8", + ) + (tmp_path / "consumer.ts").write_text( + 'import { shared } from "@dup/util";\nexport function run() { return shared(); }\n', + encoding="utf-8", + ) + m = build_js_map(tmp_path) + run = next(k for k in m["functions"] if k.endswith(":run")) + calls = m["functions"][run]["calls"] + assert "shared" in calls or any(c == "shared" for c in calls) + assert not any(isinstance(c, str) and ":" in c and c.endswith(":shared") for c in calls) + + +@pytest.mark.skipif(not ast_backend_available(), reason="Node+typescript AST worker not installed") +def test_bare_npm_package_not_invented( + tmp_path: pathlib.Path, force_js_ast, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "tsconfig.json").write_text( + '{\n "compilerOptions": { "baseUrl": ".", "paths": { "@lib/*": ["lib/*"] } }\n}\n', + encoding="utf-8", + ) + (tmp_path / "lib").mkdir() + (tmp_path / "lib" / "ok.ts").write_text( + "export function ok() { return 1; }\n", + encoding="utf-8", + ) + (tmp_path / "consumer.ts").write_text( + 'import { ok } from "@lib/ok";\n' + 'import { map } from "lodash";\n' + "export function run() { return ok() + map([]); }\n", + encoding="utf-8", + ) + m = build_js_map(tmp_path) + run = next(k for k in m["functions"] if k.endswith(":run")) + calls = m["functions"][run]["calls"] + assert any(isinstance(c, str) and c.endswith(":ok") for c in calls) + assert not any(isinstance(c, str) and "lodash" in c and ":" in c for c in calls) + assert "map" in calls or any(c == "map" for c in calls) + + +def test_regex_path_does_not_invent_pathmap_edges(force_js_regex): + m = build_js_map(pathlib.Path("examples/js_toy")) + run = next((k for k in m["functions"] if k.endswith(":runPathMapped")), None) + if run is None: + pytest.skip("pathmap_importer.ts not visible to regex path") + calls = m["functions"][run]["calls"] + assert "pathMappedUtil" in calls or any(c == "pathMappedUtil" for c in calls) + assert not any(isinstance(c, str) and ":" in c and c.endswith(":pathMappedUtil") for c in calls) + + +@pytest.mark.skipif(not ast_backend_available(), reason="Node+typescript AST worker not installed") +def test_analyzer_note_documents_path_mapping_limits(force_js_ast): + m = build_js_map(pathlib.Path("examples/js_toy")) + note = (m.get("analyzer_note") or "").lower() + assert "paths" in note or "tsconfig" in note + assert "not typechecked" in note or "typecheck" in note From a98e15673c5c052120575b6e1d1162e8ed5218cc Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 20 Jul 2026 23:34:39 -0700 Subject: [PATCH 4/4] docs: document invent-free JS/TS path-mapping fidelity Update README, usage, and schema notes for Wave 29. --- README.md | 2 +- docs/usage.md | 4 +++- schemas/SCHEMA_REFERENCE.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ef3962b..faa147b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Large codebases reward familiarity over clarity. Docs drift, static graphs show ## What it does -- **Maps** — Python: high-confidence AST call graphs with McCabe complexity and callers (import-gated / return-type factory edges when unique; ambiguous names omit rather than invent). JavaScript/TypeScript: Node TypeScript AST when available (`*-ast`, `ast-cyclomatic`, same-file + relative imports, re-export barrels, and nearest `package.json` `"exports"` subpaths); otherwise regex fallback. Go: `go/ast` when available; otherwise regex. Java: `javalang` when installed; otherwise regex. Rust: `syn` AST when cargo is available (`rust-ast`, `ast-cyclomatic`); otherwise `rust-best-effort` regex — same-file plus invent-free `mod`/`use` edges for unique scanned targets; **not** rustc typechecked/Python parity. C#: Roslyn AST when a .NET SDK is available (`csharp-ast`, `ast-cyclomatic`); otherwise `csharp-best-effort` regex — **not** typechecked/Python parity. Unsupported languages are counted and skipped (never scanned as Python or empty fake maps). +- **Maps** - Python: high-confidence AST call graphs with McCabe complexity and callers (import-gated / return-type factory edges when unique; ambiguous names omit rather than invent). JavaScript/TypeScript: Node TypeScript AST when available (`*-ast`, `ast-cyclomatic`, same-file + relative imports, re-export barrels, nearest `package.json` `"exports"`/`"imports"`, and nearest `tsconfig`/`jsconfig` `paths` when unique - still not typechecked); otherwise regex fallback. Go: `go/ast` when available; otherwise regex. Java: `javalang` when installed; otherwise regex. Rust: `syn` AST when cargo is available (`rust-ast`, `ast-cyclomatic`); otherwise `rust-best-effort` regex - same-file plus invent-free `mod`/`use` edges for unique scanned targets; **not** rustc typechecked/Python parity. C#: Roslyn AST when a .NET SDK is available (`csharp-ast`, `ast-cyclomatic`); otherwise `csharp-best-effort` regex - **not** typechecked/Python parity. Unsupported languages are counted and skipped (never scanned as Python or empty fake maps). - **Invariants & effects** — Contract-linked structure and pre/post hints where configured; map `side_effects` tags are AST heuristics on Python, not proven purity (JS maps leave `side_effects` empty). - **Hot-path fixtures** — Small, runnable scaffolds tied to real execution. - **Reading plans** — Module-oriented summaries and questions before you edit. diff --git a/docs/usage.md b/docs/usage.md index ef1713f..39baaa0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -66,7 +66,9 @@ prefer a **Node + TypeScript compiler API** path (`javascript-ast` / `npm install` in `cli/ucli/analyzers/js_ast` are available; otherwise they **degrade** to the regex adapter (`*-best-effort`, `keyword-heuristic`). Force with `UF_JS_ANALYZER=regex|ast|auto`. Relative imports may also resolve via -re-export barrels and nearest `package.json` `"exports"` subpaths (AST path). +re-export barrels, nearest `package.json` `"exports"` / `"imports"`, and nearest +`tsconfig.json` / `jsconfig.json` `paths` aliases when uniquely mapped (AST path; +still **not** typechecked; bare npm packages are never invented). Go (`.go`) prefers **`go/parser`** via `go run` in `cli/ucli/analyzers/go_ast` (`go-ast`, `ast-cyclomatic`) when Go 1.21+ is on PATH; otherwise `go-best-effort` regex. Force with diff --git a/schemas/SCHEMA_REFERENCE.md b/schemas/SCHEMA_REFERENCE.md index 88f8293..23394ed 100644 --- a/schemas/SCHEMA_REFERENCE.md +++ b/schemas/SCHEMA_REFERENCE.md @@ -119,7 +119,7 @@ Each function object also includes `simple_name`: the bare basename (`helper` fo | Language | Fidelity | What is emitted | Limits | | --- | --- | --- | --- | | Python (`.py`) | AST | McCabe `complexity`, high-confidence `calls`/`callers`, heuristic `side_effects` | Same honesty rules as above | -| JavaScript / TypeScript (`.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.jsx`) | **AST preferred** (`javascript-ast` / `typescript-ast`) via Node + TypeScript compiler API; **regex fallback** (`*-best-effort`) if Node or `cli/ucli/analyzers/js_ast` deps missing | Defs + calls; `ast-cyclomatic` or keyword-heuristic; same-file unique edges; AST path may qualify **relative** `./` / `../` imports (incl. re-export barrels ≤2 hops) and nearest **`package.json` `"exports"`** subpaths when uniquely resolved | Not Python parity: no typed `obj.method`, no bare npm package invent, no typechecking. Override with `UF_JS_ANALYZER=regex\|ast\|auto` | +| JavaScript / TypeScript (`.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.jsx`) | **AST preferred** (`javascript-ast` / `typescript-ast`) via Node + TypeScript compiler API; **regex fallback** (`*-best-effort`) if Node or `cli/ucli/analyzers/js_ast` deps missing | Defs + calls; `ast-cyclomatic` or keyword-heuristic; same-file unique edges; AST path may qualify **relative** `./` / `../` imports (incl. re-export barrels ≤2 hops), nearest **`package.json` `"exports"` / `"imports"`**, and nearest **`tsconfig` / `jsconfig` `paths`** aliases when uniquely resolved | Not Python parity: no typed `obj.method`, no bare npm package invent, no typechecking (parse-only; `paths`/`imports` are string maps, not a program). Override with `UF_JS_ANALYZER=regex\|ast\|auto` | | Go (`.go`) | **AST preferred** (`go-ast`) via `go run` + `go/parser`; **regex fallback** (`go-best-effort`) if Go toolchain missing | Defs + methods; `ast-cyclomatic` or keyword-heuristic; same-file unique edges; AST path may qualify **same-package** unique names across scanned files and **cross-package** `alias.Func` when the import path uniquely maps under the scan root (nearest `go.mod`) | Not Python parity: no typed method resolve; no invent when import path is ambiguous or outside the scan root; not typechecked / no `packages.Load`. Override with `UF_GO_ANALYZER=regex\|ast\|auto` | | Java (`.java`) | **AST preferred** (`java-ast`) via optional ``javalang``; **regex fallback** (`java-best-effort`) | Class/method defs; `ast-cyclomatic` or keyword-heuristic; same-file + same-package unique edges | Not typechecked; no cross-package invent. Install `javalang` / `understand-first[analyzers]`. Override with `UF_JAVA_ANALYZER=regex\|ast\|auto` | | Rust (`.rs`) | **AST preferred** (`rust-ast`) via `cargo run` + `syn`; **regex fallback** (`rust-best-effort`) if cargo missing | `fn` / `impl` methods; `ast-cyclomatic` or keyword-heuristic; same-file unique edges, plus invent-free cross-module edges when `mod`/`use` uniquely resolves a scanned target | Not rustc typecheck; ambiguous or unproven `mod`/`use` edges omitted (no crate-graph invent). Override with `UF_RUST_ANALYZER=regex\|ast\|auto` |