From 2dfcf956d744c5a2201e92129c531d49b9b64eb2 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Wed, 5 Aug 2026 16:57:18 -0400 Subject: [PATCH 1/2] Ignore .codegraph/.ai as symlinks, not just directories The patterns ended in a slash, which git matches against directories only. In a shared-index worktree layout these paths are symlinks to a sibling checkout's index, so every worktree showed a permanently untracked .codegraph entry. Dropping the trailing slash covers both forms. --- .gitignore | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index fda1b04..5618d47 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules/ *.log dist/ -# tool-generated index/scratch dirs (runecho / codegraph) — never committed -.ai/ -.codegraph/ +# tool-generated index/scratch dirs (runecho / codegraph) — never committed. +# No trailing slash: in a shared-index worktree layout these are symlinks to a +# sibling checkout's index, and a dir-only pattern leaves them untracked-noisy. +.ai +.codegraph From 73e4602d55b32e47a65d8049b399420a5089fc1c Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Wed, 5 Aug 2026 19:11:42 -0400 Subject: [PATCH 2/2] Attribute --architecture edges by file when symbol names collide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codegraph callees ` takes a bare name with no file disambiguation, and answers with the UNION of every same-named symbol's callees. So a collision did not merely pick the wrong file — it invented edges that exist in neither. Measured on a two-`handle` fixture: 4 edges drawn, 2 real. probeFileEdges now re-probes names appearing in more than one FILE with `codegraph node -f `, the only file-qualified probe codegraph offers, and reads its trail via parseNodeCalls. Counting distinct files rather than symbol occurrences is load-bearing: two same-named symbols inside ONE file have no file ambiguity, the bare-name union is already exactly right for them, and codegraph answers that case with two concatenated trail blocks — routing it through the file-qualified probe would delete one of them. The trail is a human-facing summary, not an API, so parseNodeCalls falls back to the bare-name probe (over-reports, never under-reports) on four measured conditions: no trail section (file-mode output or a changed format), more than one trail section, a trail truncated with `+N more` (codegraph caps it at 12 entries — `main` here has 23 callees), or a reported Location outside the requested file (`-f` is a preference, not a filter: `-f no/such/file.js -- buildDot` still answers with render/callgraph.js's buildDot, exit 0). parseCodegraphOutput now matches the not-found message against only the first non-empty line: under json:false the response embeds indexed source, and this repo's own test file contains that sentence verbatim. Duplicate file basenames remain genuinely unresolvable (node -f answers a file node in file mode); duplicateNameWarning now reports the resolved and unresolved halves separately. --- TECHNICAL.md | 4 +- USAGE.md | 1 + render/callgraph.js | 183 +++++++++++++++++++++++++++++++------ test/run.js | 217 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 376 insertions(+), 29 deletions(-) diff --git a/TECHNICAL.md b/TECHNICAL.md index 65aaca4..eed9f2f 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -120,7 +120,7 @@ It doubles as a live legend for the [Visual Encoding](#visual-encoding) rules be - **`render/callgraph.js`** — the entire tool. Exports `buildDot(symbol, callers, callees, { maxRender, transitiveEdges })` (pure: turns caller/callee arrays into a DOT digraph string, deduplicating entries with the same `name`+`filePath` so repeated JSON rows don't render as duplicate edges; via `nodeIdentities` it gives each drawn node a graphviz id unique per `name`+`filePath`, so two *distinct* symbols that share a name but live in different files render as two separate boxes instead of silently collapsing into one — graphviz keys a node by the exact string in its edge, so without this the second same-named caller/callee vanishes from the picture; a name that occurs in only one file keeps name-as-id, leaving collision-free graphs byte-for-byte identical to before this existed, and only a colliding name gains a file-qualified id plus a `name\n(basename)` label to tell the boxes apart; when `tooltips` is set (`main` sets it for `svg`/`svgz` output only, since graphviz renders node tooltips as `` there and ignores them for raster formats), every drawn node is declared with its `filePath` as a hover tooltip so you can read which file a symbol lives in without cluttering the box — the root symbol is left un-tooltipped since `buildDot` isn't passed its file; if `maxRender` is given, `allocateRenderBudget` splits it as one shared allowance across callers, callees, and `transitiveEdges` — spent in that priority order, so the direct trail is never starved to make room for deeper hops; a `"kind":"file"` node — codegraph's way of saying "this is a module-level/import reference, not a verified function call" — is styled dotted/gray/`"file"` via `edgeStyleAttrs` instead of looking like a real call edge; `transitiveEdges`, an optional array of `{ from, to, depth }` pairs from `--depth > 1` traversal, is colored by `depthColor(depth)` unless it's file-kind — omitting `transitiveEdges`/`maxRender` renders exactly as before either feature existed), `allocateRenderBudget(maxRender, counts)` (pure: the shared-budget split described above, also called from `main` so its stderr notes report what actually got drawn), `dedupeNodes(nodes)` (pure: collapses same-`name`+`filePath` entries, used by both `buildDot` and `main` so distinct-count logic has one source of truth), `dedupeEdges(edges)` (pure: the same idea as `dedupeNodes` but keyed on a `from`+`to` pair, used only for `transitiveEdges`), `depthColor(depth)` (pure: maps hop distance ≥2 to a progressively lighter shade, clamped at the palette's last entry for very deep hops), `isTestRef(node)` (true if a node's name or filePath looks test-related, used to render those edges dashed — applies to `transitiveEdges` too, checked against each edge's `from` node, and is overridden by file-kind styling when both apply; also reused by `buildArchitectureDot` below, passed `{name: '', filePath}` since architecture-mode nodes have no symbol name — the name-based heuristics degrade harmlessly to `false` on an empty name, leaving the path-based ones intact), `truncationWarning(kind, results, limit)` (pure: returns a warning string if `results.length` hit `limit` exactly, else `null` — the *fetch* cap), `renderTruncationNote(kind, distinctCount, cap)` (pure: returns a warning string if the deduplicated count exceeds `cap`, else `null` — the *render* cap; `main` passes each dimension's actual `allocateRenderBudget` allotment as `cap`, not the raw `--max-render`; also reused as-is by `--architecture` mode with `kind: 'files'`), `depthBudgetWarning(truncated, budget)` (pure: returns a warning string if `--depth` traversal hit its node-discovery cap (`--max-depth-nodes`) before finishing, else `null`), `formatMismatchWarning(outFile, format)` (pure: returns a warning string if `--out`'s extension is a real `dot`-recognized format that disagrees with `--format`, else `null`), and `matchSymbolNotFound(out)` (pure: extracts the symbol name from codegraph's plain-text "Symbol not found" message, or `null` if `out` doesn't match that shape). Everything else (`requireOnPath`, `runCodegraph`, `parseCodegraphOutput`, `resolveSymbol`, `collectTransitive`, `main`) is CLI plumbing, not exported — `resolveSymbol` and `collectTransitive` in particular do real I/O (`codegraph` calls), so like `runCodegraph` they're only exercised by the CLI-level tests, not unit-tested directly. - **`--architecture` mode adds:** `unwrapQueryNodes(queryResults)` (pure: unwraps `query`'s `{node, score}` result shape, dropping only a missing `node` — a `"kind":"file"` entry is deliberately KEPT rather than dropped: `codegraph callees ` is a real, working probe against it, and it's the only way to surface calls made from inside a top-level anonymous callback, which codegraph attributes to the enclosing file rather than any named function — see the note on `probeFileEdges` and Known Limitations. Also deliberately does NOT allowlist "callable" kinds like `function`/`method` for the non-file entries — probing a `constant` or `variable` just harmlessly returns an empty `callees` array, which is more robust across languages than maintaining a per-language kind list), `symbolBudgetWarning(truncated, budget)` (pure: same shape as `depthBudgetWarning`, fires when `--max-symbols` cut enumeration short), `duplicateNameWarning(symbols)` (pure: warns — with a few real examples — when any probed symbol name appears in more than one file, since `codegraph callees ` has no way to disambiguate which file's symbol it means; see Known Limitations), `aggregateFileEdges(symbolEdges)` (pure: dedupes/sums `{fromFile, toFile}` pairs from every probed symbol into weighted `{from, to, weight}` file edges, dropping self-file edges and any edge missing a real `filePath` on either end — an unresolved external/stdlib callee has no file of its own and would otherwise render as a bogus `""` node), `topFilesByWeight(fileEdges, maxRender)` (pure: ranks files by total in+out edge weight and returns the top `maxRender` as a `Set`, or `null` meaning "no cap" — deliberately a simple weight cutoff, not a connected-component/centrality algorithm), `buildArchitectureDot(fileEdges, { maxRender })` (pure: the architecture-mode analog of `buildDot` — a dedicated function rather than a `buildDot` branch, since the semantics genuinely differ: no root-symbol highlight, no caller/callee direction split, no file-kind dotted-edge concept since every node already IS a file), and `architectureOutputBaseName(repoPath)` (pure: `sanitizeForFilename(path.basename(path.resolve(repoPath)))`, used for the default `--out` filename). Unexported CLI plumbing: `enumerateSymbols`, `probeFileEdges`, `runArchitectureMode` (real I/O, only exercised via the CLI-level test), and `renderDotToFile` (shared with symbol mode — the write-tempfile/`dot -T`/delete-tempfile tail, previously inline in `main`, extracted once a second call site needed it). + **`--architecture` mode adds:** `unwrapQueryNodes(queryResults)` (pure: unwraps `query`'s `{node, score}` result shape, dropping only a missing `node` — a `"kind":"file"` entry is deliberately KEPT rather than dropped: `codegraph callees ` is a real, working probe against it, and it's the only way to surface calls made from inside a top-level anonymous callback, which codegraph attributes to the enclosing file rather than any named function — see the note on `probeFileEdges` and Known Limitations. Also deliberately does NOT allowlist "callable" kinds like `function`/`method` for the non-file entries — probing a `constant` or `variable` just harmlessly returns an empty `callees` array, which is more robust across languages than maintaining a per-language kind list), `symbolBudgetWarning(truncated, budget)` (pure: same shape as `depthBudgetWarning`, fires when `--max-symbols` cut enumeration short), `duplicateNames(symbols)` (pure: the `Set` of names appearing in more than one distinct **file** — counting files rather than symbol occurrences is what keeps same-file collisions off the file-qualified route; shared by `probeFileEdges` and `duplicateNameWarning` so the fix and the warning can't disagree about what counts as a duplicate), `parseNodeCalls(out, expectedFile)` (pure: reads the file-qualified trail line out of `codegraph node -f`'s text output into `{name, filePath}` callees; returns `[]` for a recognized symbol that calls nothing, `null` on any of the four untrustworthy responses listed under Known Limitations so the caller falls back rather than under-reporting, and drops a callee whose name equals its own file's basename — the structural stand-in for the `"kind":"file"` filter, since the trail carries no kind), `duplicateNameWarning(symbols)` (pure: reports duplicate names in two halves — the symbol collisions `probeFileEdges` resolved via `node -f`, and the file-name collisions that remain genuinely ambiguous; see Known Limitations), `aggregateFileEdges(symbolEdges)` (pure: dedupes/sums `{fromFile, toFile}` pairs from every probed symbol into weighted `{from, to, weight}` file edges, dropping self-file edges and any edge missing a real `filePath` on either end — an unresolved external/stdlib callee has no file of its own and would otherwise render as a bogus `""` node), `topFilesByWeight(fileEdges, maxRender)` (pure: ranks files by total in+out edge weight and returns the top `maxRender` as a `Set`, or `null` meaning "no cap" — deliberately a simple weight cutoff, not a connected-component/centrality algorithm), `buildArchitectureDot(fileEdges, { maxRender })` (pure: the architecture-mode analog of `buildDot` — a dedicated function rather than a `buildDot` branch, since the semantics genuinely differ: no root-symbol highlight, no caller/callee direction split, no file-kind dotted-edge concept since every node already IS a file), and `architectureOutputBaseName(repoPath)` (pure: `sanitizeForFilename(path.basename(path.resolve(repoPath)))`, used for the default `--out` filename). Unexported CLI plumbing: `enumerateSymbols`, `probeFileEdges` (takes the file-qualified route for duplicate-named non-file symbols, the bare-name `callees --json` route for everything else), `probeCallsInFile`, `runArchitectureMode` (real I/O, exercised via the CLI-level tests — including a purpose-built fixture repo covering both a cross-file collision (two `handle`s, which must be split) and a same-file one (two `run`s, which must not be), since this repo's own index has no duplicate names to trip either path), and `renderDotToFile` (shared with symbol mode — the write-tempfile/`dot -T`/delete-tempfile tail, previously inline in `main`, extracted once a second call site needed it). - **`test/run.js`** — assertion-based test suite (Node's built-in `assert`, no framework) covering all of the pure functions above directly. Run via `npm test`. - **`package.json`** — declares the `codeshot` bin pointing at `render/callgraph.js`, and the `test` script. - **`.runechoguardignore`** — false-positive suppression list for the RunEcho pre-commit symbol-resolution guard (a local hook, not part of codeshot itself). Bare-call identifiers the guard can't resolve (e.g. Node builtins passed as function parameters) get listed here instead of disabling the guard. @@ -172,7 +172,7 @@ There is no service to restart, no rollback beyond `npm uninstall -g codeshot` / - **Real, verified codegraph indexing gaps that codeshot has no way to detect or correct, and silently renders as if they were the whole truth** (found by testing codeshot against `runecho`, `codegraph-upstream`, `honeyslate`, and `secret-broker` — real external repos, not this one): same-named methods on unrelated classes/types are sometimes merged into one node, sometimes one is silently dropped, inconsistently between cases; aliased imports (`from x import load as load_config`) can return zero callers for a function with several real call sites; and confirmed-real call sites (verified by reading the actual source) are sometimes simply missing from `codegraph callers`'s response with no indication anything was omitted. None of these are fixable from codeshot's side — it only draws what `codegraph` returns — but they're worth knowing before trusting a sparse-looking graph as complete. - `--depth`'s traversal treats `--limit`/`--max-render` as global, not per-hop — a symbol with a huge fan-out at hop 2 fetches up to `--limit` results for *each* newly discovered node at that hop, which is the main driver of `--max-depth-nodes` exhaustion; there's no independent per-hop limit to trade off against total node count (though the total budget itself is now configurable — see `--max-depth-nodes` above). - A cyclic call graph (recursion, or A and B calling each other) can cause `--depth`'s transitive traversal to rediscover the root symbol or an already-drawn depth-1 node as a "from"/"to" endpoint of a deeper edge. This is harmless (graphviz just draws the extra edge; `dedupeEdges` still collapses exact repeats) but can occasionally show what looks like a redundant edge back into an already-visible node. -- **`--architecture` mode's edges can be misattributed to the wrong file when symbol names collide.** `codegraph callees ` takes a bare name with no way to disambiguate which file's symbol is meant (unlike `codegraph node -f `, which does support this). In symbol mode this ambiguity affects exactly one user-chosen name — a corner case. In `--architecture` mode, Codeshot probes `codegraph callees` for every enumerated symbol in the whole repo, where generically-named methods (`render`, `init`, `get`, `run`, `String`) existing in more than one file is common, not rare, in most real codebases (confirmed: 12 duplicate names out of 500 probed symbols on a real ~1,900-node Go repo). Since `unwrapQueryNodes` also keeps file nodes in the probed set (see below), the same ambiguity now applies to file basenames too — two files named `index.js` in different directories are indistinguishable to a bare-name `callees` probe. `duplicateNameWarning` surfaces both cases on stderr with real examples from the current run, but Codeshot has no way to fix the underlying ambiguity — same as the other `codegraph` indexing gaps documented above, it can only draw what `codegraph` returns. +- **`--architecture` mode resolves colliding *symbol* names by file, but still cannot resolve colliding *file* names.** `codegraph callees ` takes a bare name with no way to disambiguate which file's symbol is meant, and — measured against codegraph 1.5.0 — it answers with the **union** of every same-named symbol's callees, so a bare-name probe doesn't merely pick the wrong file, it invents edges that exist in neither. On a two-`handle` fixture repo, 4 edges were drawn where only 2 were real. This matters at `--architecture`'s scale: generically-named methods (`render`, `init`, `get`, `run`, `String`) existing in more than one file is common, not rare, in most real codebases (confirmed: 12 duplicate names out of 500 probed symbols on a real ~1,900-node Go repo). `probeFileEdges` therefore re-probes exactly the symbols whose name appears in more than one **file** with `codegraph node -f `, the one file-qualified probe codegraph offers, and reads its trail line via `parseNodeCalls`. The "more than one file" precision is load-bearing: two symbols sharing a name *inside one file* (Go's `String()` on two types, two class methods) have no file-attribution ambiguity at all, the bare-name union is already exactly right for them, and routing them through `node -f` would *lose* edges — codegraph answers a same-file collision with two concatenated trail blocks. Costs, all deliberate: `node -f` returns the symbol's full source on every call, so this route is scoped to the duplicate subset rather than made the default; and it has **no `--json`**, so `parseNodeCalls` reads markdown text. That text is a human-facing summary, not an API, and is treated with matching suspicion — `parseNodeCalls` returns `null` (→ fall back to the bare-name probe, which over-reports rather than under-reports) on four measured conditions: no trail section at all (file-mode output, or a changed format); more than one trail section; a trail codegraph truncated with `+N more` (**it caps at 12 entries** — `main` in this repo has 23 callees and its trail shows 12, so taking the visible ones would trade fabricated edges for missing ones, the worse failure per the note above); or a reported `**Location:**` outside the file that was requested (`-f` is a *preference*, not a filter — measured: `node -f no/such/file.js -- buildDot` still answers with `render/callgraph.js`'s `buildDot`, exit 0). It also scans only after the trail marker, since the embedded source can itself contain trail-shaped lines, and `parseCodegraphOutput` matches codegraph's not-found message against only the first non-empty line for the same reason. **The residue that is still genuinely unfixable:** `unwrapQueryNodes` keeps *file* nodes in the probed set (see below), and `node -f` answers a file node in file mode — a different output shape — so two files named `index.js` in different directories remain indistinguishable to their bare-name probe. `duplicateNameWarning` reports both halves separately: which collisions were resolved, and which remain a real risk. - **`--architecture` mode probes file nodes' `callees`, not just named symbols', specifically to catch calls made from inside a top-level anonymous callback** (e.g. `test('...', () => { realCall() })` — a common pattern in test suites, including this repo's own `test/run.js`). codegraph attributes such a call to the enclosing file, not any named function, since no named function contains it; without probing the file node itself, `--architecture` mode would be structurally blind to that entire category of real cross-file dependency. This was confirmed against a real regression-turned-non-regression in codegraph itself: a codegraph 1.4.1 bug (fixed in 1.5.0, see `git log` for `LITERAL_RECEIVER_TYPES` in codegraph's history) briefly caused a *different*, spurious file-node-unrelated edge to appear in this project's own diagram — a call like `/regex/.test(x)` in `render/callgraph.js` got bare-name-matched to `test/run.js`'s own `test(name, fn)` helper purely by name collision. That edge is gone as of codegraph 1.5.0+ (correctly — it was never real); probing file nodes is what makes the *actual* dependency (`test/run.js` calling into `render/callgraph.js`) visible in its place. `probeFileEdges` skips any `"kind":"file"` *callee* it gets back from a probe — the same unverified-reference status that makes symbol mode draw it dotted/gray rather than as a real call (see Visual Encoding) means it must not be counted as a real cross-file edge here either, or this exact fabricated-edge problem reappears via a different mechanism. Verified empirically against this repo's own index (0 `"kind":"file"` callees among 100 probed) — but a repo where a file's *only* top-level reference is an unresolved `require(...)` with no other calls would exercise this path, and there's no dedicated test for it (real I/O against a live index would be needed to construct one). Two costs of probing file nodes, both real but not separately mitigated: enumeration and `--max-symbols` now compete real symbols against file nodes for the same fixed slot budget in an order codegraph doesn't guarantee (see the `--limit` note below) — on a repo near the cap, file nodes could crowd out real-symbol coverage with no warning distinguishing the two; and the probe count (and thus the already-"multi-minute" wall-clock cost) grows by roughly the repo's file count, since `probeFileEdges` is strictly sequential. - **`--architecture` mode's enumeration query (`codegraph query --json --limit -- ''`) has confirmed, inconsistent `--limit` behavior worth knowing before trusting it.** Without `--limit`, an empty-string query silently caps around 50 results regardless of actual repo size (confirmed on a real 1,870-node index). Passing a large `--limit` (confirmed with both 500 and 2000 against that same index) instead returns *every* result codegraph has — more than the requested number, not capped at it. Codeshot works around this by always passing a very large `--limit` to force the "return everything" behavior, then applying the real `--max-symbols` cap client-side — but the *order* codegraph returns results in in that case is unknown (untested whether it's insertion order, alphabetical, ID-based, or something else), so on a repo larger than `--max-symbols`, the kept subset should not be assumed to sample evenly across the whole repo — it could be clustered by file, directory, or however codegraph happens to have stored them. diff --git a/USAGE.md b/USAGE.md index 156acea..ebc16f9 100644 --- a/USAGE.md +++ b/USAGE.md @@ -104,6 +104,7 @@ fall back to a raw byte-compare, which does require CI to use the same - **"codeshot: '...' has no callers or callees in codegraph's index"** — The symbol exists but nothing calls it and it calls nothing, so the diagram is just that one box. It may be genuinely unused (dead code) or a top-level entry point — or codegraph's index is incomplete for its file (see the sparse-diagram note below). The image is still written; the warning just explains why it's a lone box. - **"codeshot: --architecture found no cross-file call edges — the diagram is blank"** — codegraph reported no resolved calls *between files* in this repo, so there's nothing for the file-level graph to draw. Expected for a small or single-file repo; otherwise the index is likely missing or stale — run `codegraph init `, then `codegraph status` to confirm it built. A blank image is still written so the `--out` path exists. - **"codeshot: symbol '...' not found in codegraph's index"** — Double-check the exact spelling/casing of the symbol name, and confirm `--path` points at the repo that actually contains it. +- **"codeshot: N symbol name(s) appear in more than one file ... N file name(s) appear in more than one directory"** — `--architecture` only. The first half is informational: those names collide across files, so Codeshot re-probed them with a file-qualified `codegraph node -f` and their edges are attributed correctly. Where that probe can't answer completely (CodeGraph truncates its call list at 12 entries, among other cases) Codeshot silently falls back to the bare-name probe, which over-reports rather than under-reports — so a colliding name with a very large fan-out can still show extra edges. The second half is a real caveat you can't turn off: two files sharing a basename (two `index.js`) can only be probed by that bare name, so edges involving them may land on the wrong one. Rename one of the files, or treat those specific edges as unverified. - **The diagram is real but looks sparse — CodeGraph's index has known gaps.** Tested against several real codebases: same-named methods on unrelated classes are sometimes merged or one silently dropped, aliased imports (`import x as y`) can return zero callers for a genuinely well-used function, and dependency-injection patterns (e.g. FastAPI's `Depends()`) often don't resolve to real caller functions at all. Codeshot only draws what CodeGraph reports — if a diagram looks thinner than you expect for a symbol you know is heavily used, that's more likely a CodeGraph indexing gap than a Codeshot bug. A `dotted gray "file"` edge (see above) is one visible symptom of this; a *missing* edge is the invisible version and harder to catch — spot-check against the real source if it matters. - **`--out diagram.svg` produced a PNG (or vice versa)** — Codeshot only ever writes what `--format` says; it never infers format from `--out`'s extension. If you see this, you forgot `--format svg` (or whichever format matches the extension you wanted) — Codeshot now warns about this mismatch on stderr before it happens, so check for that warning first. - **The image looks unreadable / too cluttered** — This usually means the symbol has a very large number of callers or callees. Rerun with `--max-render ` (e.g. `--max-render 30`) to cap how many are drawn — Codeshot will still tell you on stderr how many were left out. If the nodes themselves are legible but hard to read at the zoom level a PNG forces on you, try `--format svg` instead — it stays crisp at any zoom, so it's worth trying before reaching for `--max-render` if you still want to see everything. diff --git a/render/callgraph.js b/render/callgraph.js index ce40476..aecdb9b 100755 --- a/render/callgraph.js +++ b/render/callgraph.js @@ -107,13 +107,26 @@ function exitNotInitialized(args, fatal) { // must survive an individual bad one (e.g. --architecture's enumeration) — // process.exit() would otherwise kill the whole scan, and a bare try/catch // around runCodegraph does NOT catch process.exit(). -function parseCodegraphOutput(out, args, { fatal = true } = {}) { - const notFound = matchSymbolNotFound(out); +// `json: false` returns the raw stdout instead of parsing it. Needed because +// `codegraph node` is the one subcommand codeshot calls that has no --json flag +// (callers/callees/query all do) — its output is markdown-ish text only. The +// not-found and not-initialized handling above it is identical either way, which +// is why this is an option here rather than a separate parallel runner. +function parseCodegraphOutput(out, args, { fatal = true, json = true } = {}) { + // Scoped to the FIRST non-empty line, never the whole response. codegraph + // prints the not-found message alone, on line one — but under `json: false` + // a successful response embeds arbitrary indexed source, which can contain + // that sentence as content (this repo's own test/run.js contains the literal + // string). Scanning the whole body is the same false-positive trap that keeps + // the not-initialized check off stdout entirely; see runCodegraph. + const firstLine = String(out).split('\n').find(l => l.trim().length > 0) || ''; + const notFound = matchSymbolNotFound(firstLine); if (notFound !== null) { if (!fatal) return null; console.error(`codeshot: symbol '${notFound}' not found in codegraph's index — check the spelling/casing, or confirm --path points at the repo that contains it.`); process.exit(1); } + if (!json) return out; // NOTE: the "not initialized" case is deliberately NOT handled here on stdout — // see exitNotInitialized. A successful codegraph response can contain that // phrase as indexed source content; it's only a real signal on stderr with a @@ -132,7 +145,7 @@ function parseCodegraphOutput(out, args, { fatal = true } = {}) { // (confirmed: exceeded on a real 1,870-node index) — raised well above any // single codegraph response this tool realistically produces. const MAX_CODEGRAPH_BUFFER = 64 * 1024 * 1024; -async function runCodegraph(args, { fatal = true } = {}) { +async function runCodegraph(args, { fatal = true, json = true } = {}) { let result; try { result = await execFileAsync('codegraph', args, { encoding: 'utf8', maxBuffer: MAX_CODEGRAPH_BUFFER }); @@ -146,7 +159,7 @@ async function runCodegraph(args, { fatal = true } = {}) { if (matchNotInitialized(`${err.stderr || ''}`)) return exitNotInitialized(args, fatal); throw err; } - return parseCodegraphOutput(result.stdout, args, { fatal }); + return parseCodegraphOutput(result.stdout, args, { fatal, json }); } // `codegraph status` warns when an index was left mid-build ("N references from @@ -499,20 +512,109 @@ function emptyArchitectureWarning(fileEdges) { return `codeshot: --architecture found no cross-file call edges — the diagram is blank. codegraph's index has no resolved calls between files in this repo (it may be small or single-file, or the index may be missing — run 'codegraph init ' to build it, then 'codegraph status' to confirm).`; } -// codegraph's callers/callees take a bare name with no --file disambiguation -// (unlike `codegraph node -f`), so two same-named symbols in different files -// are genuinely ambiguous to a `codegraph callees ` probe — a real risk -// at --architecture's scale (probing hundreds of names), not a corner case. -// Since unwrapQueryNodes now keeps file nodes in the probed set too, this also -// catches two files sharing a basename in different directories (e.g. two -// `index.js`) — the same ambiguity, just on a file's own name. +// The set of names that appear in more than one distinct FILE. Shared by +// probeFileEdges (which re-probes exactly these, file-qualified) and by +// duplicateNameWarning, so the fix and the warning can never disagree about +// what counts as a duplicate. +// +// Counting distinct files, not symbol occurrences, is load-bearing: two symbols +// sharing a name inside ONE file (Go's `String()` on two types in one file, two +// class methods, overloads) have no file-attribution ambiguity at all — the +// bare-name probe's union of their callees is already exactly right, and +// routing them through the file-qualified probe would only lose edges. Pure. +function duplicateNames(symbols) { + const files = new Map(); + for (const s of symbols || []) { + if (!files.has(s.name)) files.set(s.name, new Set()); + files.get(s.name).add(s.filePath); + } + return new Set([...files.entries()].filter(([, f]) => f.size > 1).map(([name]) => name)); +} + +// Parses the trail that `codegraph node -f ` prints at the end of +// its output, into file-qualified callees. This is the ONLY file-disambiguated +// callee probe codegraph offers — `codegraph callees` takes a bare name with no +// --file flag — so it's how --architecture resolves same-named symbols in +// different files instead of guessing. +// +// Returns null whenever the response can't be trusted to be a COMPLETE call list +// for exactly the symbol in `expectedFile`, so the caller falls back to the +// bare-name probe rather than silently under-reporting. Four ways it bails, each +// a real measured behavior of codegraph 1.5.0, not defensive padding: +// +// 1. No trail section. File-mode output (which `-f ` returns) +// has no trail at all, and neither does an unrecognized/changed format. +// A symbol that genuinely calls nothing still prints the trail header, so +// this cleanly separates "no calls" ([]) from "no answer" (null). +// 2. More than one trail section. codegraph concatenates every match into one +// response, so a multi-block answer means the probe was still ambiguous; +// keeping just one block would silently drop the others' edges. +// 3. The trail is TRUNCATED. codegraph caps the line at 12 entries and appends +// `+N more` (measured: `main` in this repo has 23 callees, the trail shows +// 12). The trail is a human-facing summary, not an API — taking the visible +// 12 would trade this fix's fabricated edges for missing ones, which +// TECHNICAL.md's own limitations call the worse failure ("a *missing* edge +// is the invisible version and harder to catch"). +// 4. The reported location isn't in `expectedFile`. `-f` is a PREFERENCE, not a +// filter — measured: `node -f test/run.js -- buildDot` and even +// `-f no/such/file.js -- buildDot` both happily answer with +// render/callgraph.js's buildDot, exit 0. Without this check the "fix" would +// confidently attribute another file's edges and never fall back. +// +// Reading text rather than JSON is the acknowledged cost (`codegraph node` has +// no --json). Scanning only AFTER the trail marker is the other guard: the +// response embeds the symbol's own source, which in this repo can itself contain +// lines that look like a trail. Pure. +function parseNodeCalls(out, expectedFile) { + const text = String(out || ''); + const trailAt = text.indexOf('**Trail'); + if (trailAt === -1) return null; + if (text.indexOf('**Trail', trailAt + 1) !== -1) return null; + const location = text.match(/^\*\*Location:\*\*\s*(.+?):(\d+)\s*$/m); + if (!location) return null; + if (expectedFile !== undefined && location[1].trim() !== expectedFile) return null; + const tail = text.slice(trailAt); + const line = tail.match(/^\*\*Calls\s*→\*\*\s*(.+)$/m); + if (!line) return []; + if (/\+\d+\s+more/.test(line[1])) return null; + const calls = []; + const entry = /([^\s,()]+)\s+\(([^()]+):(\d+)\)/g; + let m; + while ((m = entry.exec(line[1])) !== null) { + const [, name, filePath] = m; + // The trail carries no `kind`, so the file-node filter probeFileEdges applies + // to the JSON path has to be structural here: codegraph renders a file node as + // its own basename (e.g. `run.js (test/run.js:1)`). Counting one as a real call + // would fabricate exactly the full-weight edge that keeping file nodes out of + // aggregateFileEdges exists to prevent. + if (name === path.basename(filePath)) continue; + calls.push({ name, filePath }); + } + return calls; +} + +// codegraph's callers/callees take a bare name with no --file disambiguation, so +// two same-named symbols in different files are ambiguous to a bare-name probe — +// a real risk at --architecture's scale (probing hundreds of names), not a corner +// case. probeFileEdges now resolves that for ordinary symbols via `node -f`, so +// this reports the fix for those and warns only about the residue it still can't +// disambiguate: file nodes, which unwrapQueryNodes deliberately keeps in the +// probed set and which `node -f` answers in a different (file-mode) shape. function duplicateNameWarning(symbols) { - const counts = new Map(); - for (const s of symbols || []) counts.set(s.name, (counts.get(s.name) || 0) + 1); - const dupes = [...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name); - if (!dupes.length) return null; - const examples = dupes.slice(0, 3).join(', '); - return `codeshot: ${dupes.length} symbol name(s) appear in more than one file (e.g. ${examples}) — codegraph's callees can't disambiguate by file, so edges for these may be attributed to the wrong file.`; + const dupes = duplicateNames(symbols); + if (!dupes.size) return null; + const fileDupes = [...new Set((symbols || []) + .filter(s => s.kind === 'file' && dupes.has(s.name)) + .map(s => s.name))]; + const resolved = [...dupes].filter(n => !fileDupes.includes(n)); + const parts = []; + if (resolved.length) { + parts.push(`${resolved.length} symbol name(s) appear in more than one file (e.g. ${resolved.slice(0, 3).join(', ')}) — codeshot re-probes these with a file-qualified 'codegraph node -f' so their edges land on the right file, falling back to the bare name (which over-reports rather than under-reports) where that probe can't answer completely.`); + } + if (fileDupes.length) { + parts.push(`${fileDupes.length} file name(s) appear in more than one directory (e.g. ${fileDupes.slice(0, 3).join(', ')}) — these are still probed by bare name, so their edges may be attributed to the wrong file.`); + } + return `codeshot: ${parts.join(' ')}`; } // Drops self-file edges (intra-file calls aren't cross-module architecture) @@ -592,28 +694,57 @@ async function enumerateSymbols(repoPath, maxSymbols) { return { symbols: symbols.slice(0, maxSymbols), truncated }; } +// The file-qualified probe, used only for names that are actually ambiguous. +// Returns null whenever the answer can't be trusted to be this file's complete +// call list — see parseNodeCalls for the four cases — so the caller falls back +// to the bare-name probe. The fallback is over-inclusive (it's the union across +// same-named symbols, the very thing this fix narrows) but never under-inclusive, +// which is the right way round to fail. +async function probeCallsInFile(symbol, repoPath) { + const out = await runCodegraph( + ['node', '--path', repoPath, '-f', symbol.filePath, '--', symbol.name], + { fatal: false, json: false } + ); + return out === null ? null : parseNodeCalls(out, symbol.filePath); +} + // Sequential — same concurrency hazard as collectTransitive: parallel // codegraph calls against one index race on its schema_versions table. -// fatal:false + the null check below is what lets one ambiguous/not-found +// fatal:false + the null checks below are what let one ambiguous/not-found // probed name (real and expected at this scale — see duplicateNameWarning) // skip past without aborting the whole multi-minute scan. +// +// Duplicate-named symbols take the file-qualified `node -f` route; everything +// else keeps the cheaper bare-name `callees --json` route. That split is +// deliberate: `node -f` returns the symbol's full source on every call, which is +// only affordable because duplicates are a small slice of a repo (~2% measured +// on a real ~1,900-node Go index), and it keeps the text-parsing path off 98% of +// the scan. async function probeFileEdges(symbols, repoPath, limit) { const edges = []; + const dupes = duplicateNames(symbols); for (let i = 0; i < symbols.length; i++) { const s = symbols[i]; - const result = await runCodegraph( - ['callees', '--path', repoPath, '--limit', String(limit), '--json', '--', s.name], - { fatal: false } - ); - if (result === null) continue; - for (const c of result.callees || []) { + // File nodes are excluded: `node -f` answers those in file mode, a different + // output shape parseNodeCalls deliberately rejects. + let callees = dupes.has(s.name) && s.kind !== 'file' && s.filePath + ? await probeCallsInFile(s, repoPath) + : null; + if (callees === null) { + const result = await runCodegraph( + ['callees', '--path', repoPath, '--limit', String(limit), '--json', '--', s.name], + { fatal: false } + ); + if (result === null) continue; // A "kind":"file" callee is a module-level/import reference codegraph // couldn't resolve to a real call site — symbol mode already treats // these as unverified (edgeStyleAttrs draws them dotted/gray, not a // real call edge); counting one as a full-weight file-to-file edge // here would fabricate exactly the kind of edge this file-node-probing // change exists to stop fabricating. - if (c.kind === 'file') continue; + callees = (result.callees || []).filter(c => c.kind !== 'file'); + } + for (const c of callees) { edges.push({ fromFile: s.filePath, toFile: c.filePath }); } if ((i + 1) % 25 === 0) { @@ -1059,7 +1190,7 @@ if (require.main === module) { module.exports = { buildDot, nodeIdentities, isTestRef, truncationWarning, dedupeNodes, renderTruncationNote, dedupeEdges, depthColor, depthBudgetWarning, allocateRenderBudget, formatMismatchWarning, matchSymbolNotFound, - unwrapQueryNodes, symbolBudgetWarning, duplicateNameWarning, aggregateFileEdges, + unwrapQueryNodes, symbolBudgetWarning, duplicateNameWarning, duplicateNames, parseNodeCalls, aggregateFileEdges, topFilesByWeight, buildArchitectureDot, architectureOutputBaseName, applyEmbed, embedMarkers, embedRelLink, parseUnresolvedRefs, svgStructure, decodeXmlEntities, diff --git a/test/run.js b/test/run.js index e50faf8..e92b905 100644 --- a/test/run.js +++ b/test/run.js @@ -5,7 +5,7 @@ const assert = require('assert'); const { buildDot, nodeIdentities, isTestRef, truncationWarning, dedupeNodes, renderTruncationNote, dedupeEdges, depthColor, depthBudgetWarning, allocateRenderBudget, formatMismatchWarning, matchSymbolNotFound, - unwrapQueryNodes, symbolBudgetWarning, duplicateNameWarning, aggregateFileEdges, + unwrapQueryNodes, symbolBudgetWarning, duplicateNameWarning, duplicateNames, parseNodeCalls, aggregateFileEdges, topFilesByWeight, buildArchitectureDot, architectureOutputBaseName, applyEmbed, embedMarkers, embedRelLink, parseUnresolvedRefs, svgStructure, decodeXmlEntities, @@ -648,6 +648,169 @@ test('duplicateNameWarning is null when every name is unique', () => { assert.strictEqual(duplicateNameWarning(symbols), null); }); +test('duplicateNameWarning reports ordinary duplicates as re-probed, not as possibly-wrong', () => { + const symbols = [ + { name: 'render', kind: 'function', filePath: 'a.js' }, + { name: 'render', kind: 'function', filePath: 'b.js' }, + ]; + const warning = duplicateNameWarning(symbols); + assert.match(warning, /file-qualified/); + assert.doesNotMatch(warning, /may be attributed to the wrong file/); +}); + +test('duplicateNameWarning still warns about duplicate file names, which node -f cannot disambiguate', () => { + const symbols = [ + { name: 'index.js', kind: 'file', filePath: 'a/index.js' }, + { name: 'index.js', kind: 'file', filePath: 'b/index.js' }, + ]; + const warning = duplicateNameWarning(symbols); + assert.match(warning, /may be attributed to the wrong file/); + assert.match(warning, /index\.js/); +}); + +test('duplicateNames returns only names seen in more than one file', () => { + const dupes = duplicateNames([ + { name: 'render', filePath: 'a.js' }, + { name: 'render', filePath: 'b.js' }, + { name: 'unique', filePath: 'c.js' }, + ]); + assert.deepStrictEqual([...dupes], ['render']); +}); + +test('duplicateNames ignores same-name-same-file symbols, which have no file ambiguity to resolve', () => { + // Two methods named String() on different types in one file: the bare-name + // probe's union of their callees is already exactly right for that file, and + // re-probing by file would only lose edges. + const dupes = duplicateNames([ + { name: 'String', filePath: 'svc.go' }, + { name: 'String', filePath: 'svc.go' }, + ]); + assert.deepStrictEqual([...dupes], []); +}); + +// Fixtures below are real `codegraph node -f` output shapes from the pinned +// 1.5.0 — the whole point of parseNodeCalls is that it reads text, not JSON, so +// these pin the format the parser was written against. +const NODE_OUTPUT_HEAD = [ + '**buildDot** (function)', + '', + '**Location:** render/callgraph.js:314', + '**Signature:** `(symbol, callers = [], callees = [])`', + '', +]; + +const TRAIL_HEADER = '**Trail — codegraph_node any of these to follow it (no Read needed)**'; + +test('parseNodeCalls reads file-qualified callees out of a real node -f trail', () => { + const out = [ + ...NODE_OUTPUT_HEAD, + TRAIL_HEADER, + '**Calls →** dedupeNodes (render/callgraph.js:223), depthColor (render/other.js:251)', + '**Called by ←** main (render/callgraph.js:821)', + ].join('\n'); + assert.deepStrictEqual(parseNodeCalls(out, 'render/callgraph.js'), [ + { name: 'dedupeNodes', filePath: 'render/callgraph.js' }, + { name: 'depthColor', filePath: 'render/other.js' }, + ]); +}); + +test('parseNodeCalls returns [] — not null — for a recognized symbol that calls nothing', () => { + const out = [...NODE_OUTPUT_HEAD, TRAIL_HEADER, '**Called by ←** main (render/callgraph.js:821)'].join('\n'); + assert.deepStrictEqual(parseNodeCalls(out, 'render/callgraph.js'), []); +}); + +test('parseNodeCalls returns null on unrecognized output so the caller falls back instead of inventing an empty result', () => { + for (const bad of ['', 'Symbol "X" not found in the codebase', '{"callees":[]}', 'total garbage']) { + assert.strictEqual(parseNodeCalls(bad, 'a.js'), null, `expected null for ${JSON.stringify(bad)}`); + } +}); + +test('parseNodeCalls returns null for file-mode output, which has a header and a location but no trail', () => { + // `codegraph node -f ` answers in file mode. Returning [] + // here would read as "this file calls nothing" and suppress the fallback. + const out = ['**callgraph.js** (file)', '', '**Location:** render/callgraph.js:1', '', '```javascript', "1\t#!/usr/bin/env node", '```'].join('\n'); + assert.strictEqual(parseNodeCalls(out, 'render/callgraph.js'), null); +}); + +test('parseNodeCalls returns null when codegraph truncates the trail with "+N more"', () => { + // Measured on codegraph 1.5.0: the trail line caps at 12 entries. `main` in + // this repo has 23 callees and its trail shows 12 + "+11 more". Taking the + // visible 12 would silently drop real edges — worse than the over-reporting + // bare-name probe we fall back to. + const out = [ + ...NODE_OUTPUT_HEAD, + TRAIL_HEADER, + '**Calls →** a (x.js:1), b (x.js:2), c (x.js:3), +11 more', + ].join('\n'); + assert.strictEqual(parseNodeCalls(out, 'render/callgraph.js'), null); +}); + +test('parseNodeCalls returns null when the answer is for a different file than the one probed', () => { + // `-f` is a preference, not a filter: codegraph answers with another file's + // same-named symbol (exit 0, no error) when the requested file has no match. + const out = [...NODE_OUTPUT_HEAD, TRAIL_HEADER, '**Calls →** alpha (a/alpha.js:1)'].join('\n'); + assert.strictEqual(parseNodeCalls(out, 'b/svc.js'), null); + assert.deepStrictEqual(parseNodeCalls(out, 'render/callgraph.js'), [{ name: 'alpha', filePath: 'a/alpha.js' }]); +}); + +test('parseNodeCalls returns null when codegraph concatenates more than one matching symbol', () => { + // Two same-named symbols in one file come back as two trail blocks; keeping + // only one would silently drop the other's edges. + const out = [ + ...NODE_OUTPUT_HEAD, + TRAIL_HEADER, + '**Calls →** alpha (a/alpha.js:1)', + '', + '**handle** (function)', + '', + '**Location:** render/callgraph.js:400', + TRAIL_HEADER, + '**Calls →** beta (b/beta.js:1)', + ].join('\n'); + assert.strictEqual(parseNodeCalls(out, 'render/callgraph.js'), null); +}); + +test('parseNodeCalls drops a file-node callee, which has no real call site to draw', () => { + const out = [ + ...NODE_OUTPUT_HEAD, + TRAIL_HEADER, + '**Calls →** run.js (test/run.js:1), dedupeNodes (render/callgraph.js:223)', + ].join('\n'); + assert.deepStrictEqual(parseNodeCalls(out, 'render/callgraph.js'), [{ name: 'dedupeNodes', filePath: 'render/callgraph.js' }]); +}); + +test('parseNodeCalls ignores a trail-shaped line inside the embedded source body', () => { + // node -f echoes the symbol's own source, and this repo's source legitimately + // contains lines describing the trail format. + const out = [ + ...NODE_OUTPUT_HEAD, + '```javascript', + '315\t// the Calls line, e.g. dedupeNodes (render/callgraph.js:223)', + '```', + TRAIL_HEADER, + '**Calls →** dedupeNodes (render/callgraph.js:223)', + ].join('\n'); + assert.deepStrictEqual(parseNodeCalls(out, 'render/callgraph.js'), [{ name: 'dedupeNodes', filePath: 'render/callgraph.js' }]); +}); + +test('parseCodegraphOutput does not mistake indexed source content for a not-found message', () => { + // Under json:false the response embeds arbitrary source, and this repo's own + // test file contains the literal not-found sentence. Matching it anywhere in + // the body would null out a perfectly good answer and silently fall back. + const out = [ + '**probe** (function)', + '', + '**Location:** test/run.js:700', + '', + '```javascript', + '700\tassert.match(err, /Symbol "Foo" not found in the codebase/);', + '```', + TRAIL_HEADER, + '**Calls →** assert (test/run.js:4)', + ].join('\n'); + assert.strictEqual(parseCodegraphOutput(out, ['node'], { fatal: false, json: false }), out); +}); + test('aggregateFileEdges drops self-file edges', () => { const edges = aggregateFileEdges([{ fromFile: 'a.js', toFile: 'a.js' }, { fromFile: 'a.js', toFile: 'b.js' }]); assert.strictEqual(edges.length, 1); @@ -745,6 +908,58 @@ test('CLI --architecture runs end-to-end against this repo\'s own real codegraph } }); +// The regression test for the duplicate-name fix. This repo's own index has zero +// duplicate names, so the self-test above can never exercise the file-qualified +// path — it needs a purpose-built repo where the bare-name probe is provably +// wrong. Measured against the real codegraph 1.5.0: `codegraph callees handle` +// returns the UNION of both files' callees, so the pre-fix code drew 4 edges of +// which 2 were fabricated. Building the fixture index takes ~2s. +test('--architecture attributes a duplicate-named symbol\'s edges to its own file, not the union of every same-named symbol', () => { + const { execFileSync } = require('child_process'); + const path = require('path'); + const fs = require('fs'); + const os = require('os'); + const callgraphJs = path.join(__dirname, '..', 'render', 'callgraph.js'); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codeshot-dupname-')); + try { + fs.mkdirSync(path.join(dir, 'a')); + fs.mkdirSync(path.join(dir, 'b')); + // Two files defining `handle`, each calling a DIFFERENT function. The bare + // name alone cannot tell them apart; the containing file can. + fs.writeFileSync(path.join(dir, 'a', 'svc.js'), 'const { alpha } = require("./alpha");\nfunction handle() { return alpha(); }\nmodule.exports = { handle };\n'); + fs.writeFileSync(path.join(dir, 'a', 'alpha.js'), 'function alpha() { return 1; }\nmodule.exports = { alpha };\n'); + fs.writeFileSync(path.join(dir, 'b', 'svc.js'), 'const { beta } = require("../b/beta");\nfunction handle() { return beta(); }\nmodule.exports = { handle };\n'); + fs.writeFileSync(path.join(dir, 'b', 'beta.js'), 'function beta() { return 2; }\nmodule.exports = { beta };\n'); + // Two `run` methods in ONE file, calling different things. Same name, but no + // file ambiguity — so this must keep the bare-name probe and keep BOTH edges. + // Routing it through the file-qualified probe loses one: codegraph answers a + // same-file collision with two concatenated trail blocks. + fs.mkdirSync(path.join(dir, 'c')); + fs.writeFileSync(path.join(dir, 'c', 'dual.js'), 'const { alpha } = require("../a/alpha");\nconst { beta } = require("../b/beta");\nclass A { run() { return alpha(); } }\nclass B { run() { return beta(); } }\nmodule.exports = { A, B };\n'); + + try { + execFileSync('codegraph', ['init', dir], { stdio: 'pipe', timeout: 180000 }); + } catch { + console.log(' # skipped: `codegraph` not on PATH or could not index the fixture repo'); + return; + } + + const out = path.join(dir, 'arch.dot'); + execFileSync('node', [callgraphJs, '--architecture', '--path', dir, '--out', out, '--format', 'dot'], { encoding: 'utf8', stdio: 'pipe', timeout: 180000 }); + const dot = fs.readFileSync(out, 'utf8'); + + assert.match(dot, /"a\/svc\.js" -> "a\/alpha\.js"/, 'expected the real edge from a/svc.js'); + assert.match(dot, /"b\/svc\.js" -> "b\/beta\.js"/, 'expected the real edge from b/svc.js'); + assert.doesNotMatch(dot, /"a\/svc\.js" -> "b\/beta\.js"/, 'a/svc.js does not call beta — this is the misattributed edge the fix removes'); + assert.doesNotMatch(dot, /"b\/svc\.js" -> "a\/alpha\.js"/, 'b/svc.js does not call alpha — this is the misattributed edge the fix removes'); + assert.match(dot, /"c\/dual\.js" -> "a\/alpha\.js"/, 'same-file duplicates must keep both edges, not just the last trail block'); + assert.match(dot, /"c\/dual\.js" -> "b\/beta\.js"/, 'same-file duplicates must keep both edges, not just the last trail block'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('--architecture rejects a argument', () => { const { execFileSync } = require('child_process'); let threw = false;