Commit graph

8 commits

Author SHA1 Message Date
Shane Thurston Wijaya
fc885a4bf3
docs(claude-skills): bind repository and worktree identity in multi repo skills (#2981) 2026-08-18 14:09:09 +00:00
Gergő Magyar
2be508e796
fix(mcp): stop scaling the detect_changes query with the diff's hunk count (#2915) (#2930)
* fix(mcp): map diff hunks to symbols without per-hunk OR conditions (#2915)

`detect_changes` folded one `(n.startLine <= $hunkEndI AND n.endLine >=
$hunkStartI)` pair per diff hunk into a single WHERE clause, one query per
changed file. A machine-generated file (cache JSON, lockfile, golden fixture)
diffs at thousands of hunks with `-U0`, and the expression tree that produces
overflows LadybugDB's recursive evaluator copy on a TaskScheduler worker
thread: a bare SIGBUS with no error output where secondary threads get 512 KB
of stack (macOS), a swallowed 30s query timeout where they get more (Linux),
which the CLI then printed as "No changes detected." with exit 0.

Coalesce each file's hunks into sorted, disjoint ranges and run the overlap
test in JS instead. Only ranges that overlap or abut are merged, so the union
covers exactly the lines the raw hunks covered. Query text and parameters are
now identical whether a file changed in 1 place or 100,000, and files are
queried in batches of 100 rather than one full node scan each.

Reproduced on Linux by running the engine with macOS-sized (512 KB) thread
stacks: 2,500 hunks passed, 3,333 and 4,000 segfaulted — matching the reporter's
macOS threshold table. After the change the same repo maps a 100,001-hunk diff
in 2.1s with no crash.

Also fixes a line-base mismatch the rewrite exposed: graph rows are 0-based
(#2377) while git hunk lines are 1-based, so the raw comparison shifted every
symbol one line up. An edit to a symbol's LAST line reported nothing changed —
a one-line function whose body was edited was invisible to the pre-commit gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* fix(cli): say when a detect_changes result is partial (#2915)

When a graph query fails, `detect_changes` swallows the error, sets
`partial: true` and leaves the counts at zero (#2283). The CLI formatter never
read that flag, so a degraded run printed "No changes detected." and exited 0 —
the pre-commit safety gate reporting a clean bill of health for a check that
did not complete. Print the partial note in both the empty and non-empty
branches.

Also restore the `Symbol` placeholder for rows whose label came back as an
empty string: the changed-symbol mapping now keeps `''` instead of dropping it
to undefined, so the formatter needs `||`, not `??`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor(mcp): bound the hunk→symbol query and simplify the overlap helpers (#2915)

Cleanup pass over the #2915 fix. No change to which symbols detect_changes
reports, except that a node matched by two changed paths is now reported once.

* Push a per-file [lo, hi] span into the query. Coalesced ranges are sorted and
  disjoint, so a file's whole touched span is free, and the engine can drop the
  symbols outside it instead of shipping every row in the file across the native
  boundary. Measured on a 400-file batch against a 25k-node index: 546ms/13,870
  rows before, 84ms/1,555 rows after, identical kept set. Depth stays constant
  (two comparisons per file, not per hunk), so #2915 cannot come back — the JS
  test still rejects symbols landing in the gaps between hunks. The struct-list
  parameter was verified against @ladybugdb/core 0.18.3 and 0.19.1.
* Convert hunks into the graph's 0-based space once, at the point they are
  grouped, with the existing `toZeroBasedLine`. Every comparison downstream is
  then base-neutral, and `toDisplayLine` goes back to being what its doc says it
  is: an MCP response-boundary converter, not a filter input.
* Deduplicate matched nodes by id. `ENDS WITH` is a plain string suffix, so a
  diff touching both `README.md` and `pkg/README.md` counted the same node
  twice (169 duplicates in 13,870 rows on a real 400-file diff). Pre-existing,
  free to fix now that the rows are shaped in one place.
* Drop the positional `?? sym[N]` row fallbacks in this block.
  `executeParameterized` returns `getAll()` rows, which are alias-keyed objects,
  so the fallbacks were dead — and they coupled the mapping to RETURN column
  order, which is what made adding a column a renumbering exercise.
* Build the path→hunks map in one pass, so "every value is coalesced" holds at
  every point rather than being repaired by a second loop. Simplify
  `coalesceHunks` (the length<2 branch and the sort tiebreaker changed nothing)
  and state `hunksOverlapRange` as a standard half-open lower bound.
* Document `partial` in the detect_changes tool description. The CLI now prints
  it, but the MCP client — the main consumer of the pre-commit gate — was
  getting the flag as an undocumented raw key.
* Tests: pin the query text as identical for a 1-hunk and a 3,000-hunk diff
  (replacing a magic length bound), pin the 0-based bounds parameter, pin the
  dedup, and fold two near-identical row mocks into one helper. Temp dirs now
  come from the shared pool helper, whose cleanup is per-directory and
  Windows-lock aware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* feat(mcp): bound and batch the hunk→symbol query, and anchor its path match (#2915)

Follow-up review pass on the #2915 fix, implementing every remaining finding.

* Push a per-file `[lo, hi]` span into the query. Coalesced hunks are sorted and
  disjoint, so a file's touched span is free, and the engine drops the symbols
  outside it instead of shipping every row in the file across the native
  boundary. Measured on a 25k-node index, 400-file batch: 546ms/13,870 rows
  before, 84ms/1,555 after, identical kept set. Depth stays constant (two
  comparisons per file, not per hunk), so #2915 cannot return. The struct-list
  parameter was probed against @ladybugdb/core 0.18.3 and 0.19.1 first; the
  index-subscript form `$paths[i]` does not parse.
* Anchor the path match: `n.filePath = b.path OR n.filePath ENDS WITH b.suffix`
  where suffix is the path with a leading separator. A bare `ENDS WITH` is a
  plain string suffix, so a diff touching `lib/a.py` also reported a symbol from
  an indexed `src/mylib/a.py` — a file the diff never touched. This is the form
  `explain` already uses. Pinned by an integration test against a real engine
  (it fails 3/3 with the un-anchored predicate).
* Run batches a few at a time. `executeParameterized` checks a connection out of
  the 8-connection per-repo pool for the duration of a query, so parallel calls
  never share one — the same reason ~15 other queries in this file already run
  under `Promise.all`. `allSettled`, so one failed batch degrades the result to
  `partial` instead of discarding the batches that succeeded beside it.
* Deduplicate matched nodes by id, and count `changed_files` as distinct paths:
  a path can appear twice in one diff (a rename reported alongside an edit).
* Cap the listed symbols at 1,000 with `symbols_truncated: {listed, total}`.
  A repo-wide diff otherwise puts an unbounded array in one MCP payload — the
  CLI has `--limit`, an MCP client has nothing. Counts are never capped, so the
  risk level and the CLI's "... and N more" still see the true total.
* Extract `chunk` / `mapBatches` / `LBUG_QUERY_BATCH_SIZE` into
  `core/lbug/query-batch.ts`. Every query built from a caller-sized array has
  this ceiling; the shape now has one name and the measured batch size is
  recorded where it is defined rather than in three constants under three names.
* Move hunk grouping and the 0-based conversion into `coalesceHunksByPath`, at
  the parse boundary. `parseDiffHunks` stays faithful to git (1-based, like the
  `@@` headers it reads), consumers compare graph-native values, and the
  conversion is unit-testable instead of living in the backend.
* Document `partial` and `symbols_truncated` in the detect_changes tool
  description — the MCP client is the main consumer of the pre-commit gate and
  was getting both as undocumented raw keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor(core): batch every remaining repo-sized query list (#2915)

`detect_changes` was not the only place building query text from a
caller-sized array. `core/wiki/graph-queries.ts` interpolated the whole file
list of a module into four `IN [...]` literals, growing the query with the
repo — flat breadth rather than the nested depth that crashed #2915, but the
same unbounded shape, and the one the repo's own `DELETE_FILES_CHUNK_SIZE`
precedent already chunks elsewhere.

All four now run one query per batch and merge in JS. The membership arms need
care, and each is documented where it happens:

* `getIntraModuleCallEdges` batches the caller arm only. A per-batch callee arm
  would drop a call from batch 0 to batch 2, both inside the module, so that
  predicate moves to JS against the whole set. Results are now sorted: the
  single-query form had no ORDER BY, and batch order would hand the entire
  30-edge window `formatCallEdges` keeps to the first 100 files (#2787).
* `getInterModuleCallEdges` keeps the SAME batch list in its `NOT` arm. That is
  sound — a file outside the module is outside every batch — and it preserves
  the null handling: `NOT null IN [...]` is null, so the original dropped edges
  to a node with no filePath, where a JS-only `!has(undefined)` would admit
  them. ORDER BY and LIMIT move to JS because a per-batch limit would cut rows
  before the cross-batch membership filter ran.
* `getProcessesForFiles` keeps `LIMIT` inside the batch: `stepCount DESC, id` is
  a total order, so a process in the global top-N is in its own batch's top-N.

Also adopt the shared `chunk()` at the hand-rolled slice loops in
`lbug-adapter.ts`, `embeddings/http-client.ts` and `run-analyze.ts`. The loops
whose index fed a progress callback or an error message use
`chunk(...).entries()`, which removes the `i / SIZE` and `Math.floor(i / SIZE)`
arithmetic rather than reproducing it. No batch size changed.

One trap that survived tsc and is worth naming: after renaming a loop variable
away from `chunk`, a leftover `chunk.length` silently resolved to the imported
FUNCTION's arity, reporting `chunkSize: 1` for a 200-path batch. Only
`lbug-query-importers-batch`'s exact-value assertion caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor: name the line-base conversions and share the symbol line (#2915)

The 0-based-graph vs 1-based-elsewhere rule was open-coded in five places with
the reasoning living only in comments — the same rule that, applied by hand and
skipped once, hid every last-line edit from `detect_changes`.

* Add `toOneBasedLine` beside `toZeroBasedLine` in `ingestion/utils/line-base.ts`
  so the module owns both directions, and adopt it at the four CFG/PDG join
  sites in `pdg-impact.ts` and the two in `local-backend.ts`. This is NOT
  `line-display.ts`'s `toDisplayLine`, which is documented as a response
  boundary converter with an `undefined` passthrough; the joins need
  arithmetic, and the guards that produce `Number.NaN` for an absent line are
  kept verbatim.
* `http-route-extractor.ts` probed graph spans with a bare `line - 1` and a
  20-line comment. It calls `toZeroBasedLine` now; the `?? pick(line)` fallback
  arm is untouched, so which node is picked cannot change (the clamp differs
  only for a negative line, which no emitter can produce).
* Extract `formatSymbolLine`: `detect-changes-format.ts` and `eval-server.ts`
  rendered the same `type name → filePath` line. One behavior note — the two
  were not byte-identical, and eval-server had no placeholder on `name`, so a
  definition with an empty name rendered the literal `undefined` and now
  renders `?`. Both `definitions[]` shapes set name from a graph row, so this
  is unreachable in practice, and printing `undefined` into LLM-facing output
  is the bug, not the intent.

`||` (not `??`) in the placeholders is deliberate and documented: a node label
can come back as an empty string and still needs the placeholder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* perf(wiki): bind the module file list instead of splicing it into the query (#2915)

The wiki's four `IN [...]` sites interpolated every file of a module into the
query text, so the text grew with the repo — the shape that overflowed
LadybugDB's recursive evaluator copy in `detect_changes`. The previous commit
chunked them, which worked but cost real complexity: the callee arm had to leave
Cypher and be re-implemented in JS, DISTINCT had to be re-established across
batches, and ORDER BY/LIMIT had to move to JS so a per-batch window could not cut
rows the cross-batch filter still needed.

Binding the list as a parameter removes the reason for all of it. The text is
constant at any list length, and measured against a real index a bound list is
~3x faster than the equivalent literal (5,000 items: 139ms vs 459ms; 20,000:
598ms vs 1,686ms). Every predicate goes back into Cypher, including the `NOT ...
IN` arms whose null handling is load-bearing — `NOT null IN [...]` is null, so a
callee with no filePath is dropped by the engine, where a JS membership test
would have admitted it.

Verified on this repo's own index: a 2,000-path bound list returns 14,856 rows in
877ms.

Also collapses the per-process step query into one grouped `p.id IN $ids` fetch —
105ms to 13ms for 20 processes — and drops `fileListLiteral`, `callEdgeKey`,
`compareProcessHeaders` and the batching loops with it. `compareStrings` was a
byte-identical re-roll of `compareCodeUnits` (src/lib/utils.ts), including its
#2787 rationale; it now calls the shared one.

Intra-module edges are sorted where the original had no ORDER BY: `formatCallEdges`
keeps only the first 30, and an unordered cut keeps a different subset per machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor(core): one home for batching, and a backstop for the shape that crashed (#2915)

`chunk` moves to `src/lib/utils.ts`, the repo's generic-utility home: it is an
array helper, and leaving it in `core/lbug/query-batch.ts` made an HTTP
embedding client import batching from the graph-DB namespace. `query-batch.ts`
keeps what is actually about queries — the measured `LBUG_QUERY_BATCH_SIZE`, the
concurrency helper, and the ceiling — and now documents the preference the wiki
change proved: bind the list as a parameter first, chunk only when you cannot.

`mapBatches` becomes `mapConcurrent`: nothing about it is batch-specific, and it
now has non-query callers. Its body is a per-item try/catch plus `Promise.all`,
so ordering comes from the primitive rather than from unwrapping a settled
union. The wave barrier stays — measured against a rolling window it is 538ms vs
532ms on a 1,000-file diff, whose per-batch times spread only 1.35x.

Adopted at the loops that were still hand-rolled: `file-hash.ts`,
`cluster-enricher.ts` (its progress callback now accumulates `batch.length`
instead of clamping an index), `filesystem-walker.ts` and `language-config.ts`
(wave scheduling with `allSettled`, which is exactly `mapConcurrent`).
Deliberately not adopted, each for a stated reason: the analyzer-identity probe
runs as a standalone `node -e` script with no module resolution; the embedding
sub-batch loop slices two parallel arrays and breaks early;
`walkRepositoryPaths` reports progress from inside each wave, which
`mapConcurrent` cannot express.

`warnIfQueryTextUnbounded` is the backstop: #2915 died in native code with no
message, and a query built by concatenating a caller-sized list is the shape
that gets there. Wired at both execution chokepoints (`pool-adapter`'s
`executeParameterized`, `lbug-adapter`'s `executePrepared`/`streamQuery`; their
`executeQuery` siblings delegate and are covered once). It never throws — a long
query the engine can actually run must not start failing on a heuristic — and it
is deliberately absent from the raw write path, where a node's `content` is
inlined and a large source file would warn legitimately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor(mcp): name the path-match rule, and key detect_changes by node id (#2915)

* `path-predicate.ts` names the three ways a caller's path can match a stored
  `filePath` — `exact`, `pathSuffix`, `fragment` — instead of each call site
  copying whichever idiom its neighbour used. A bare `ENDS WITH` is a plain
  string suffix, which is how a diff touching `lib/a.ts` came to report a symbol
  from `src/mylib/a.ts`; the loose `CONTAINS` sites are loose ON PURPOSE (a user
  hint of `src/mcp` should match a directory fragment), and naming the modes is
  what lets a call site choose rather than inherit.
* `detectChanges` kept four structures over one row set — an array, a dedup Set,
  an id list and an id→name Map — that had to stay in sync by hand. One
  id-keyed Map is all of them; insertion order is preserved, so every output is
  byte-identical.
* `symbols_truncated: {listed, total}` becomes `truncated: true`, the key
  `explain`/`pdg_query`/`trace` already use. The true total was always in
  `summary.changed_count`, so the nested object said nothing the existing
  vocabulary could not.
* `GraphLineRange` is now a distinct type from `DiffHunk`: they carry the same
  two fields in different bases, and mixing them IS #2377. The name means a
  1-based hunk cannot reach `hunksOverlapRange` without a conversion between.
* `coalesceHunksByPath` accumulates raw ranges and coalesces once per path
  rather than re-sorting on every occurrence.
* `chunk` adopted at this file's own five loops — the point of extracting it —
  including two locals named `chunk` that shadowed the import. That shadowing is
  not cosmetic: it is how a leftover `chunk.length` silently became the
  function's arity earlier in this branch.

One bug caught by the real-engine integration test and worth naming: Cypher
comments are `//`, not `--`. A `--` comment inside the query string made
LadybugDB reject the whole query at PREPARE, which `detect_changes` swallows
into `partial` and renders as "No changes detected." Every mocked unit test
passed. Prose stays out of query strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* refactor(test): share the git-repo bootstrap, and move the shared formatter out (#2915)

`formatSymbolLine` lived in `detect-changes-format.ts` but is rendered by
`eval-server`'s query formatter too, so a `query` formatter imported from a
`detect_changes` module. It moves to `src/cli/format-symbol.ts`; both callers
import it from there. The `||`-not-`??` fallbacks stay documented — a node label
can come back as an empty string and still needs its placeholder.

`test/helpers/temp-git-repo.ts` gives `initGitRepo(dir, identity?)` and
`commitAll(dir, message)` to the ~10 test files that hand-rolled the same
`git init -q` + two `git config` + `add -A` + `commit` sequence. It takes a
directory and never owns one, matching `temp-dir-pool.ts`'s split of lifecycle
from seeding; the identity is a parameter because the existing consumers
genuinely disagree about it, and each keeps exactly what it configured. Four
files stay hand-rolled for stated reasons — pinned author dates for a
deterministic digest, remote handling, `--allow-empty`, and the `-c key=value`
form that never persists to the repo.

Test trims: the `formatSymbolLine` fallback cases collapse into one `it.each`
table (the case pinning that BOTH consumers emit the helper's exact line stays —
no table row can express it); two `line-base` cases that were compositions of
their neighbours go; and `detect-changes-path-anchoring` runs its
`detect_changes` call once in `beforeAll` instead of three times, keeping the
three named failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019d85r7TrMYjWbTUT3pXccS

* perf(mcp): filter the batched hunk query before the engine materialises (#2915)

`UNWIND $bounds AS b MATCH (n) WHERE …b…` compiles to a CROSS_PRODUCT whose
build side is a RESULT_COLLECTOR over the whole filtered node table: only the
`n`-only predicates get pushed below the accumulate, so neither the anchored
path match nor the [lo, hi] span could reduce the scan. Measured at 1M nodes:
+242 MB for one batch and +922 MB for the four concurrent ones, paid even for a
one-file diff — and at a 268 MB buffer pool the query died with `Buffer manager
exception` where the old per-file query completed, landing in `partial:true` +
`changed_count:0`, the #2915 false clean by another route.

Adding the batch-wide, `b`-free disjunction as a redundant leading conjunct
lets the planner push it below the accumulate: EXPLAIN now shows it as FILTER[2]
directly under SCAN_NODE_TABLE[0]. It is a provable superset of the correlated
predicate, so it cannot drop a row the correlated filter keeps. 10x less memory,
~20% faster, identical result sets.

Also in detect_changes:
- Sort rows on (filePath, startLine, id) before the 1000-symbol cut. The cut was
  slicing engine row order — measured 5 distinct orders across 8 runs on one
  connection, the #2787 class this branch fixes 200 lines away in the wiki.
- Chunk `symIds`, the one caller-sized list left unbatched: 500k ids measured
  4.0 GB RSS. Binding keeps the query TEXT constant, which is all the unbounded
  guard measures, while the bound VALUE stayed repo-sized.
- Prefer exact path equality and widen to the anchored suffix only for paths
  that matched nothing, so a root README.md stops reporting pkg/*/README.md.
- Report `risk_level:'unknown'` rather than 'low' when a query was swallowed.
  A degraded pre-commit gate must not read as an all-clear.
- Pass --no-ext-diff --src-prefix=a/ --dst-prefix=b/. `diff.noprefix` in a user's
  gitconfig makes git emit `+++ f.py`, which parseDiffHunks cannot match, so every
  run printed "No changes detected." and exited 0 before any query ran. A diff
  that parses to zero files now raises `partial` instead of the clean branch.
- `labels(n)`, not `labels(n)[0]`: labels() returns a scalar string here, so the
  subscript was always '' and `type` never carried a label.
- Validate IMPACT_MAX_CHUNKS. The chunk() adoption turned an entry condition into
  an exit condition, so a non-numeric value ran every chunk instead of none.
- Record why four-way concurrency is safe here, and scope the arm64 sequential
  comment to the query it was written for (#496).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* fix(cli): fail the detect_changes gate instead of exiting 0 when it degrades (#2915)

The secondary half of #2915 was that a swallowed query failure printed
"No changes detected." and exited 0, so a shell pre-commit gate passed on a
broken analysis. This branch added the PARTIAL text. It did not change the exit
status, so `gitnexus detect-changes && git commit` still proceeded.

`detectChangesCommand` passed a STRING to `output()`, and `output()` sets a
failing code only for an OBJECT carrying `error` — under a comment calling
itself "the one place that keeps scripted callers honest". A string never
matches, so this command opted itself out of the only mechanism the file
provides. It was broader than `partial`: the formatter also renders a backend
`{error}` payload as text, so hard failures exited 0 too.

Fixed narrowly in `detectChangesCommand`, following the object-first shape
`checkCommand` already uses, rather than widening `output()`'s shared contract —
every one of its other seven callers already passes an object and is unaffected.
One code for both `error` and `partial`: `&&` only distinguishes zero from
non-zero, and a softer code for `partial` would invite `|| [ $? -eq 2 ]`
exemptions that reopen exactly this hole.

`truncated` deliberately stays exit 0 — only the listing is capped, while the
counts and risk are computed over the full set, so the verdict is sound and
failing on it would fire on every large-but-healthy diff.

Also wires `truncated` through the formatter, which this branch had left as a
producer-only flag while `partial` went end to end, with the note in both
locales and no count of its own so the existing "... and N more" line stays the
sole numeric report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* fix(wiki): restore step order and symbol labels, and cut the edge list in Cypher (#2915)

Found by running the queries against a real engine, which nothing did before:
this branch's regrouped `withSteps` returned step traces OUT OF ORDER.
`ORDER BY pid, r.step` combined with `WHERE p.id IN $ids` silently drops the
second sort key — `proc_1_incrementalupdate` came back 2,7,1,3,4,5,6.
`ORDER BY step` alone is correct, and so was the pre-branch per-process query,
so this was introduced by the batching. `formatProcesses` prints
"${s.step}. ${s.name}", so every module and overview page was getting scrambled
execution traces. The mocked suite passed 112/112 before and after.

`labels(x)[0]` is always the empty string: labels() returns a scalar string and
the subscript is 1-based over its characters ([1] is "F"). `prompts.ts` renders
"${s.name} (${s.type})", so all 5,027 exported symbols reached the LLM as
"name ()".

`getIntraModuleCallEdges` shipped every edge to use 30 — measured 18,299 rows
and 851 ms with all 2,079 paths bound, against 30 rows and 94 ms with
ORDER BY + LIMIT in Cypher, which the sibling `getInterModuleCallEdges` twenty
lines below already did. The determinism fix (#2787) was right; the placement
was not. `compareCallEdges` goes with it — it was intransitive when a name was
null or empty, so `Array.sort` was input-permutation dependent, i.e. the
nondeterminism it was added to remove.

Deletes the positional row ABI this branch newly documented. The vendor
declaration is `getAll(): Promise<Record<string, LbugValue>[]>` — string keys
only — and `row[0]` probes back `undefined`; the same PR deleted ~30 identical
fallbacks from local-backend.ts. They were already stale here: `withSteps`
prepends `p.id AS pid`, so `toProcessStep` was reading the pre-branch layout.
Rows are now typed by alias, so renaming an `AS` is a compile error. `??` for
`||` so a step of 0 or an empty label keeps its own value.

Tests: a real-engine integration suite covering all seven exported queries
(PREPARE included — the trap that shipped a `--` comment on this branch), and
the four holes that let the ordering bug through — a vacuous order assertion, a
LIMIT never reached by a 2-edge fixture, a fake that returned rows pre-ordered
and ignored ORDER BY, and a hardcoded `type: 'Function'` that hid labels().

The step-ordering fixture is empirically sized: 2 processes never reproduced the
bug, ~400 step edges was intermittent, 710 (20 processes x 26-45 steps) hit
11 of 11 runs. Seeded descending and interleaved so no grouping looks sorted by
accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* refactor: put the shared helpers where their callers are, and make their contracts true (#2915)

`mapConcurrent` moves to lib/utils.ts beside chunk(). Nothing about it is
query-specific and it already had filesystem callers, while its docstring
justified concurrency safety through the per-repo connection pool — an argument
that does not apply to fs.readFile. This is the precondition the branch's own
commit message stated ("it now has non-query callers") and then did not apply.
LBUG_QUERY_BATCH_SIZE and warnIfQueryTextUnbounded genuinely are query-specific
and stay.

`pathMatch`/`PathMatchMode` deleted: zero callers, and none of the three sites
its docstring cited were migrated, so the tree carried the abstraction and the
copies it was written to replace. `pathSuffixOf` stays and the module now
documents the anchoring rule it actually implements.

Contracts that were not true:
- QUERY_TEXT_CEILING_BYTES was compared against `cypher.length` — UTF-16 code
  units, not bytes — so non-ASCII query text was undercounted and the reported
  KB was wrong. Buffer.byteLength now, behind a `length * 3 <= ceiling` early
  return so only text over ~21 KB pays for the count.
- chunk(items, NaN) returned [[]], against a docstring promising never to return
  an empty slice, and mapConcurrent's Math.max(1, NaN) propagated it — which
  would have resolved [] for non-empty input with no error, read as "no results"
  by every call site.
- GraphLineRange claimed a 1-based hunk could not reach hunksOverlapRange
  without a conversion, but it was structurally identical to DiffHunk so tsc
  accepted one with no diagnostic, and coalesceHunks<T extends GraphLineRange>
  actively laundered the base while its accumulator was still DiffHunk[]. The
  useless generic is gone and a one-line phantom on each interface makes the
  claim real; a bare {startLine, endLine} literal still satisfies both, so no
  construction site needs a cast.

Pure deletions no longer vanish. A -U0 deletion emits `+N,0`, which
parseDiffHunks dropped, so the file survived with no hunks, no query ran, and
detect_changes reported `changed_files:1, changed_count:0, risk_level:'low'` —
"No changes detected." for a commit that deleted a function. A unified diff
spells an empty range as the line before it, so the anchor is line N alone:
a symbol containing the deleted text also contains N, while extending to N+1
would claim a symbol that merely starts after the gap — the widening
coalesceHunks guarantees it never does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* docs: say that a partial or truncated detect_changes is not a clean gate (#2915)

The gate itself now fails loudly, but the instructions every agent reads still
described a zero as a result. Fixed at the source: AGENTS.md's gitnexus block is
generated from a template in cli/ai-context.ts and injected into every user's
repo, so the sentence goes there and AGENTS.md/CLAUDE.md are regenerated through
the real code path (which also picks up a pre-existing `analyze --index-only`
drift the committed docs were behind).

That block is under a test-enforced size cap with 30 characters of headroom, so
the 144-character clause was paid for in the same currency: the header
exhortation, which the Always Do list restates as MUSTs with commands, and a
verbatim repeat of the detect-changes command in the regression-compare example.
3549 of 3552. Worth noting for whoever adds the next line — #2899 replaced an
absolute cap with a 0.65 ratio to let "a legitimate clause fit without
ceremony", but set the ratio flush against the block's then-current size, so it
is a ratchet with no ratchet.

The canonical block does not make the skills redundant: three of the four
install channels ship skills without touching AGENTS.md, --skip-agents-md does
the same in-repo, and a user-trimmed gitnexus:keep block legitimately has no
Always Do section — in those repos the skill file is the only carrier. Precedent
agrees: the risk:UNKNOWN rule is deliberately carried in both places. So one
sentence each in gitnexus-work (the commit gate), gitnexus-impact-analysis
(beside the UNKNOWN paragraph) and gitnexus-refactoring, whose post-hoc "verify
only expected files changed" is the worst of the three because a degraded result
makes it vacuously pass. gitnexus-taint-analysis is left alone: its audience is
always inside this repo, where the canonical block loads.

All copies mirrored to npm, plugin and cursor. The cursor copies are condensed
checklists rather than byte-mirrors, so they carry the equivalent note placed
where it governs every detect_changes line in the file — and nothing tests that,
since standard skills are fragment-checked rather than byte-compared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* refactor: break the seven small import cycles gitnexus check reports (#2915)

`check` reported 11 cycles. Five are paths inside a single 257-file strongly
connected component in core/ingestion (call-extractors / cfg visitors /
utils/ast-helpers), with a second 26-file component behind it — fixing those
paths would only make check print different ones, so both are left for their own
PR. This closes the seven that are genuinely separable, taking the graph from 9
strongly connected components to 2.

Six of the seven were one value import plus one `import type` edge. tsconfig
sets neither verbatimModuleSyntax nor isolatedModules, so those edges erase
entirely — the cut is a graph and readability change with no emitted-JS
difference. Each moved type went to a leaf module, with a re-export left behind
only where an importer outside the change actually needed it:

- cli/ai-context <-> cli/skill-gen: GeneratedSkillInfo -> cli/generated-skill.ts.
  One importer, no package export surface, so a clean move with no re-export.
- cli/analyze-config <-> cli/analyze (+core/run-analyze): AnalyzeOptions ->
  cli/analyze-options.ts. Re-export kept because a test imports it from
  analyze.js. run-analyze needed no edit — cutting the one type edge collapses
  the 3-file component into a DAG. Its own same-named AnalyzeOptions is a
  different interface and was deliberately not merged.
- ingestion/import-resolvers/types <-> ingestion/language-config: type-only in
  BOTH directions, so it had no runtime existence at all. ImportConfigs has no
  importers outside the pair and is the return type of loadImportConfigs, so it
  moved into language-config. Side effect worth having: the shared resolver
  types module no longer names a single language, which is an AGENTS.md rule for
  core/ingestion shared pipeline code.
- ingestion/di-extractors barrel <-> spring: DiResolver and the two match types
  -> di-extractors/types.ts, following the import-resolvers/types.ts precedent.
- scope-resolution/walkers <-> workspace-index: WorkspaceResolutionIndex ->
  workspace-index-types.ts. Re-export is load-bearing — 9 src importers, 4 test
  files, and a dynamic import() at contract/scope-resolver.ts. Moving the value
  isClassLike instead was rejected: ~15 value importers, and it is documented as
  a pair with isShapeLike.
- server/analyze-worker <-> analyze-worker-core: the WorkerMessage protocol ->
  analyze-worker-protocol.ts, a declarations-only leaf.

storage/branch-index <-> storage/repo-manager was the one genuine two-way
runtime cycle: branch-index called getStoragePaths/loadMeta, repo-manager used
branchSlug/BRANCHES_DIR. branch-index's header conceded the cycle and argued it
was ESM-safe because neither side calls across at module-evaluation time — a
guarantee resting on call ordering rather than structure. Folding
resolveBranchPlacement back the other way does not help, because
BranchSummary.stats is typed RepoMeta['stats'], so RepoMeta had to move either
way. Extracted storage/repo-meta.ts, a leaf importing only fs and path, holding
the metadata read primitives; repo-manager re-exports the public names so all
54 RepoMeta and 50 loadMeta importers are untouched. The moved block diffs
byte-identical against HEAD.

Verified beyond typecheck, because the worker entrypoint is the risky part and
nothing in the suite forks it: emitted analyze-worker.js still contains exactly
one runtime import, and forking the real worker over IPC boots it through
entry -> core -> protocol -> terminal-claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* refactor: apply the reuse, simplification, efficiency and altitude cleanups (#2915)

The one that mattered: the degradation exit code was fixed at the wrong depth.
`output()` has never inspected `partial` — it tests `error` only — so putting
the check in `detectChangesCommand` left every other tool exiting 0 on a
degraded run. `partial` is cross-tool vocabulary: query (enrichmentDegraded ||
ftsPartial), impact (!traversalComplete, perSymbolEnrichmentCapped) and the
mode:'pdg' envelope all emit it. A truncated impact traversal returns a short
caller set and an under-ranked risk, then exits 0 — so `gitnexus impact … &&
<edit>` proceeds, in the tool AGENTS.md makes a MUST gate before every edit.
The justification also cited checkCommand as precedent, but checkCommand passes
STRINGS too — it was the second command already hand-rolling around this gap,
while output()'s docstring called itself "the one place that keeps scripted
callers honest". output() now takes an optional renderer and fails on error OR
partial; two hand-rolled sites go away and three tools are covered instead of
one. truncated stays exit 0 (only the listing is capped) and checkCommand's
cycleCount policy stays put.

Efficiency, all re-measured on the 25k-node index:
- The process lookup was chunked with LBUG_QUERY_BATCH_SIZE, calibrated for the
  opposite query shape — that constant is for a whole-node-table scan where more
  items amortise the scan, while this is an `id IN $ids` probe where round trips
  dominate. 20k ids: 617ms at 100, 261ms at 1000. New LBUG_ID_PROBE_BATCH_SIZE,
  documented against its sibling so they cannot be re-merged. This also settles
  the older "chunking this query is a regression" measurement — that was chunk=100.
- The sort comparator re-coerced fields ChangedSymbolRow already types, O(n log n)
  redundant conversions (+31-38%). Row shape probed directly: alias-keyed, no
  positional keys, numeric columns are JS numbers.
- exactlyMatchedPaths built two throwaway arrays; one loop instead (40k rows
  11.4ms -> 4.5ms).
- The integration fixture seeded 710 step edges one round trip at a time; one
  UNWIND instead. File wall time 6.91s -> 3.63s. Fixture size unchanged — its
  docstring records the threshold below which the bug stops reproducing, and the
  mutation check still fails 3/3 when ORDER BY step is reverted.

Reuse and simplification:
- CALL_EDGE_LIMIT existed in four places; its own docstring predicted the drift
  it then caused. prompts.ts owns it now — it is a zero-import leaf so the
  direction cannot cycle, and had graph-queries.ts owned it the four suites that
  vi.mock that module would have left slice(0, undefined), silently returning
  every edge in exactly the tests meant to police the cap.
- Six dead positional row fallbacks survived the rewrite in the loop this branch
  re-indented, in the same PR that deleted the identical ABI from graph-queries.ts.
- Two test files independently modelled the same labels() scalar-string quirk.
  Deleted the wiki one — the file's own new header says semantics belong in the
  real-engine test — and kept projectTypeColumn, the only instrument that can see
  the bug for the detect_changes query.
- makeRepo onto the shared git bootstrap (the eleventh copy of the sequence the
  helper was extracted to own), the duplicate diff-args unwrapper merged into
  test/helpers, hand-rolled comparators onto compareCodeUnits, real-timer sleeps
  replaced by wave-released promises with a strengthened per-wave assertion.
- Re-exports trimmed to what is actually imported, a cross-reference this branch
  invalidated by moving mapConcurrent, and a "~20% faster" claim that does not
  survive at real index sizes (1-9%; the 10x memory win does).

Also adds the drift guard the new doc text lacked: fragment coverage for the
partial/truncated paragraph in every skill copy and in the managed AGENTS.md /
CLAUDE.md block. Falsifiability checked — none of those fragments exist at the
merge base.

Not done here, deliberately: 27 live labels(x)[0] projections remain across
impact/context/query/trace and MCP resources, with four load-bearing workarounds
that have begun depending on each other and one that fabricates rather than
degrades. That is a semantic change to five agent-facing tools and wants its own
PR, scoped to delete the workarounds too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* fix(cli): restore the detect-changes subcommand in the regression example (#2915)

Caught by the gitnexus-check bot on the PR. The regression-review fallback in
the injected mandate rendered as `--scope compare --base-ref "main" --repo .`
with no command, so anyone copying it invokes the runner with an option as its
first argument.

Self-inflicted, and by exactly the mechanism flagged when it landed: the block
is under a test-enforced size cap (#856) that had 30 characters of headroom, so
adding the partial/truncated clause required paying for it, and the 38-character
"repeat" that was dropped turned out to be the subcommand rather than a repeat.

Paid for the restoration out of the clause instead — both parentheticals are
gone, since `partial` and `truncated` are already defined in the tool
description this text points at. Block is back under the cap at 3548/3552.

Notably the cap has now been raised four times (2700 -> 2900 -> 2950, then
0.55 -> 0.65) each with the argument that the new line is load-bearing, and it
has now also caused a user-facing defect. It is not functioning as a budget.
Left at 0.65 here rather than making it five: moving the threshold to fit one's
own text is how it got here. Worth restructuring separately.

The fragment guard added a commit ago caught the rewording immediately, which
is what it is for; its fragments now pin the two policy claims rather than the
prose around them, since that prose is what gets re-trimmed under the cap.

Also verified and NOT changed: the bot's other error, that detect_changes
compares 1-based hunks against 0-based graph lines. `bounds` is built from
`coalesceHunksByPath`, which applies `toZeroBasedLine` to both ends at the
grouping boundary, and both a mocked and a real-engine test pin an edit landing
on a symbol's last line. The bot read `parseDiffHunks` in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* fix(core): reject a fractional chunk size, and stop the truncation note overclaiming (#2915)

All five from the gitnexus-check bot's pass on the previous push; two were
introduced by the cleanup round that preceded it.

`chunk` guarded with `Number.isFinite`, which admits a fractional size — and
that one does not fail, it DUPLICATES. `slice` truncates its indices while `i`
does not, so size 1.5 yields slice(0, 1.5) = items 0-1 then slice(1.5, 3) =
items 1-2, putting item 1 in two batches; a caller batching a query would send
it twice. A size is a count, so `Number.isInteger`. Unreachable today (every
caller passes a constant) but the guard existed precisely for the unreachable
case, and the NaN half of it was already there.

`mapConcurrent`'s per-item degradation contract had a hole: `onError` is
caller-supplied and was invoked outside a try, so a throwing reporter rejected
`settle`, rejected the whole `Promise.all` wave, and discarded the neighbouring
successes the function exists to preserve. Reporting a failure must not become
one.

The CLI truncation note asserted "the counts and risk level still cover all of
them", which is true only when `truncated` fires alone — with `partial` the
counts are summed from the batches that succeeded. It now varies: a distinct
string when both flags are set, saying the counts are a lower bound. This is the
same claim already corrected in the tool description; the CLI text still had the
old one.

The di-extractors contract docstring claimed the barrel re-exports everything
from it. That stopped being true when the re-export was trimmed to what is
actually imported, one commit earlier.

The real-engine wiki test claimed to prepare "every exported query" and omitted
`getInterModuleEdgesForOverview`, which `generateOverview` calls. Added — it
aggregates in JS over `getInterFileCallEdges` rather than issuing its own
Cypher, so the note says why it is in a prepare test.

Verified and NOT changed: the bot's other error, that detect_changes compares
1-based hunks against 0-based graph lines. `bounds` is built from
`coalesceHunksByPath`, which converts both ends at the grouping boundary
(storage/git.ts), and two tests pin an edit landing on a symbol's last line.
The remaining seven findings are changed-symbol heads-ups with no signature
change; their callers' suites are green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

* fix(mcp): make the IMPACT_MAX_CHUNKS fallback actually fire (#2915)

The validation added earlier this branch used `Number.parseInt`, which takes the
numeric PREFIX: '1.5' parses to 1, satisfies `Number.isInteger`, and silently
caps enrichment after a single 100-item batch — the opposite of the fallback the
comment beside it promised. `Number` instead, so a fractional value is rejected
and falls back to 10.

The emptiness check is load-bearing rather than defensive: `Number('')` is 0 and
0 is a legitimate value here (enrich nothing), so an UNSET variable would
otherwise mean "enrich nothing" rather than "use the default".

Behaviour table, old vs new: '1.5' 1 -> 10 (the bug), and undefined/''/'  '/
'10junk'/'-2'/'all' -> 10, '0' -> 0, '3' -> 3, ' 5 ' -> 5 all unchanged. So the
only case that moves is the reported one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:51:17 +01:00
DuduPhudu
223ac7010d
feat: close reported graph blind spots in reference resolution, analyze and storage (#2856)
* fix(mcp): report UNKNOWN risk when an upstream impact walk finds no callers

`risk: LOW` asserts "safe to change" — a claim ABOUT callers. An upstream
walk that resolved none has nothing to base it on: the symbol may be
genuinely unused, or reached only through a reference class the index does
not record (a property access on a plain object, a bare-identifier read of a
module-scope const). Seeding LOW from an empty result is the false-safe
signal `anyKnownRisk` already refuses to emit on the ambiguous-candidate
path, and that #2687 removed by making an undetermined impactedCount `null`
rather than `0`.

Zero-caller upstream results now report risk UNKNOWN with a riskNote saying
absence of edges is not evidence of disuse. Downstream is untouched: an
empty downstream walk reports resolved callees, not safety.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(javascript): emit ACCESSES for bare-identifier reads of module-scope consts

A constant read only as a bare identifier — `Math.max(LIMIT, n)`, a default
parameter value, `return LIMIT` — minted no reference site at all, because
JS captured only `@reference.read.member`, which requires a receiver a bare
identifier does not have. So "who uses this constant?", the question behind
every dead-code trim and constants refactor, answered with a confident zero
in both directions.

The rest of the machinery was already in place: `FIELD_KINDS` accepts
`Const`, the scope query already declares it via `@declaration.const`, and
`read` maps to ACCESSES for any resolved target. This adds the missing
capture in VALUE POSITIONS ONLY (call arguments, default-parameter values,
return statements) — a blanket `(identifier)` rule would mint a site for
every token in the file, which is unaffordable at repo scale and would keep
alive the block-local symbols `pruneLocalSymbols` exists to drop.

Cross-file readers are NOT yet covered: the site exists and a call through
the same import statement resolves, but a value-kind def does not link
across the import edge. Recorded as a todo with the investigation.

PARSE_CACHE_VERSION bumped 44 -> 45: this is parse-time capture emission, so
a warm cache replays the pre-change capture set and the new edges never
appear — observed directly, a full `analyze --force` produced a
byte-identical graph until the cache was cleared by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(javascript): pin A1/A5 plain-object property acceptance criteria

Fixture plus todo specs for the four shapes plain-object property access has
to answer: object-literal keys indexed as Property nodes, a read through the
holding variable, a property WRITE, and a read through an untyped param.

Records the investigation so the work is resumable: the parse-query pattern
scoped to literals bound to a variable matches correctly (verified against
the raw JAVASCRIPT_QUERIES), but no Property node reaches the graph and
local-symbol-pruner is not the cause — it drops only Const/Variable/Static.
The remaining gate is in the parse worker's node-creation path.

No production code — specs only, so the suite stays green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(javascript): index object-literal keys of a named object as Property nodes

Idiomatic JS models configuration as an object literal, not a class, but
Property definition nodes existed only for DECLARED CLASS FIELDS. A config
field therefore had no symbol at all: `context({name: 'exitMinAtrMult'})`
answered "not found" for a field read and written throughout a live code
path, and ACCESSES had no target to point at.

Both halves are added for keys of a literal BOUND TO A VARIABLE — the parse
query mints the graph node, the scope query mints the def the resolver can
aim at. Unbound literals are deliberately excluded: an inline call argument
or a JSX prop bag is call-site data, not a named surface other code
references, so a node per key there would add volume without adding an
answerable question.

This lands the definition-node half only. The ACCESSES edges still require
receiver resolution — typing the const that holds the literal to the
literal's scope for the precise case, and name-based matching at reduced
confidence for the untyped-param (option bag) case. Both are recorded as
todos with the mechanism each needs.

Also records a trap that cost a wrong conclusion: under vitest the parse
worker runs the BUILT dist code (parse-impl resolves parse-worker.js, absent
under src/, and falls back to dist), so parse-query changes are invisible to
tests until `npm run build`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(cache): move the SCHEMA_BUMP pin to 45

The pin is the guard that makes two branches claiming one cache-schema
number fail loudly instead of silently serving each other's entries, so a
bump is only half-done until the pin moves with it. The bump itself landed
with the JavaScript bare-identifier captures; this is the other half.

Caught by the guard working exactly as designed — the suite failed with
"expected 45 to be 44" rather than letting a mismatched pair through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): resolve plain-object property access by unique name

Idiomatic JS reads configuration off an object whose receiver cannot be
typed — an options bag passed as a parameter, a destructured handle, an
imported literal. No precise pass resolves those, so a field read and
written across a live code path produced no ACCESSES edge at all and "who
reads this setting?" answered a confident zero.

A last-resort pass runs after every precise pass and sees only what they
left behind. For each still-unresolved read/write site it asks whether
exactly ONE Property in the workspace carries that name. If so the read
almost certainly means it. If two or more do, nothing is emitted and the
site is COUNTED as ambiguous — a guess between them would be a coin flip,
and a wrong edge in the pre-edit safety gate is worse than a missing one.

Uniqueness is the right gate because it recovers exactly the names worth
recovering: distinctive domain fields (exitMinAtrMult, bookNotionalUsdt)
are unique in a repo and resolve, while generic keys (id, name, data) are
not and are skipped — which is where name matching would over-connect.

Bounded four ways:
- Confidence 0.5, the global tier, with the inference named in the reason,
  so a consumer can filter inferences without losing scope-resolved edges.
- Never second-guesses a precise result: sites already resolved are
  excluded, because first-write-wins stops a duplicate but NOT a second
  edge to a different target.
- Honors `fieldFallbackOnMethodLookup`. A statically-typed language opts
  out of name matching precisely because it over-connects; inferring an
  ACCESSES edge by name is the same claim and must obey the same opt-out.
- Requires an explicit receiver — a bare identifier is not a property
  access, and matching one by name would link a local to an unrelated key.

Indexes graph nodes rather than scope defs because an object-literal key
mints a Property NODE but no scope-resolution DEF: `localDefs` and
`scope.bindings` are both empty for exactly the population this serves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(analyze): record a collapsed graph write instead of reporting fresh

The dangerous half of a broken refresh: metadata IS written, so the index
reads as fresh, hooks re-arm, and every tool answers from a graph missing
most of its edges — indistinguishable from a codebase that genuinely has no
such relationships. Reported in the field as edges collapsing 23009 -> 2170
and as a CodeRelation table that never materialized.

`analyze` now compares the relationship count the pipeline PRODUCED against
what the DB hands back after the write. Both numbers are already in scope at
the same point, so the shortfall is provable rather than inferred — no
comparison against the previous index, which cannot distinguish a failed
write from a repo that legitimately shrank. A missing relation table needs
no special case: it reads back as a persisted count of zero.

On a collapse the run records `graphWriteCollapsed` in metadata, which
`getIndexIncompleteReasons` turns into `graph-write-collapsed` so status and
the MCP resources report the index INCOMPLETE rather than fresh.

A ratio, not equality: some relationship types do not round-trip one-for-one
and `--pdg` writes MORE rows into the same table, so demanding equality
would fire on healthy runs. Only a collapse is a defect. Fail-safe when the
expected count is unavailable — an implementation that offloads
relationships out of memory may not be able to report a total, and a false
"your index is broken" is worse than a missed one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ingestion): qualify object-literal Property ids by their owning object

Two config objects in one file that share a key name generated the same
`Property:<file>:<key>` id and COLLAPSED INTO ONE node, so two distinct
settings became a single symbol. Worse, the merged name then looked
workspace-unique to name inference, which happily resolved reads of it to a
node representing both — a wrong edge in the pre-edit safety gate, which is
precisely what the unique-name pass is bounded to avoid.

`objectLiteralOwnerInfo` already existed for exactly this ("so two
constructors in one file that both define `bar` stay distinct nodes") but
was gated to `Method`. `Property` now opts in.

`findObjectLiteralBindingInfo` returns `ownerName` only when asked. Its
`Method` ids must stay byte-identical — qualifying them would rewrite every
object-literal method id in every indexed repo — while object-literal KEYS,
indexed only since A1/A5, have no such history to preserve.

Found by a test written for the ambiguity path rather than by review: the
suite reported one node where two were expected, and an edge where none
should exist. Both are now pinned, along with the detection boundaries of
the B2 collapse check, which was previously an untestable inline expression
and is now a pure function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(typescript): index type aliases and shape members as symbols

A TS frontend models its API contracts as `type X = { … }` and `interface`,
so a field on one is exactly what "who breaks if I remove this?" is asked
about. Three gaps made that unanswerable, all in the TypeScript queries:

1. No `type_alias_declaration` -> `@definition.type`, so an alias minted NO
   NODE AT ALL and a context() lookup on an exported contract type answered
   "Symbol not found". TypeScript was the ONLY language missing this — Rust
   (type_item), Kotlin (type_alias), Swift (typealias_declaration) and Dart
   all emit it. The alias was declared for scope resolution but never became
   a graph symbol.
2. No `property_signature` in the parse query, so INTERFACE members minted no
   Property nodes either — the upstream report's "class/interface index fine"
   holds only for the type, not its fields.
3. No `property_signature` in the scope query, so even with nodes present the
   resolver had no member declaration to aim at. Its sibling
   `method_signature` -> `@declaration.method` already existed; only
   properties were missing.

Interface bodies and object-type aliases both spell members as
property_signature, so one pattern per query covers both shapes.

Lands the SYMBOLS, not yet the ACCESSES edges: the shape is already a
class-like scope and now has member declarations, but no edge forms — the
remaining link is owner/type-binding, recorded as todos with the diagnosis.
Note TypeScript sets fieldFallbackOnMethodLookup:false, so unlike JavaScript
there is deliberately no name-based fallback here; the precise path is the
only route by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(golden): accept interface members in the mini-repo snapshot

Drift is entirely the new TypeScript shape-member indexing: the fixture's
three interfaces (ValidationResult 2, DbRecord 3, LogEntry 3) contribute
exactly 8 Property nodes, each with exactly one HAS_PROPERTY owner edge.

Verified before regenerating rather than after: every pre-existing count is
untouched (CALLS 9, IMPORTS 12, DEFINES 16, HAS_METHOD 1, MEMBER_OF 12,
STEP_IN_PROCESS 12), so nothing was rewired — the digest moved only because
8 edges were added. The fixture's inline `return { valid: false, … }`
literals correctly produced nothing, confirming the object-literal rule
stays scoped to variable-bound literals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(analyze): never report a collapse from a non-numeric count

The B2 check reported healthy runs as total graph-write collapses. A
non-numeric `expected` (a graph implementation reporting no total, a
lightweight pipeline result) does not skip the guards — it INVERTS them:
`undefined < 100` is false, so the small-repo exemption never fires, and
`0 >= undefined * 0.5` is `0 >= NaN`, also false, so the ratio check
"passes" as well. Both bounds silently evaporate and every such run is
flagged.

That is precisely the failure this check was written to catch, reproduced
inside the check itself: an unmeasurable quantity treated as a measured
zero. Both sides are now validated as finite numbers before any comparison.

`persisted` is also passed as UNKNOWN rather than zero when the DB was not
demonstrably readable: `getLbugStats` flattens "no connection", "query
threw" and "empty table" all into `edges: 0`, so `stats.nodes > 0` is used
as independent evidence the read happened at all.

Caught by the existing run-analyze suites, not by the new unit tests — those
exercised the pure function with well-formed numbers and were blind to the
integration's actual inputs. Both cases are now pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(typescript): make object-type aliases own their members

A TS object-type alias declares the same `property_signature` members as the
interface beside it and answers the same question, but was not a member
owner: its fields were minted with bare ids and no owner edge, so two
aliases in one file sharing a field name collapsed onto one node, while the
identical interface resolved normally.

`type_alias_declaration` joins CLASS_CONTAINER_TYPES (and
CONTAINER_TYPE_TO_LABEL, as that set's invariant requires — a container
missing there gets orphaned member edges or a wrong owner label). Aliases
with no object type (`type Id = string`) declare no members, so they own
nothing and are unaffected.

This also lands the INTERFACE field -> consumer edges, verified on the
mini-repo fixture rather than only on a purpose-built one: `saveToDb` now
links to `ValidationResult.value`, and `formatLogEntry` to `LogEntry.level`
and `LogEntry.message` — three real contract-field reads that previously had
no graph path at all. Golden updated: +3 ACCESSES, no node changes.

The ALIAS field -> consumer edge is still not linked and is recorded as a
todo with the exact blocker: resolving a receiver typed as the alias needs
the NAME to resolve to a class-like def, and `isClassLike` is
Class|Interface|Struct|Record|Enum|Trait. That predicate is read from ~12
sites including MRO and heritage, and every language mints TypeAlias, so
widening it would enrol aliases in linearizations where they do not belong.
Widening only the scope index was tried and reverted — the type-name walkers
gate on it independently, so it fixed nothing and left dead code. That needs
a deliberate "shape-like" concept, not more call-site widening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test): record the traced diagnosis for the unlinked alias field edge

Traced to the end rather than left as "needs investigation", so the next
attempt starts from facts:

  1. Graph side is COMPLETE and symmetric with the interface —
     Property:...:LiveModeConfig.bookSlots is owner-qualified and carries
     HAS_PROPERTY.
  2. Resolution DOES reach resolveClassBindingForName('LiveModeConfig')
     (instrumented) and misses.
  3. It misses because the module scope binds LiveModeIface:Interface,
     renderAlias, renderIface — and not LiveModeConfig. The alias has no
     binding on the receiver's scope chain at all.
  4. The TS scope query tags aliases @declaration.type, but normalizeNodeLabel
     accepts only typealias / type_alias and has no "type" case, so it returns
     undefined. Kotlin and Dart use @declaration.type_alias; TypeScript is
     alone on the dead tag.
  5. Retagging is NECESSARY BUT NOT SUFFICIENT — tried, and the binding still
     does not appear, so a second gate exists in how a declaration anchored on
     a node that is ALSO a @scope.class anchor is attached: the alias appears
     to bind inside its own scope rather than hoisting to Module, where
     interface_declaration evidently does hoist.

An isShapeLike predicate (the nominal-vs-structural split: shapes declare
members, nominal types participate in MRO) plus a mirrored
findShapeBindingInScope were built and REVERTED along with the retag. With no
binding on the chain they never fire, and shipping inert widening is worse
than shipping none — the same standard applied to the earlier scope-index
attempt. The design is recorded here; it is worth doing once step 5 is fixed,
and it also unblocks Rust's parked union_item, which the MEMBER_OWNER_NODE_TYPES
comment documents as the same gap in another language.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): resolve cross-file value references, skip block-locals

Two halves of the same question, "who uses this constant?".

CROSS-FILE. `resolveReferenceSites` runs against the registries and, as its
own comment says, "imports live in finalized bindings the registries can't
see" — which is why free CALLS need `emitFreeCallFallback`. Reads had no
counterpart, so `import { LIMIT }` followed by a bare use resolved to nothing
while a CALL through the very same import statement resolved fine. This adds
the read/write counterpart, reusing `findValueBindingInScope` (which walks the
FINALIZED chain) rather than inventing a lookup. Confidence 0.9: the import
names the def, so this is precise resolution, not inference.

BLOCK-LOCALS. Bare-identifier capture also matches a read of a block-local
`const`, and an edge to one keeps alive exactly the inert locals
`pruneLocalSymbols` exists to drop — a pruned node becomes a retained node
plus an edge, in every function of every indexed repo. Emission now takes the
set of value defs bound at MODULE scope and drops ACCESSES to
Const/Variable/Static outside it. The cross-file pass carries the same
guarantee structurally: a def in another file cannot be a block-local of this
one, so it skips same-file hits entirely.

The block-local leak was already shipped in the intra-file A2 commit and was
found only because a test was written for the guard rather than the feature —
the same way the object-literal id collision surfaced.

Verified on the full resolver matrix: 3172 tests, golden unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(lbug): diagnose a vanished staging CSV instead of surfacing a Binder error

A forced rebuild could fail with "COPY failed for File: Binder exception: No
file found that matches the pattern .gitnexus/csv/file.csv" and then an ENOENT
on .gitnexus/csv/rel_Folder_File.csv — two engine-level messages that name
neither a cause nor a remedy, which is where several field reports end.

Only tables with rows > 0 enter the COPY manifest (csv-generator.ts), so an
absent file was WRITTEN during this run and removed since. Both COPY loops now
preflight and say exactly that, with the row count, both causes the reports
point at (a second `gitnexus analyze` on the same repo — they share
.gitnexus/csv — or an external cleanup of .gitnexus/), and the action to take.

Scope note, deliberately narrow: this does not attempt to fix WAL corruption
or checkpoint rotation. Those already have detection and recovery hints
(isWalCorruptionError, WAL_RECOVERY_SUGGESTION, the configurable
wal-checkpoint-threshold), and the ~6000 lines added to lbug/ + storage/ since
v1.6.9 — index-lock.ts most of all, which serializes writers and plausibly
closes the concurrent-run class outright — postdate every report in the
window. Guessing at unreproducible durability faults would be speculation;
making the one failure with NO handling legible is not.

An existing overlap test induced this exact scenario (a manifest entry
pointing at a missing csv) and asserted on the engine's wording. Its intent —
that a node-COPY failure is rethrown at the FK barrier rather than swallowed —
is unchanged and still asserted; only the message it matches moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): split shape-like from class-like, linking alias fields

Completes A4: a field on a TypeScript object-type alias now links to the code
that reads it, the last unanswerable half of "who breaks if I remove this?"
for a TS frontend that models contracts as `type X = { … }`.

`isClassLike` answered two questions that only coincide for classes:
  1. does this declare MEMBERS I can look up?   — a SHAPE (structural)
  2. does this participate in inheritance / MRO? — a NOMINAL TYPE
An object-type alias is (1) and emphatically not (2) — it has no supertypes
and no place in a linearization. Widening `isClassLike` to buy (1) would have
enrolled every language's aliases (Rust type_item, Kotlin/Swift/Dart
typealias, C typedef) into MRO and heritage, so the two questions now get two
predicates. Call sites split by which they ask, and their names already said
which: `resolveInheritanceBaseInScope` and `resolveQualifiedInheritanceBase`
keep `isClassLike`; receiver typing and member OWNERSHIP take `isShapeLike`.

Three parts, each necessary and none sufficient alone:
- `findShapeBindingInScope`, mirroring `findValueBindingInScope`'s established
  relationship to `findClassBindingInScope` (same walker, different accepted
  def-type), consulted only AFTER the class lookup misses so a class of the
  same name always wins.
- `populateClassOwnedMembers` uses it, so alias members get an `ownerId` and
  are registered under the alias. Without this the receiver resolved to the
  alias and then found no members under it.
- The TS scope query tags aliases `@declaration.type_alias`, not
  `@declaration.type`: `normalizeNodeLabel` accepts typealias / type_alias and
  has no "type" case, so the old tag mapped to NO label and TypeScript aliases
  produced no scope-resolution def at all. Kotlin and Dart already spelled it
  this way; TypeScript alone was on the dead tag.

An earlier attempt concluded a further "scope-attachment gate" existed. That
was wrong and is worth recording: scope extraction runs in the parse WORKER,
which loads built `dist`, so the retag was never executed. Rebuilt, the alias
hoists to Module scope exactly as the interface does. Same trap as the parse
query — `src` edits to anything the worker runs are invisible until
`npm run build`.

Typedef and Union stay out of `isShapeLike` deliberately: they belong
conceptually (the union_item note on MEMBER_OWNER_NODE_TYPES records the same
gap) but neither is wired as a member container, so including them would widen
a predicate nothing exercises.

Verified on the full resolver matrix: 3173 tests, golden unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(typescript): pin the type-alias capture to a tag that maps to a label

The capture test asserted `@declaration.type`, the tag that
`normalizeNodeLabel` does not recognize (it accepts typealias / type_alias and
has no "type" case). So the test passed for as long as the tag was broken: it
checked only that the capture FIRED, never that it resolved to anything, while
TypeScript aliases produced no scope-resolution def at all.

Updated to the working tag and given a second assertion that the derived kind
string is one the label mapper accepts — the property that actually matters,
and the one whose absence let a dead tag sit pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(lbug): declare TypeAlias member pairs so analyze does not abort

Making object-type aliases member owners emits HAS_PROPERTY from a `TypeAlias`,
and the relation schema declared no such pair. The emit therefore threw
`UndeclaredRelationPairError` and the ENTIRE analyze died on any repo
containing `type X = { ... }` — a hard stop, not a dropped edge. Found by
running the analyzer over a real 16k-node TypeScript repo, not by a test.

`Method` is declared alongside `Property`: a member written
`type Handler = { onClick(): void }` is a method_signature and would fail in
exactly the same way.

Why every existing test missed it: the resolver suites build an in-memory
graph via `runPipelineFromRepo` and never write to LadybugDB, so the schema
constraint was never exercised. `structural-pair-coverage.test.ts` is the one
suite that does run the emitters against the declared pairs — and its own
docstring names the gap: coverage is bounded by NON_BRIDGE_CORPUS, "a new
structural emitter should land with an entry here". This adds that entry,
pinning TypeAlias|Property and Interface|Property as sentinels.

Verified the guard is not vacuous: removing the pair again makes the suite
fail with undeclaredPairs: ["TypeAlias|Property"].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(processes): trace depth-first so multi-hop flows are detected

D1 ("query ranks frontend components above the backend module that owns the
concept") and D2 ("processes is dominated by trivial mechanical chains") are
the same defect, and neither is about ranking or selection.

The walk stops after a fixed NUMBER of traces, so traversal order decides which
traces those are. Breadth-first reaches every shallow terminal before any deep
one, so the quota filled with the shortest paths in the graph and the walk
stopped — `maxTraceDepth: 10` was never approached. Measured on a real repo
before the fix: of 300 processes NONE exceeded 7 steps and 90% were 3-4. A
multi-hop business flow (signal → order → exit) therefore had no process that
could represent it, and `query` could only rank the mechanical pairs that did
exist. Step 4 of the caller already sorts by length and dedupes by endpoint —
it was always asking for the deepest traces this walk could give it.

Depth-first descends to a terminal first, so the same quota is spent on paths
worth keeping. Cost is unchanged: same budget, same cycle guard, same depth
ceiling — only the order differs.

Measured on the same 16k-node repo, same build and flags, BFS vs DFS (an
earlier comparison was discarded as confounded — it crossed builds and --pdg):

  steps   6-8:  50 → 168   (3.4x)
  totals:      844 → 806

and the reported query moved from `LiveSetupView → Cn` (a React component) to
`ReconcilePositions → IsTpInProfit / WithHeld / ShouldNotify` — server-side
exit management, which is what was asked for.

`traceFromEntryPoint` is exported for the test. Traversal order is unobservable
through `processProcesses`: `findEntryPoints` supplies several starting points,
so a deep chain is traced from inside it whatever the order does. A test at
that level passes under BOTH traversals — the first version of this test did
exactly that and guarded nothing. Driving the walk directly, it fails under
breadth-first with "expected 3 to be greater than 3".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test): correct a stale status note left behind by a later fix

The A1/A5 header still said "edge resolution REMAINING ... neither is
implemented". Both shapes resolve — the typeable receiver precisely, the
untyped one by workspace-unique name — and the tests below assert exactly that,
so the note contradicted the file it sat on.

It was accurate when written and went stale when the work continued past it.
Left as-is it would tell a reviewer that a landed feature is missing.

The TRAP note is kept: the parse worker still runs built dist under vitest, and
that is still the trap it describes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): index literals behind identity-preserving wrappers

`export const INERT_EXIT_CONTRACT = Object.freeze({ ... })` minted no
`Property` node for any of its keys. The object-literal rule matches
`variable_declarator > value: (object)` as a DIRECT child, and freezing puts a
call expression in between — so the shape whose fields are most worth querying
was the one shape the rule could not see. Freezing a config object is how JS
publishes an immutable contract, which is why this reads as a confident zero
on exactly the fields a reader cares about.

The allowlist is three functions, not "any call". `Object.freeze`, `seal` and
`preventExtensions` RETURN THE ARGUMENT THEY WERE GIVEN, which is what makes
the literal's keys members of the bound name. For `const x = compute({ a: 1 })`
the literal is an argument and `x` holds compute's return value, so attributing
`a` to `x` would be a fabrication.

Two negative controls, because the obvious one is vacuous: a bare-identifier
callee is rejected structurally and would pass with no allowlist at all, so the
assertion that actually pins the predicate uses `Object.entries` — identical
shape, differing only by name. Verified load-bearing by adding `entries` to the
allowlist and watching that test alone fail.

SCHEMA_BUMP 46 -> 47: parse-time emission, so a warm cache replays the pre-fix
capture set. Observed as a false negative first — `analyze --force` returned
the old node set until the on-disk cache was removed by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): narrow multi-candidate property names by scope

Workspace uniqueness was the wrong denominator. Measured on the reporting
repo: `exitMinAtrMult` has 26 `Property` definitions — 16 in one-off
`scripts/`, 7 in the frontend, one in a test, and exactly ONE in the backend
that reads it. Every backend read was refused because of competitors the
reader cannot see. The gate was not too permissive or too strict, it was
scope-blind.

A name with several definitions is now narrowed before being abandoned:
same-file first, then files the reading file directly imports, using the
finalized import graph rather than a path-shape heuristic. Exactly one
survivor at the first non-empty tier resolves; anything else stays refused.
A tier holding several candidates stops the walk instead of falling through —
local evidence that is itself ambiguous still contradicts reaching further out.

Confidence stays 0.5 at every tier. Narrowing changes which candidate is
chosen, not the kind of claim: it is still a name match, and the round-1
contract is that filtering on confidence drops all name inference at once.
The reason string now names the tier that fired.

Ambiguity reporting goes from a count to the actual names (capped), because a
count says a gap exists while the names say which fields are unanswerable.

Measured on that repo, backend readers of `exitMinAtrMult` go 0 -> 24 and
total readers 9 -> 45, including the two call sites in
`oppositeSignalExitManager.js` the report singled out. Both narrowing tests
were mutation-checked by dropping the import evidence and confirming they, and
only they, fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): capture destructured parameter keys as property reads

`function exit({ exitMinAtrMult = 0 })` reads that property off whatever the
caller passes, exactly as `cfg.exitMinAtrMult` would. It never appears in a
member_expression, so it had no reference site at all — and this is the shape
the function that IMPLEMENTS a behaviour uses, so the most relevant reader was
the one systematically missing from "who reads this setting?".

Uses a distinct `@reference.read.destructured` anchor rather than
`@reference.read.member`. The latter is filtered emit-side to matches with a
member_expression ancestor, because calls and writes share its shape, and a
destructuring pattern has none — reusing the tag would have been silently
dropped by that filter. The `read.` head already maps to a read kind, so no
mapping change is needed.

Scoped to formal_parameters. A destructuring binding elsewhere
(`const { x } = require('m')`) is frequently an import rather than a field
read, and minting a property read there would attribute module bindings to
unrelated same-named keys.

All three cases (default value, bare shorthand, renamed key) mutation-checked
by removing the patterns and confirming those three tests, and only those,
fail. The renamed case also asserts the edge points at the KEY and that the
local alias mints nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): link type consumers to the type they name

An exported contract type owned its members after round 1 and still answered
`incoming: {}`, so "what breaks if I remove this field?" — the question a
contract type exists to answer — had no edge to walk. Measured on the
reporting repo: all 324 TypeAlias nodes AND every Interface node had DEFINES
as their only incoming edge.

Two independent causes, and the second is why the first was not enough.

TypeScript captured no type references at all — only cpp and csharp did — so
an annotation naming a declared type minted no reference site. Added for
annotations, generic arguments and `as` assertions, anchored to those contexts
rather than a bare `(type_identifier)`, which would also match the name in
`type X = …` and make every declaration a consumer of itself.

That alone fixed interfaces and left aliases still empty. `TypeAlias` was
missing from `LINKABLE_LABELS`, so alias graph nodes were never indexed in
`nodeLookup` and `resolveDefGraphId` could not bridge a def to its node — the
edge was dropped AFTER a successful lookup. `CLASS_KINDS` has always listed
TypeAlias and the ClassRegistry returned the def correctly, which is what made
this read as a resolution failure; instrumenting the lookup showed it
returning the right def all along and moved the search one table over. Exactly
the bug already documented two entries above it for Trait.

Fixes every language that spells an alias this way — TypeScript, Kotlin, Dart
and Rust all emit `@declaration.type_alias`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): capture record construction as property writes

The read side answered well after the narrowing work while "who SETS this
field?" still missed the code that stamps the value. A record built inline —
`return { exitContract: { exitMinAtrMult: settings.x } }` — is bound to no
variable, so it minted no definition and its keys referenced nothing.

Modelled as WRITE REFERENCES, deliberately not definitions. The round-1 rule
already mints Property nodes for literals bound to a variable; minting more for
anonymous records would add same-named competitors to the very name-narrowing
that makes these fields resolvable — measured at 26 competing definitions for
one field on the reporting repo, which is what made every backend read
unanswerable in the first place. A construction site is a USE of a field, not
another declaration of it.

Two positions only: nested under a key, and returned. Both are records with a
name attached (the key, or the function). An inline call argument
(`doThing({ id: 1 })`) stays excluded for the same reason round 1 excluded it
from definitions — it is call-site data, not a named surface — and is asserted
as such.

The enclosing literal is the receiver and it is anonymous, so these route
through the same narrowing and the same refusal-to-guess as every other
untyped receiver.

Verified on the reporting repo: `entryPlan.js` went from no rows to
`selectExitEnvelope` as a writer of `exitMinAtrMult`. Both captures
mutation-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(processes): select round-robin by terminal so the list is not one flow repeated

Ranking was `sort by length` alone, so the top of the list was one behaviour
described many ways: eleven of the top fourteen processes on the reporting
repo were four entry points crossed with three terminals of the SAME
date-window utility cluster. Genuine call chains, but a reader learns one
thing from fourteen entries, and the repo's own domain flows sat below them.

Selection now round-robins across TERMINALS, deepest first. Depth still orders
within a terminal and still leads the list; what changes is that no terminal
takes a second slot until every other has had a first.

Keying on the entry point was tried first and made it worse — many files
declare a `main`, so each was a distinct entry that round-robin then awarded
its own slot, and `Main -> AlignWindowEnd` went from one row to eight. The
repetition was never in where a flow starts.

Measured on that repo: distinct terminals in the top 20 went 3 -> 20, and its
domain flows (`ReconcilePositions -> ...`) moved into the top 4%.

Two things this deliberately does not claim. The reported cause — ranking
rewarding fan-in, promoting chains ending in widely-called helpers — measured
FALSE: those terminals have one caller each (`alignWindowStart` 1,
`validateSymbol` 1). A fan-in discount was implemented against that hypothesis,
measured, and reverted for moving nothing. And a business flow still cannot be
a process in its own right: the walk only emits at a leaf, at max depth, or on
a cycle, so a flow whose meaningful endpoint calls onward survives only as
whatever leaf it bottoms out in. Both are recorded in the code so neither
reads as settled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(structural-pairs): pin the type-annotation USES pair

R2-2 emits USES INTO a `TypeAlias`, so the pair is `Function|TypeAlias` — a
different table from the `TypeAlias|Property` entry added in round 1, and one
that entry stays green without. `TypeAlias` is on the eleven-table list this
suite exists for, and an undeclared pair does not degrade: it throws
`UndeclaredRelationPairError` and kills the entire analyze on any repo
containing an annotated type. Every resolver suite still passes, because they
build an in-memory graph and never write to the DB.

That exact failure shipped once in this PR already. Two emitters into the same
label, each with its own way to reach a released build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): build the module-level set before the out-of-core seal

Review blocker. Under `GITNEXUS_DISK_SCOPE_INDEX=1` the seal replaces every
ParsedFile with a scope-STRIPPED copy, and the block-local filter's set was
built after it — so it walked `scopes: []` for every file, came out empty, and
the filter read that as "no def is module-level" and dropped EVERY
`Const`/`Variable`/`Static` ACCESSES edge in the repo. All languages, all
files, including the module-scope-const edges this PR exists to add. Nothing
threw and nothing logged, on the path the largest repos take: the exact
confident-empty answer the PR is about.

Built above the seal now, from `parsedFiles`, and passed as `undefined` rather
than an empty set when no scope was inspectable — an empty set is a legitimate
answer ("this repo has no module-level value defs") and must not be
indistinguishable from "could not look". Fails open; the block-local exclusion
is still asserted under the seal, since that is correctness rather than
optimization.

Also widens module level past `kind === 'Module'`. A `Namespace` scope (TS
`namespace`, Rust `mod`, C++/C# `namespace`) holds importable values too, and
treating its consts as function-locals dropped their reads. Included only when
the whole chain to the root is Module/Namespace, so a namespace declared inside
a function body stays local — asserted both ways.

That fixture then failed for a third reason: `@reference.read.identifier`
existed ONLY in the JavaScript query, so A2 did not work for TypeScript at all.
Added there, and both languages widened to `variable_declarator value:` and
`binary_expression` operands — the gaps review named between what A2 claimed
and what it matched.

Nothing covered `GITNEXUS_DISK_SCOPE_INDEX`. The new parity test asserts the
seal changes no edge, and was verified against an emulation of the original
bug: same-file readers vanish and only the cross-file reader survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(typescript): anchor property_signature to declared shapes

Review blocker, and it reproduces end to end. `property_signature` occurs in
EVERY object_type in the TS grammar, not only in an interface body or an
alias's object type, so inline parameter types, inline return types and nested
object types all matched — and the enclosing-container walk hung each one off
the nearest class, interface or alias. Measured against the unanchored rule,
all four appeared as members of shapes that do not have them:

  Property:contracts.ts:Svc.inlineParamOnlyKey
  Property:contracts.ts:Repo.inlineQueryOnlyKey
  Property:contracts.ts:NestedConfig.nestedOnlyKey
  Property:contracts.ts:buildInline.inlineReturnOnlyKey@46:33

When the inline member shares a name with a real one — `run(opts: { retries:
number })` inside a class that declares `retries` — `addNode` is
first-write-wins and the two distinct symbols merge onto one node, so every
context()/impact()/rename() answer about that field describes the merge. The
sibling JS object-literal rule in this same PR is anchored for exactly this
reason; this is the TypeScript half of the same fix.

`(A (B))` matches DIRECT children, so nested object types are excluded by the
same anchor rather than by a second rule.

The first version of these tests was VACUOUS and is recorded here because the
reason generalizes: a collision and a correct exclusion both leave exactly one
node behind, so counting ids cannot distinguish them. Every inline member in
the fixture is now uniquely named, which is the only thing that discriminates —
verified by restoring the unanchored rule and watching exactly those four
assertions fail. A fifth test asserts anchoring costs no real member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(analyze): correct the numbers feeding the graph-write-collapse guard

Review blocker. The predicate itself held under adversarial probing; every
defect was in what it was handed and what happened after it fired.

(a) `expected` was wrong twice. Under `GraphEmitSink` streaming the bulk types
leave the heap at parse time and never enter `relationshipCount`, so the count
understated the real volume by most of it and the ratio passed trivially —
on `force === true` runs, which include crash recovery AND the
`analyze --force` retry this check's own warning tells the operator to run.
Adds the manifest totals, the same correction the buffer-pool hint in this file
already makes for the same reason. Separately, an incremental run persists only
the changed subgraph while both counts are whole-scope: a 10,000-edge index
that lost 200 replacements reads 9,800 and is certified complete. The check is
skipped on that path rather than answered wrongly.

(b) A throwing edge count became a measured zero. `getLbugStats` initialised
its total to 0 and ran the query in a swallowing catch, so WAL/lock contention
during finalize — documented on this exact call — reported a healthy index as a
total collapse. It now returns `number | undefined`, and the caller requires
both a readable node count and a defined edge count.

(c) A total loss was exempted for being small. The min-edges rule tested
`expected` before looking at `persisted` at all, so `expected = 99,
persisted = 0` — every edge gone — stayed fresh and reported success. Total
loss is now decided first. The existing test asserted the defect; it now
asserts a PARTIAL shortfall, which is the case the exemption was written for.

(d) A detected collapse reported success and exited 0. It is different in kind
from the other incomplete reasons: those describe a run that did what it said
and left work for later, this one means most of your edges are gone and every
query answers a confident empty. The CLI now prints INCOMPLETE with the counts
and sets a non-zero exit code, and the flag crosses IPC so the worker cannot
send a clean `complete` either.

Nothing exercised this wiring — only the pure helper. Adds tests for all four,
each written so the pre-fix arithmetic fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): keep unique-name property inference inside one language

The pass indexed `Property` nodes from the whole shared graph. Per-language
gating decides whether it RUNS for a language; it never restricted which nodes
could be TARGETS. So the only carrier of a name could be in another language
entirely, and a read here resolved to it on name uniqueness alone — no owner,
no file, no call path.

Reproduced: a Java class declaring `private int loyaltyPointsBalance` and a JS
`cfg.loyaltyPointsBalance` on an untyped parameter produced an ACCESSES edge
from the JS function to the Java private field. Confidence does not mitigate
it, because `minConfidence` defaults to 0 — the tier is only a filter for
consumers who ask for one.

Candidates are now restricted to files in the language's own `parsedFiles`,
which is a precise restriction rather than a heuristic and needs no new node
property.

Every other fixture in the suite is single-language, so this could not be
caught anywhere by construction. The new fixture is deliberately polyglot and
asserts both halves: no cross-language edge, and a same-language unique name
still resolves.

Known and not addressed here: the index is still O(total graph nodes) and is
rebuilt once per qualifying language, the per-language whole-graph-scan pattern
`phase.ts` hoisted out for `sharedNodeLookup`. Hoisting it belongs with that
machinery rather than in this fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(processes): explore siblings in source order, log the exhausted budget

`slice(0, maxBranching)` selected the FIRST N callees while `pop()` explored
them LAST-first, so the trace budget went to the last-declared branch. For
`main() { init(); loadConfig(); run(); shutdown(); }` the walk spends itself on
`shutdown` and can drop `init` — the earliest steps of a flow, which is the
opposite of what a process describes. Selecting first-N and exploring last-first
was simply inconsistent; pushing in reverse makes the stack pop in source order.

Measured on the reporting repo, this costs depth: 6-8 step processes go 168 ->
146 of 816. Still roughly three times the pre-PR baseline of 50, and the right
trade — a deep branch is no longer reached by accident of being declared last.

The remaining limit is the BUDGET, not the traversal: with a fixed quota a deep
branch declared after enough shallow ones is not reached at all. That is now
asserted in both directions rather than left implicit, and the walk logs when it
stops with branches unexplored — a silently truncating cap reads as "this is
everything", the same confident-empty answer this work is about, and the repo
already sets that precedent for `dispatchFanoutSkipped`.

Removes the second depth test, which was vacuous: the note twelve lines above
it already said a `processProcesses`-level depth assertion passes under BOTH
traversals, and measured it does — breadth-first yields the same deepest
stepCount of 8, so it passed with the production change reverted. Traversal
order is asserted against `traceFromEntryPoint` directly; what is observable at
the pipeline level is which traces survive selection, which the diversity tests
cover.

Also renames `queue` to `stack` and corrects the BFS references in the module
docstring and the function's own JSDoc, which is what an IDE hover shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(impact): carry riskNote onto ambiguous candidates and separate UNKNOWN's two meanings

Two problems on the ambiguous fan-out, which builds its own candidate object
rather than returning the single-symbol shape.

The narrowed type had no `riskNote` field and never read one, so a candidate
that resolved and found no callers reported `risk: UNKNOWN` with nothing
attached — losing the entire point of the change on the path where the reader
has the least context, since the name is ambiguous there by definition.

And `UNKNOWN` used to mean exactly one thing on this path: the probe threw. The
zero-caller branch gives it a second meaning, so an all-UNKNOWN fan-out could
no longer be told apart from a broken one. Candidates now carry `probeFailed`,
and the comment asserting the old reading is corrected.

Also aligns `gitnexus-web`, which review flagged as giving a different verdict
for the same symbol. That surface answers in prose rather than an enum, and its
message said the symbol "appears to be unused (not called by anything)" — the
identical false certainty in words. It now carries the same MEANING rather than
the same field. Downstream wording is unchanged: no outgoing dependencies
really is a fact about the symbol itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: replace assertions that cannot fail

Four from review, each satisfied by the defect it was meant to catch.

`new Set(props).size === 2` over two different literal strings can only ever
be 2, so it could not detect the node merge its title promises — that is a
difference in COUNT, now asserted on the raw array.

The ambiguity test asserted only an empty edge set, which is satisfied equally
by "the gate fired" and "the name was never looked up". It now also requires
the ambiguity counter to have moved.

`Interface|Property` was listed as a structural-pair sentinel beside
`TypeAlias|Property`, but both its labels are in the SCOPE_BRIDGE cross-product
so the pair is generated by construction and the sentinel cannot fail. Dropped
rather than left reading as coverage; `TypeAlias|Property` is the load-bearing
one.

`TypeAlias|Method` was declared in the schema with no fixture emitting it — a
declared pair no emitter exercises is indistinguishable from a missing one
until an analyze aborts on a real repo. Adds a method-shaped alias member, and
the suite requires sentinels to actually appear, so it is not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: document the new incomplete reason, the UNKNOWN verdict and the id churn

Review found the code changes landed without the guidance around them, and an
agent following this repo's own rules would have been told the wrong thing.

`graph-write-collapsed` joined `INDEX_INCOMPLETE_REASONS` with no Sign block
and no recovery section, while the precedent it cites
(`embedding-checkpoint-pending`) has both — so `gitnexus status` would surface a
new string naming silent wrong answers with nothing explaining trigger or
remedy. Added to RUNBOOK and GUARDRAILS, including why this reason alone also
fails the exit code.

`AGENTS.md` said MUST warn on HIGH or CRITICAL and never mentioned UNKNOWN, and
the shipped impact skill's risk table had no UNKNOWN row and still implied
few-callers ⇒ LOW. An agent obeying those rules literally sees `risk: UNKNOWN`
and proceeds, which negates the change the verdict exists to make. Both copies
of both skills updated.

`MIGRATION.md` now records that process ids do not survive this release —
positional ids plus depth-first tracing, source-order siblings and round-robin
selection mean `proc_7_handle` is a different flow afterwards. Bounded honestly:
nothing in-repo joins on a raw process id, so it is index churn, not a broken
consumer.

`ARCHITECTURE.md`'s scope-resolution stage list gains the two new stages.
The guide skill's node list gains `Property` and `TypeAlias` — the two node
types this work most prominently creates.

Also, on the pair-CSV preflight review asked to confirm: the hard abort IS
deliberate, because a fallback recovering zero rows is the confident-empty
failure this work targets. But the transient the message itself names — a second
concurrent analyze sharing `.gitnexus/csv` — is a race, so the check now
re-looks three times over ~150ms before declaring the file gone. Long enough to
ride out a rename, far too short to mask a file that is genuinely missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop redundant TypeAlias pairs and keep bare identifiers off class members

Two regressions the full suite caught after the review fixes, both real.

`schema-pair-coverage` failed with eleven hand-declared pairs that a rule now
generates. Adding `TypeAlias` to `LINKABLE_LABELS` — needed so
`resolveDefGraphId` can bridge an alias def to its node — also makes it a
SCOPE_BRIDGE source and target, so the cross-product produces `File|TypeAlias`,
`TypeAlias|Property` and nine others that round 1 had declared by hand. Removed;
the invariant is that no pair is both generated and hand-declared.

This also changes what the structural-pair sentinel means, and the comment is
corrected rather than left overstating it: `TypeAlias|Property` is no longer
load-bearing because the label is off the generated grid — it is load-bearing
because it now depends on `TypeAlias` being IN `LINKABLE_LABELS`. Remove it and
the pair stops being generated while the hand declaration is gone, which is the
same state that silently breaks alias consumer edges.

`block-scope-shadowing` failed because a bare identifier resolved to a class
`Property`. `class Box { baseUrl = '...'; pick() { const baseUrl = ...; return
baseUrl; } }` linked the block-local read to `Box.baseUrl`, duplicating the
legitimate `this.baseUrl` edge. A bare identifier is not a member access: with
no receiver there is no object whose property it could be, and in JS/TS a field
read needs `this.`. Receiver-less read/write sites no longer accept `Property`
hits; callables stay reachable, so `cb = save` naming a top-level function is
unaffected.

That defect PREDATES this branch's TypeScript captures — JavaScript has emitted
bare-identifier reads since A2 and no class fixture exercised the shadow. The
TS parity added here is what surfaced it.

Golden snapshot regenerated after verifying the drift line by line: exactly
+5 USES from type annotations in the mini-repo, every pre-existing count
unchanged, so nothing was rewired.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): share the property-name index across language passes

Review follow-up. `indexPropertyNodesByName` scanned every node in the graph
and was rebuilt inside each qualifying language pass, reintroducing exactly the
pattern `phase.ts` hoisted out for `sharedNodeLookup` — whose comment records
why it matters: "the previous per-language rebuild burned that CPU+heap N times
and, on a huge repo, a tiny language's full-graph copy overlapped the next
language's — a real contributor to the scope-resolution memory peak."

Built once in `phase.ts` beside `sharedNodeLookup` and `sharedFnNodeIndex`, and
threaded through the same `prebuilt*` seam, so tests and isolated calls still
build their own.

Sharing is only safe because the per-language restriction MOVED rather than
disappeared: the shared index is whole-graph, and candidates are filtered to
the language's own files at lookup time. That also fixes a subtlety the
per-language build had backwards — the cap now applies to the FILTERED set, so
a name carried by forty properties across a polyglot monorepo but only two in
the language being resolved is still answerable, where a global cap would have
refused it.

The tri-state at the lookup boundary is deliberate and the three outcomes are
not interchangeable: no property of this name in this language (nothing to say,
and NOT an ambiguity), too many to choose between (reportable), or a list to
narrow.

Caught mid-change by the polyglot fixture: an intermediate state shared the
index without moving the filter, and the cross-language edge came straight
back. That test earning its keep twice is the reason it exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): report when a field's only anchor is another language

Round 3, found OUT-OF-SAMPLE — six field names appearing in no prior report, so
nothing here was tuned against them. All six answered 0 backend ACCESSES while
their definitions sat in `apps/research-dashboard/**`: TypeScript only. The
in-sample set scored 5/5 and the out-of-sample set 0/6, and the gap is entirely
this.

Per-language inference (`3c5eadc7`) is right and stays. What was wrong is that
declining is INVISIBLE: an empty result for a field anchored only in TypeScript
is byte-identical to an empty result for a field nobody reads. One says "look
in the other language or grep"; the other says "delete it". That is the same
confident-empty failure this series exists to remove, one surface over — and
this time the missing fact is about the ANALYZER's reach rather than the code.

Declines are now counted and named, with the languages the anchors actually
live in, kept SEPARATE from ambiguity because the remedies differ: ambiguity
wants better receiver typing, this wants an anchor in the reading language.
Collapsing them would tell a reader the wrong thing to do. A non-zero count
warns at analyze time regardless of dev mode.

The facts are published as `PipelineResult.propertyInference`, which they had
to be for any of this to be testable — and that exposed a second defect. The
round-2 ambiguity assertion, which I told the reviewer of #2856 I had
strengthened, read its stat off a `scopeResolution` field that does not exist
on PipelineResult: the `if (undefined) return` guard swallowed it and the test
passed with the production code deleted. Both that assertion and the new ones
now read the published field, and the guard is an assertion rather than an
escape. Verified by deleting the counter and watching them fail.

Reported by the same round-3 method note that caught it: verifying a fix
against the cases it was written for only proves those cases pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(context): explain an empty property result caused by a cross-language anchor

The other half of R3-1. The analyze pass now knows which fields it declined to
link because every definition of the name lives in another language; this puts
that fact where it is actually read.

`context()` on such a field previously returned an incoming list byte-identical
to a genuinely unread field. The two demand opposite actions — "look in the
other language, or grep" versus "delete it" — so the difference has to travel
with the answer:

  unresolved: property reads of this name were NOT linked: every definition of
              it is typescript, and name inference does not cross languages.
              An empty or short incoming list here is not evidence the field is
              unused — confirm with a text search, or give it an anchor in the
              reading language.
  anchorLanguages: ['typescript']

Carried through repo meta because the graph cannot answer it: the unlinked
reads mint no edge and no node, so the only record is the pass that declined
them.

Keyed on the NAME, not on the resolved label. Gating on `=== 'Property'` was
tried first and is wrong — the label reads `''` on this path for a plain
Property node, so the gate silently suppressed the entire feature while every
test still passed. Caught by asserting the field is DEFINED rather than
guarding on it, which is the same anti-pattern that made two earlier
assertions vacuous. The meta list only ever contains property names, so
matching the name is itself the type check.

Cached per (index, indexedAt): `ensureInitialized` deliberately avoids a
per-call `loadMeta` because every tool routes through it, so this re-reads
exactly when a re-analyze could have changed the answer and never otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): report declined property reads for opt-out languages too

Generalizing R3-1 rather than waiting for it to be re-reported in the other
direction. The reported case was a JavaScript read whose only anchor was
TypeScript; the mirror — a TypeScript read anchored only in JavaScript — was
still silent, because a language that sets `fieldFallbackOnMethodLookup: false`
had the whole pass skipped, and skipping emission also skipped REPORTING.

Detection is not inference. Counting what could not be linked asserts nothing
about what it means, so `reportOnly` runs the pass for its facts while emitting
no edge, and the opt-out keeps protecting exactly what it protected before.

Two things this turned up that a single-instance fix would have missed:

The cross-language fixture could NOT prove `reportOnly` is load-bearing — the
per-language candidate filter already blocks those edges, so the assertion
passed with the flag forced off. The case that discriminates is a SAME-language
TypeScript read that name inference could legitimately link and the opt-out
forbids; forcing the flag off there emits `readsTsOnly -> tsOnlyBudget`, which
is the violation.

Getting to that case surfaced a sibling gap, recorded but NOT fixed here: the
object-literal `Property` rule is JavaScript-only, so `const CONFIG = { ... }`
in a `.ts` file mints no node and its keys are invisible. The first draft of
this fixture used exactly that shape and could not discriminate for that reason.
It is the TypeScript half of R2-1a and wants its own change, not a rider on
this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(typescript): index object-literal keys, as JavaScript already did

The sibling recorded in `0c5a4f64` and deliberately left out of it. Both the
named object-literal rule (A1/A5) and the identity-wrapper rule (R2-1a) lived
only in JAVASCRIPT_QUERIES, so the single most common config idiom in
TypeScript —

    export const tsRuntimeConfig = { tsConfigRetries: 3 };

— minted no node for any key. `context()` answered "Symbol not found" and a
precise read through the holding variable had nothing to resolve to.

TypeScript sets `fieldFallbackOnMethodLookup: false`, so these gain no
name-based inference. What they gain is the PRECISE path, which is the route
TypeScript is meant to use: `tsRuntimeConfig.tsConfigRetries` has a typeable
receiver and now resolves. A read through an untyped receiver stays unresolved
and, since `0c5a4f64`, is reported as such rather than answering an empty set.

Scoped exactly as the JavaScript rules are — bound to a variable, and for the
wrapper only the three functions that return the argument they were given —
with the same `Object.entries` negative control pinning the allowlist.

Found by fixture, not by report: the first draft of the `reportOnly` test used
a TS `const CONFIG = { ... }` as its discriminator and could not discriminate,
because the shape mints nothing. That is the whole argument for sweeping a
class instead of waiting for each instance to be filed.

SCHEMA_BUMP 47 -> 48: parse-time, so a warm cache replays ParsedFiles carrying
none of these matches and the keys stay invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): anchor anonymous returned object literals to their function

The last gap round 3 named, and the dominant shape in idiomatic JS: 437
`return {` sites in a single backend directory of the reporting repo, including
the ~25-field payload of its entire signal pipeline. The literal binds to
nothing, so its keys could not even be named — "who reads wickRatio?" had no
symbol to ask about.

The enclosing FUNCTION is the owner: the literal is that function's return
shape, a contract its callers consume. Keys qualify as `<function>.<key>`, so
two functions returning the same name stay two shapes rather than one merged
symbol, and multiple returns in one function stay distinct by position.

RECONCILING THIS WITH R2-1b, which deliberately modelled returned keys as WRITES
to avoid adding same-named competitors to narrowing. These are definitions, but
narrowing now ranks DECLARED anchors — named literals, class fields, interface
and alias members — strictly above return shapes. A name that already resolved
keeps resolving to what it resolved to before, so the competitor problem R2-1b
was avoiding cannot come back. Mutation-checked: dropping that ranking breaks
five pre-existing R2 resolutions.

That also required an R2-1b assertion to change, and the change is a
strengthening rather than a concession. It asserted `toHaveLength(1)` — no new
definition — as a proxy for "adding definitions must not move an existing
answer". The proxy is now false while the property still holds, so the property
itself is asserted directly.

No `HAS_PROPERTY` edge from the function: that would be a `Function|Property`
relation pair the schema does not declare, and an undeclared pair does not
degrade — it throws and kills the whole analyze. That already shipped once in
this PR.

Two things found by dumping rather than assuming, both fixed here:

SHORTHAND keys were not matched at all. `return { symbol, interval, score }` is
the commonest spelling and the reporting repo's own payload is mostly this form,
but tree-sitter models it as `shorthand_property_identifier`, which `(pair)`
does not match. Caught by dumping the golden fixture and seeing a literal
returning `{ level, message, timestamp: Date.now() }` had indexed only
`timestamp`. Now covered in return position AND in the variable-bound rule,
which had the same gap.

Provenance was flagged by owner-presence, which mislabelled the anonymous case:
a callback's return shape yields no name to qualify by, so it looked like a
DECLARED anchor and would have outranked real declarations. Flagged by position
now — a different question from whether a name could be derived.

SCHEMA_BUMP 48 -> 49. Within one PR the version only has to differ from main's,
but a build stamped 48 was installed and used to analyze before these captures
existed, so caches stamped 48 carry none of them — the intermediate-build hazard
this ledger already records for 33/34.

Golden regenerated after verifying the drift: exactly +10 Property and +10
DEFINES, every pre-existing count unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): rank production anchors above test fixtures

Found by testing R3-4 on the reporting repo instead of on its fixtures. Anchoring
returned literals took `wickRatio` from 6 definitions to 13 — and backend reads
still resolved to nothing, because SEVEN of the new JavaScript anchors compete
and four of them are in `tests/`. A test constructs throwaway shapes carrying
production field names; a read in shipped code cannot mean one of them.

Applied before the declared/return-shape split, because "is this the shipped
program" is the stronger signal — a declaration inside a test fixture is still a
test fixture. Skipped when the READER is itself a test, since a read there
legitimately means the test's own shape.

The first version of this test was vacuous and the mutation check caught it: the
reader sat in the same file as the production anchor, so the same-file tier
resolved it whether or not this tier existed. The reader now lives in a file
that imports neither anchor, which leaves production-vs-test as the only thing
that can decide.

Honest about what this does NOT do: it narrows `wickRatio` from seven candidates
to three, and three functions in different files each returning that field is
GENUINELY ambiguous — refusing is correct, and the ambiguity is now counted and
named rather than silent. The reported question ("who reads wickRatio?") is
answerable only where one producer exists; where several do, the honest answer
is the list of producers, which R3-4 made nameable for the first time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): resolve members through a call result's return shape

The question three rounds of reports could not answer, and the one narrowing
must refuse by design: a field produced by SEVERAL functions. A read of
`spike.wickRatio` could mean any producer, so name inference correctly declines
and no amount of tier-tuning changes that. It needs evidence, not inference.

The evidence existed in two halves that had never been joined. The call-result
type binding (`const alert = formatSpikeAlert(row)` binds `alert` to a TypeRef
whose rawName is the callee) predates all of this work; it simply had nothing to
resolve to when the callee returned an anonymous literal, because an anonymous
literal named nothing. R3-4 gave it a name. Joining them:

    const alert = formatSpikeAlert(row);
    alert.wickRatio   ->   Property:...:formatSpikeAlert.wickRatio

Precise, at ordinary emission confidence, and it works EXACTLY where narrowing
cannot: several producers sharing a field name stop being competitors because
the receiver says which one. Runs before the name fallback and claims its sites,
so a precise answer is never second-guessed by a name match.

Measured on the reporting repo: 1,410 precise edges, and all six fields round 3
verified OUT-OF-SAMPLE go from 0 backend readers to 7, 11, 10, 7, 6 and 14.
Round 3 scored 0/6 on that set; this is 6/6.

The bound is asserted, not just documented: a read off a BARE PARAMETER has no
binding here, because typing it needs the caller's type to flow in — that is
inter-procedural and genuinely larger. Those reads still fall through to name
inference and are still reported when it declines. The fixture has two producers
sharing a field name precisely so the test cannot pass by name matching, and
mutation-checking the owner lookup fails it.

No SCHEMA_BUMP: this is scope resolution, not parse-time capture, so a warm
cache already carries everything it reads. Noted in the ledger because the
reflex on this branch has been to bump, and an unnecessary bump costs every user
a full re-parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert "return-shape anchoring" (R3-4/R3-5): it degrades query

Reverts af5eec5c, c764847a and 4f93f32e. The capability was real and measured —
all six fields round 3 verified OUT-OF-SAMPLE went from 0 backend readers to
7/11/10/7/6/14, 0/6 to 6/6, via 1,410 precise return-shape edges. It is reverted
anyway, because it costs more than it buys in its current form.

`cli-limit-e2e` caught it. Bisected to af5eec5c: on the mini-repo fixture,
`query('message')` returned two processes before and NONE after. The mechanism
is not window displacement — that hypothesis was tested with a partition that
kept function-local property keys from taking window slots, and it changed
nothing. Indexing the keys of every returned literal adds many nodes whose names
are ordinary words, which moves the BM25 CORPUS statistics: "message" gets less
discriminating, and `createLogEntry` — the callable that actually carries the
processes — stops ranking at all. A corpus-level effect is not repairable by a
tie-break.

Trading a regression in `query`, one of the core tools, for coverage in
`context` is the wrong trade, and shipping it because the number was good would
be the same mistake this PR spent three rounds removing: a confident answer that
is worse than the honest one.

What the work established, and what re-landing needs:

  - The mechanism is right. Joining the existing call-result type binding to a
    named return shape resolves `alert.wickRatio` by EVIDENCE, which is why it
    succeeds exactly where name inference must refuse.
  - The cost is search dilution, and it needs to be measured on BM25 ranking
    BEFORE the capture lands — not discovered by a downstream e2e test.
  - The likely shape of the fix is keeping return-shape keys out of the text
    search corpus while keeping them in the graph, which needs persisted
    provenance rather than the in-memory flag used here.

Kept: everything through 8972d223, which is verified green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(search): give the index a notion of DETAIL symbols, and re-land R3-4/R3-5

Reverts the revert. The return-shape work was correct and measured — 1,410
precise edges, and all six fields round 3 verified out-of-sample going 0/6 to
6/6 — and it was dropped for a regression that was really a MISSING LAYER: the
search index had no way to say "this symbol is queryable but is not a concept a
text search should surface on its own".

Indexing the keys of anonymous returned literals adds many nodes whose names are
ordinary words (`message`, `value`, `timestamp`). Without that notion they
compete on equal terms in FTS, push the CALLABLES named after the same concept
past the search's row cap, and `query('message')` returned two processes before
and none after.

The layer, rather than a workaround:

  - `Property.isDetail`, persisted. A Property-only column, which that table
    already precedents with `declaredType`, set where the key is minted.
  - `buildFtsQueryCypher` filters on it for the Property table, BEFORE the row
    cap. That placement is the whole point: rows crowded out never reach the
    caller, so no downstream re-ranking can recover them. Two downstream fixes
    were tried first — a tie-break and a partition of the merge window — and
    recovered nothing, which is what located the real seam.
  - `IS NULL`-tolerant, so an index written before the column existed still
    answers instead of returning nothing.

Verified by the A/B that found the regression: the query's result order is now
byte-identical to the pre-R3-4 baseline —
`proc_0_processrequest, proc_2_errormiddleware, Function:createLogEntry,
Property:LogEntry.message` — with the return-shape coverage retained.

The determinism guard then caught prose in the new DDL comment containing the
token this repo scans for, which would have read as an unordered query. Reworded;
that suite is doing exactly its job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(processes): let a flow end where the program reaches outward

The item three rounds kept circling. A trace was only emitted at a node with NO
outgoing calls, so a real flow — scan, score, arm, PLACE THE ORDER — is always a
PREFIX of some longer chain that runs on into date helpers, and could never be a
process in its own right. Ranking could not fix that; the flow was never a
candidate to rank.

What blocked it was signal granularity, and the fix is the layer that was
missing rather than a heuristic. GitNexus already knew where the program reaches
outward: the parse phase collects fetch calls and ORM queries carrying
`filePath` + `lineNumber`. Those facts only ever produced FILE-level edges
(`File -[FETCHES]-> Route`), which cannot end a trace — every function in a file
containing one would qualify. Attributing each site to the function whose range
CONTAINS it turns the same facts into the function-level signal the walk needs:
no new extraction, no new relation pair, no schema change. Innermost wins, so a
closure that performs the call is the sink rather than the function spanning it.

Three touch points, and the second is the one that makes or breaks it:

  - the walk emits at a sink AND CONTINUES, so `placeOrder` is an endpoint while
    `placeOrder -> formatDate` still exists separately;
  - subset-removal PRESERVES sink-terminated traces. A sink flow is by
    definition a prefix of the chain that runs past it, so emitting one at the
    walk and deleting it one step later would have been a no-op. Mutation-
    checked: removing this preservation fails all three sink tests, including
    the one asserting the sink is reached at all;
  - selection ranks sink-terminated above leaf-terminated, then by depth.

`processes` now declares `parse` as a dependency. It historically avoided that
on the grounds the dependency was spurious for a progress counter — it is no
longer spurious, so it is declared rather than reached for implicitly, and the
read fails open so a pipeline without that output detects no sinks instead of
losing every process.

Bounded honestly: this fires where fetch/ORM extraction fires. On the reporting
repo it will do nothing until route detection handles hand-rolled dispatchers,
since that codebase routes with `pathname === '/api/...'` on raw node:http and
produces zero Route nodes — a separate gap, and the next one worth closing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(processes): the comment above the sink ranking still described it as unreachable

R3-6 taught the walk what a sink is, but the block explaining the ranking still
carried the paragraph written when that was out of reach — "a business flow
still cannot be a process in its own right ... fixing that means teaching the
walk what a sink is" — sitting directly above the code that does exactly that.
A reader arriving at `rankedByInterest` would take the limitation as current.

The measured-false fan-in finding stays; it is still true and still worth not
re-deriving. What replaces the stale half is the bound that IS current: sinks
fire where fetch/ORM extraction fires, so a codebase whose outward calls are not
detected as such still sees leaf-terminated traces only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(routes): read a route that is declared by a comparison, not by a framework

`route_map` on the reporting repo returned

    {"routes": [], "total": 0, "message": "No routes found in this project."}

for a codebase with SEVENTEEN route modules, an `apiRouteTable.js`, and 113
path comparisons. Not a partial answer — a statement about the code, and a
false one. Same confident-empty class as the rest of this branch, except here
it takes out a whole tool.

Four route-discovery paths existed — filesystem convention, single-file
framework route, cross-file framework route, decorator — and every one of them
needs a FRAMEWORK to declare the route. A raw `node:http` server declares it
the only way the language offers:

    if (req.method === 'GET' && pathname === '/api/live/portfolio') { … }

A path, a verb, and a handler. Nothing in the pipeline could read it.

The failure modes are not symmetric, so the rules are weighted accordingly: a
route this misses is a coverage limit, a route it invents is `route_map`
asserting something false. A comparison therefore qualifies only against a
demonstrable request path (`pathname`, `*.pathname`, `req.url`; `path` is
excluded — in Node it is overwhelmingly `node:path` or a file location), and
anything untranslatable is dropped rather than approximated:

  - `pathname.startsWith('/api/')` is a namespace test; minting `/api` would
    claim a route nobody serves;
  - a bare `pathname === '/'` with no verb is more often the static-file
    normalisation branch (`pathname === '/' ? '/index.html' : pathname`) than a
    route — WITH a verb the intent is unambiguous, so that form IS taken;
  - an anchored regex converts only when its body is a literal path plus
    single-segment wildcards, so `/^\/api\/research-runs\/[^/]+$/` becomes
    `/api/research-runs/{param1}` while an optional group or an alternation
    bails.

Three things went in that nobody reported, each found by measuring rather than
by a second report.

`switch (pathname) { case '/api/x': }` is the same dispatch in different
syntax, and waiting for a bug report per shape is how a graph stays permanently
one idiom behind the code it indexes.

The reconciliation had to move up a level. The reporting repo keeps its path
table (`isKnownApiPath`) in one module and its handlers in sixteen others, so a
per-file rule sees each half separately and lists every route twice — once
verb-less with the table as its "handler", once properly. Measured: 22 of the
first 94 routes were that shadow. Only the whole registry can tell them apart,
so the rule lives in the routes phase and touches dispatch-guard routes only —
a framework route without a verb is method-agnostic BY DECLARATION (a Django
function view, a Laravel resource), a fact rather than a weaker observation.

And a path composed from a constant needed folding. One of those seventeen
modules writes every one of its routes as `` `${autoTradeBasePath}/rules` ``,
where the base is an alias of a module-level literal. Refusing that lost the
whole file — and lost it INVISIBLY, since a module with unfoldable paths and a
module with no routes are the same empty answer. Same-file only, literals only,
one alias hop, and it refuses on ambiguity: a name declared twice with
different values is dropped rather than guessed, because a partially-folded
path is a wrong route and a wrong route is the failure this module exists to
avoid.

Wiring is a LanguageProvider hook, not a language check in shared code.
`extractDecoratorRoutes` was already the general "route from this file's own
AST" channel rather than a decorator-only one — express routes have flowed
through it as `decorator-express.get` for a while — so the transport, the
`(method, url)` dedup and the handler-symbol resolution all apply unchanged.
`ExtractedDecoratorRoute.source` carries the one thing that genuinely differs:
a decorator route is DECLARED, a dispatch-guard route is INFERRED. The walk is
gated behind a substring pre-filter so it costs nothing on files that cannot
produce a route, and the gate is sound by construction — every rule reaches a
route only through `isPathExpression`, which needs one of exactly those tokens.

SCHEMA_BUMP 49 -> 51, two entries. Decorator routes are worker output carried
in the parse cache, so a warm cache replays results predating the extractor and
`route_map` stays empty — the symptom this fixes, wearing the mask of "the
extractor does not work". The second bump is the v34 hazard tripping again: a
build stamped 50 had already been used to analyze before folding existed, so
caches stamped 50 carry the unfolded route set. Caught by measuring — the
post-folding run came back suspiciously fast and would have reported the
pre-folding number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): ask whether a value def is FUNCTION-LOCAL, not whether it is module-level

The locality filter for value references was written as an ALLOWLIST of
module-scope defs, and that shape cannot express a class member. A value def has
three homes, not two: module level, a function body, and a CLASS body. Java and
C# fields and Python class attributes live in the third, so an allowlist keyed on
"module level" excludes every one of them by construction.

The guard written to make that safe could not fire either. The set arms whenever
a Module scope is FOUND, and Java has module scopes while having no module-level
values at all — so for Java it armed permanently empty, which is exactly the
state the guard exists to distinguish from "there genuinely are none".

Inverting it removes the class. A blocklist of defs positively identified as
function-local fails safe: a Java field, a Python class attribute, or a language
whose scopes could not be inspected is emitted rather than dropped. That also
retires the arming flag — an empty blocklist and an uninspected one mean the same
thing, and both mean "emit". The failure mode moves from "silently deletes an
edge class" to "retains an inert local", which is the right direction for a tool
whose stated principle is that a confident empty answer is the worst outcome.

MEASURED, because the review that prompted this reported it as a P0 deleting
every Java/C#/Python field ACCESSES edge, and that half does not reproduce.
Instrumenting the bridge over `java-write-access` shows ZERO value-ACCESSES
candidates reaching the filter: Java field references resolve to a `Property`
target and `isValueDefinitionLabel` covers only Const/Static/Variable, so the
filter is never consulted there. Pipeline-level edge sets are byte-identical with
the filter forced on and forced off, across four shapes — Java cross-file field
writes, Java cross-file constant reads, Java bare same-class constant reads, and
a Python module-constant/class-attribute mix. The defect is real and latent; the
blast radius is not. Fixed anyway, because the predicate asks the wrong question
and the next change that makes the bridge the sole emitter would ship the
deletion for real.

New `value-ref-locality.test.ts` pins the invariant triple — local dropped,
module-scope kept, class member kept — by TARGET rather than by `reason`. The
per-language suites filter on `rel.reason === 'read'|'write'` while the bridge
stamps `scope-resolution: read|write`, so they are blind to bridge-side change in
both directions. The file states plainly which half gates the mechanism (JS,
mutation-verified) and which gates only the outcome (Java, because the mechanism
is unreachable there), so it cannot be mistaken for a stronger gate than it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(docs): restore the agent guidance a generated-block refresh deleted

Commit 8f8261021's message is entirely about cross-language anchor reporting; it
also regenerated the `gitnexus:start` block in AGENTS.md and CLAUDE.md against a
LOCAL, non-PDG index and swept six documentation/config files along with it. The
review caught this and it is correct. Restored:

  - the index stats, which regressed 248612 symbols / 565510 relationships /
    918 flows -> 29969 / 118986 / 762 — my machine's index described as the
    project's;
  - the whole `pdg_query` bullet and the PDG half of the impact bullet, while
    both capabilities remain live in `mcp/tools.ts` and `local-backend.ts`;
  - the "Inline staleness signal" section in the guide skill, content that never
    left `origin/main` and that this branch had no reason to touch;
  - `.mcp.json`, which had moved from `npx -y gitnexus@latest mcp` to a bare
    `gitnexus` — a fresh clone with no global install gets a dead MCP server.

The worst of it is self-inflicted in a specific way worth naming: commit
411cac9b9, four hours earlier on this same branch, ADDED the instruction telling
agents not to read `risk: UNKNOWN` as an all-clear. The refresh deleted it. So
the branch shipped a new UNKNOWN verdict and simultaneously removed the guidance
for reading it — the exact false-safe this PR exists to remove, reintroduced one
layer up in the docs.

Re-applied that guidance, and found the drift is wider than reported. The review
noted the `.claude/` copy contradicting the plugin mirror; in fact the UNKNOWN
block was present in ONE of five shipped distributions. `gitnexus/skills/` (the
npm package), `gitnexus-cursor-integration/`, and `.agents/` were missing it too,
so every non-Claude consumer of this skill had the old table.

`shipped-skills-sync.test.ts` passed 54/54 through all of that. Its byte-identical
check covers only the plan/work/review/lfg family, and the standard skills are
guarded solely by per-skill fragment lists — so a fragment nobody listed is a
fragment nothing protects. Added the UNKNOWN fragments to that list, plus a
`copies.length > 1` assertion so an empty copy list cannot make the loop vacuous.
Verified it fails against the pre-fix tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): require the return-shape producer to RESOLVE, not merely to name-match

Review finding 2, reached independently by three Claude lanes and two Codex
legs, and reproduced here. `emitReturnShapeMemberAccesses` took the receiver's
type binding, then filtered a WHOLE-GRAPH property index with `idNamesMember` —
a textual match on the node id. Any node whose id happened to read
`<producer>.<member>` qualified, in any file and any language, and it emitted at
the 0.9 PRECISE tier where a `minConfidence` floor cannot filter it out. The
sibling unique-name pass was given a per-language restriction for exactly this
hazard; this pass consumed the same shared index with none.

Three guards, catching different shapes:

  - the producer must RESOLVE to a definition (`findCallableBindingInScope` — a
    CALLABLE lookup: the producer is the function whose return shape owns the
    member, and it resolves through finalized import bindings so a producer in
    another file still yields its own file);
  - the member must live in that definition's file;
  - that file must belong to the language being resolved.

The third is not redundant with the second, which is the part worth recording.
A receiver typed by CONSTRUCTION (`const bound = new Loyalty()`) resolves through
the shared class registry, which is polyglot — so the producer resolves into
`Loyalty.java`, its members legitimately live in that same file, and file
equality waves the cross-language edge straight through.

Also fixes the sibling P2: a site where the receiver IS typed to a producer that
owns no such member now claims the site. That branch is the strongest negative
evidence the pipeline can produce, and letting it fall through meant the 0.5 name
fallback answered a question the precise pass had just DISPROVED — measured,
linking a read to an unrelated same-named key in another file.

`polyglot-property-isolation` gains the bound-receiver arm the review asked for,
and it is the right arm: the pre-existing case has an untyped receiver and so
only ever exercised the unique-name pass, while one extra token routes an
identical read through this one. Mutation-verified — restoring the pre-fix
matching makes exactly the new leak assertion fail. The first version of that arm
was silently vacuous (it introduced a JS key of the same name, which destroyed
the fixture's Java-only premise), which is why it now asserts on the TARGET FILE
rather than on the absence of a name.

KNOWN LIMIT, stated rather than papered over: a member-call producer
(`const r = svc.make()`) binds `svc.make`, which resolves to no callable, so this
pass now declines it. Codex B3 raised that converse case and it is real. Fixing
it means typing `svc` and then finding `make` on that type — a larger piece of
work, queued for the follow-up PR. Declining is the correct interim behaviour:
the alternative is matching `make.<member>` by name across the graph, which is
the fabrication this commit removes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): resolve the import map by point lookup so the seal cannot empty it

Review finding 4, reproduced end-to-end by two lanes: the same commit and the
same repo produced a DIFFERENT graph depending on `GITNEXUS_DISK_SCOPE_INDEX`.

`buildDirectImportMap` built `scopeToFile` by walking `parsed.scopes`. The
out-of-core seal replaces `emitParsedFiles` with a scope-STRIPPED copy — that is
its documented contract, scopes are reachable only via `scopeTree.getScope`
afterwards — so under the seal the map came out empty, every `directImports`
lookup returned undefined, and tier-2 narrowing died repo-wide.

The reporting is the worse half. The loss surfaced as `ambiguous`, which means
"several candidates and the pass refused to choose". The truth was "the evidence
was discarded one function earlier". A reader acting on that would go looking for
better receiver typing to fix a problem that was not there.

This is the SECOND consumer of `parsed.scopes` on this branch to hit the seal.
The first was hoisted above it. This one is converted to the point lookup
instead, which is the stronger fix: a point lookup survives the seal by contract,
so there is no ordering left for a future edit to get wrong.

The parity assertion that would have caught it now exists. The sealed harness in
`javascript-const-references` already ran the fixture both ways, but every
assertion in it pinned ONE field's readers — which is exactly how a second
instance slipped in, since no assertion happened to cover a narrowed name. It now
also compares the WHOLE ACCESSES edge set between the two runs, as a sorted diff
so a failure names the edges that moved, with a non-empty guard so two empty sets
cannot compare equal and assert nothing. Mutation-verified: forcing the map empty
fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): bind a producer's own returned key to itself, and stop claiming uniqueness for a ranked answer

Review finding 3, accepting the two defects it demonstrates and declining the
remedy it proposes. Both halves are mutation-verified.

1. A SITE INSIDE ITS OWN RETURN SHAPE NOW BINDS TO ITS OWN KEY.

   `export function buildB(row) { return { tickIntervalMs: row.b } }` writes the
   key that IS `buildB.tickIntervalMs`. Ranking declared anchors above return
   shapes is correct for a READ through a receiver, but applied to this site it
   handed the write to a same-named module const that `buildB` never touches —
   a wrong edge — while the node the key actually defines was left with no
   writer at all. Both halves wrong from one rule applied to the wrong shape.

   Checked before every other rule, because it is evidence rather than ranking:
   the owner qualifier on the candidate id and the enclosing callable are the
   same symbol. Nothing outranks that.

2. THE TIER NO LONGER LIES.

   `workspace-unique` is a claim that exactly one node in the workspace carries
   the name — a fact about the graph, and the label a reader trusts most. An
   answer reached by FILTERING (tests down-ranked, return shapes down-ranked)
   is a weaker claim, and it was reported under the same label. The edge is
   unchanged; what it is allowed to say about itself is not. `narrowed` now
   counts these correctly too, since it keys off the tier.

WHAT I AM NOT DOING, and why. The review proposes dropping the same-file and
imported-file tiers "and keeping only genuine workspace-uniqueness". That would
revert the measured R2 result taking backend readers of `exitMinAtrMult` from
0 to 24. Workspace uniqueness was already measured too strict on that repo: the
field carries 26 Property definitions — 16 in one-off scripts, 7 in the
frontend, one in a test, and exactly one in the backend that reads it. Strict
uniqueness declines all 24.

The alternative suggestion — require the receiver to bind to the owning object —
has the same effect by another route: the population this pass exists for is the
untyped option bag, whose receiver binds to nothing. Requiring a binding turns
the pass off for its own use case. So the two demonstrated defects are fixed and
the capability around them is kept, at half confidence, naming its inference in
the reason string, and honoured only where `fieldFallbackOnMethodLookup` allows.

The R3-5 precision test needed rescoping rather than relaxing: it asserted that
EVERY edge to the contested field is a precise return-shape edge, which the
producer's own (correct, name-tier) write now violates. It asserts the reader
edges are precise and the producer's write binds to its own key — two different
claims reached two different ways, which is what the code now models.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(bench): re-baseline the JS/TS scope-capture fingerprints for this branch's capture additions

The `Cross-language scope-capture fingerprint + scaling guards` CI step was
failing on TypeScript and JavaScript, and it had been failing for the whole PR —
the branch changed both SCOPE queries without ever updating the guard's
baseline. It only surfaced now because a merge conflict had prevented CI from
running at all, so nothing reported it.

Re-baselined per the file's own instruction ("re-baseline intentionally on a
legitimate capture change"), and verified first rather than rubber-stamped. The
capture-name sets in both scope queries, diffed against `origin/main`:

  TypeScript  + @reference.read.identifier      (A2, bare-identifier reads)
              + @reference.type                 (R2-2, type references)
  JavaScript  + @reference.read.identifier      (A2)
              + @reference.read.destructured    (R2-1c)
              + @reference.write.property-key   (R2-1b)

Nothing removed on either side. A pure superset is the check that no EXISTING
capture moved — which is the failure mode a fingerprint guard exists to catch,
and the reason to look before regenerating.

Consistent everywhere else too: `capture_groups_small`/`_large` are unchanged
(4503/14403) because those measure the SYNTHETIC scaling source this branch does
not touch, so only the fixture-corpus number moves — 2097 -> 2338 across 21 new
lang-resolution fixtures, 146 -> 151 files. Scaling stayed linear and inside
budget (typescript 1.116, javascript 1.010, both < 1.5), so the added rules cost
no super-linear time. Prior and new hashes are recorded in the baseline note, as
every previous entry in that file does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(bench): re-baseline the receiver-resolution drop guard for the new WRITE site kind

Second of the two bench guards that had been failing for the whole PR without
anyone seeing it — CI could not run while the branch was conflicted, so both
went unreported until the merge cleared.

The drift is a new site KIND, not a movement in an existing one:

    totalDropsAllKinds  129 -> 140
    bySiteKind          {call: 102, read: 27}
                     -> {call: 102, read: 27, write: 11}

`call` and `read` are byte-identical, which is the check that matters. This
branch added write-site captures the corpus never had — `@reference.write.
property-key` (R2-1b record construction) and the destructured-read rules — so
write sites reach receiver resolution for the first time, and 11 of them have a
receiver that does not resolve. A drop is the honest outcome for those; the
alternative is the name-inferred guess this series spent three rounds bounding.

Verified it is NOT caused by this session's review fixes before re-baselining:
removing the `memberNotOnShape` site-claim added in 69047086 and re-running gives
the identical 129 -> 140 / write: 11 drift, so the movement predates today and
belongs to the capture work, exactly as the arithmetic above says.

The sibling `scope-emission` guard still PASSES untouched, and the fingerprint
guard passes after 20a937f4 — so all three arms of the benchmarks job are green
locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(routes): track boolean polarity in dispatch guards, so a negated condition cannot invent a route

Reproduced exactly as reported. `dispatch-guard.ts` refuses to inherit a verb
from an `if` whose `else` branch holds the comparison — the module's own doc
comment explains why: that branch runs precisely when the condition did NOT
hold, so attributing it is backwards. `!` is the same fact written as an
operator, and it was not handled. A stated invariant with half an
implementation, which is worse than an absent one, because the comment reads as
though it were covered.

Measured against the real extractor before fixing:

    if (!(pathname === '/api/admin'))                  ->  '' /api/admin   INVENTED
    if (!(req.method === 'GET') && pathname === '/x')  ->  GET /x          INVERTED
    if (!(req.method === 'POST' && pathname === '/w')) ->  POST /w         BOTH

And the review is right that this is not additive-only. Driven through the real
pipeline with a policy module that serves nothing plus a one-line route table,
the invented `GET /api/report` collected into `verbedUrls` and
`reconcileDispatchGuardRoutes` then EVICTED the true verb-less route for that
path. A false route deleted a real one. After the fix that repo yields exactly
one route, verb-less, path intact.

Parity, not presence: `!!x` is `x`, so counting negations and testing the parity
is the only rule that keeps a doubly-negated guard working. A negated VERB drops
to verb-less rather than dropping the route — `!(method === 'GET')` means every
method except GET, which no single value expresses, while the path evidence is
untouched. Applies to the regex arm too; `!/^\/api\/x$/.test(pathname)` had the
identical hole.

Deliberately NOT keeping the `statement_block` break from the suggested patch.
It is unreachable — the `!` in `if (!cond) { … }` lives in the condition, a
SIBLING of the block, never an ancestor of anything inside it, and the only
shape that puts a `!` above a block is an IIFE, which the function-boundary stop
catches first. Unreachable in the UNSAFE direction, too: breaking early
under-counts negations, and an under-count reads a negated guard as positive and
invents the route. Verified by mutation — with the break present, deleting it
fails nothing; the other three guards each fail a test when removed.

Six new cases, all previously absent (`grep -c '!(' ` over both test files was 0,
and the only negation covered was `!==`, the form that already worked).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(bench): re-baseline the emit-persistence byte-identity fingerprint for the isDetail column

The third bench guard this branch left red, and the one the earlier
rebaseline pass missed: the `benchmarks (GITNEXUS_BENCH)` job has never
succeeded once in eleven attempts, and since step 11 aborts the job, the
two steps after it — the streaming PDG-emit guard and the cross-language
pipeline benchmarks — have never executed at all.

    [emit-persistence --check] FAIL: byte-identity fingerprint drift
      (got 4ee15e74…, expected 69e9182a…)

Cause is this branch's own `isDetail` BOOLEAN on the Property table
(PROPERTY_SCHEMA), which `streamAllCSVsToDisk` writes as one more header
field and one more cell per Property row.

Verified header-only rather than regenerated on faith. Dumping every CSV
the bench emits on both `origin/main` and this branch and diffing them
per file (name, byte length, sha256): the file set is identical at 35
CSVs, 34 of the 35 are byte-identical, and the sole difference is
property.csv growing 68 -> 77 bytes as the header gains `,isDetail`. The
synthetic graph mints no Property nodes, so not one data row moved —
which is the thing this fingerprint exists to catch. Both timing gates
were green throughout (scaling_ratio 0.783 against a 1.8 budget,
elapsed_ms_large 229ms against the 1000ms backstop), so no throughput
claim is being rebaselined away.

Justification recorded in a `_rebaselined_<reason>` key, the convention
bench/scope-capture/baselines.json already sets, and the note now says so
explicitly so the next regeneration records its reasoning too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj

* perf(processes): build each trace key once, not once per comparison

`deduplicateTraces` held its `join('->')` inside the `some()` callback, so
every already-kept trace had its key rebuilt from scratch against every
candidate: O(T*U) joins of O(depth * id-length) characters. The
allocation, not the substring scan, is what the pass spends its time on.

Nothing about breadth-first search made that safe. It only hid the cost by
keeping traces short — measured on this repo the walk averaged 4.3 steps
before D1 and 9.4 after, which roughly doubles both the number of
surviving traces and the length of every key, so the same quadratic that
was affordable under BFS is about six times the work under DFS. That is
the whole of the slowdown D1 was carrying; the depth-first walk itself is
cheaper than the queue it replaced (`pop()` against an O(frontier)
`shift()`), and its frontier is bounded by depth rather than by breadth.

Hoisting the join into a `uniqueKeys` array removes the multiplication.
Measured back to back on one host, 5 reps, 25k callables, production sink
path (main -> this branch before -> this branch after):

    deep_chain      876.8ms -> 1233.1ms -> 101.9ms
    mixed_cycles    731.4ms -> 1130.6ms -> 132.8ms
    shallow_wide    572.5ms ->  531.8ms ->  49.6ms

and on the real gitnexus/src corpus (11,490 symbols) process detection
goes 204ms -> 89ms against main, having been slower than main before.

Output is unchanged, which is the property that matters here: swapping the
file back and forth and diffing every non-timing field across all sixteen
shape x scale x sink-variant configurations gives no difference, and the
real corpus returns the same 936 processes / 4,648 steps either way. Sink
keys are pushed alongside the traces they belong to, so the comparison set
is the same set it always was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj

* fix(processes): type the parse-output read as ParseOutput

The R3-6 sink read declared its own structural shape for the parse output
instead of naming `ParseOutput`, which made it the only one of the five
parse consumers in the repo not bound to the real type — cross-file.ts,
orm.ts, routes.ts and tools.ts all pass the type argument.

`getPhaseOutput` is a raw `as T` cast, so a local shape checks nothing at
runtime and only severs the compile-time link: renaming `allFetchCalls` on
`ParseOutput` would still compile here and silently detect zero sinks
forever. Verified with a real `tsc --noEmit --strict` run over exactly that
rename — the typed consumers error, this one did not. The runtime `.filter`
stays, since it is the only thing actually guarding the cast.

Also brings the phase docblock back in line with the deps array, which was
missing `structure` (pre-existing) and `parse` (added by this branch), and
records the two parse fields the phase now reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-08-08 09:58:14 +01:00
azizur100389
797e4ef8f6
fix(ai-context): document CLI graph fallbacks (#2803)
* fix(ai-context): document CLI graph fallbacks

Teach generated GitNexus guidance to pair mandatory MCP graph checks with repo-scoped CLI fallbacks so agents can keep working when MCP is unavailable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(ai-context): satisfy Prettier

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 20:52:44 +01:00
Gergő Magyar
2dc0cc6398
fix(mcp): prevent sibling-clone repo ID collisions and correct generated MCP tool names (#2067) 2026-06-07 10:57:15 +01:00
Gergő Magyar
f885330b34
fix(cli): steer docs, skills, and hooks through a CLI-neutral project-local runner (#1939) (#1945)
* fix(cli): steer npm 11 users away from npx install crash (#1939)

Prefer global gitnexus or pnpm dlx in hooks and generated AI context, warn
when npm 11.x would use the broken npx path, and document workarounds for
the arborist node.target null failure mode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(hooks): stage resolve-analyze-cmd.cjs for antigravity adapter; harden load checks

The antigravity adapter gained a top-level require('./resolve-analyze-cmd.cjs')
but stageAdapter() did not copy it, so the spawned adapter crashed with
MODULE_NOT_FOUND. Three load-sensitive tests failed; four silent-path tests
false-passed on empty stdout.

Stage the helper alongside the other sibling helpers, and assert status===0 and
no MODULE_NOT_FOUND on the four silent-path tests so a non-loading hook can never
pass green again. Force a deterministic invocation mode in the stale-index test
so the emitted analyze command no longer varies by CI-runner PATH.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): standardize invocation hints on gitnexus@latest; single-source CJS helper

NPX_REF becomes a literal `gitnexus@latest` in resolve-invocation.ts, dropping
the package.json require and the module-load throw (a malformed/absent version
can no longer crash any CLI command at import). The safety this PR delivers is
the install method steered to (global / pnpm dlx), not a pinned gitnexus
version, and the in-repo CJS mirror already degraded to `latest` once copied
outside the package.

Make the two resolve-analyze-cmd.cjs copies byte-identical and add a parity
test that fails on drift. The separate, version-pinned NPX_REF that setup.ts
writes into the MCP server registration is intentional and left unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf(cli): move npm-11 npx warning off module load; memoize invocation mode

warnIfNpm11NpxRisk() ran at index.ts module load, so every CLI invocation
(including the `gitnexus mcp` stdio hot path) paid which/where + npm --version
spawns — against the lazy-startup/MCP-stdout discipline (#207, #1383). Move the
call into analyzeCommand, after the ensureHeap() re-exec guard, so it fires once
in the working process and only for `analyze`.

Memoize the PATH-probe-derived invocation mode (the GITNEXUS_INVOCATION override
stays uncached) so repeated callers don't re-probe, and add a test-only reset so
the cache + once-only warning flag don't leak across the unit suite. Covers the
mode!=='npx', npm<11, and npm-absent suppression branches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): detect .exe/extensionless global gitnexus shims on Windows

The winGitnexusWrapper branch only matched .cmd/.bat, so a global gitnexus
installed by Volta or scoop (a .exe or an extensionless shim) was missed and the
hint fell back to pnpm/npx. Accept .exe and treat any non-empty `where` hit as
on-PATH (the emitted hint is `gitnexus analyze` regardless of which shim
resolves it). Mirror the change into both resolve-analyze-cmd.cjs copies so the
TS source and the byte-identical hook mirrors stay in sync.

Add Windows-mocked test cases (.exe-only, extensionless, .cmd preference, CRLF
stripping) and register resolve-invocation.test.ts in cross-platform-tests.ts so
the windows-latest runner exercises the branch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): emit fixed pnpm dlx analyze command in generated AGENTS.md/CLAUDE.md

ai-context baked a machine-resolved command (formatAnalyzeCommand) into
git-tracked AGENTS.md/CLAUDE.md, so the stale-index hint varied per machine and
churned across branches (the #1706 class). Emit the fixed string
`pnpm dlx gitnexus@latest analyze` instead: committed AI-context is the most
authoritative instruction an agent reads, so it must name an install-free,
crash-free method — never `npx`, the npm-11 path #1939 steers away from.

formatAnalyzeCommand stays exported and unit-tested in resolve-invocation.ts
(it still mirrors the two .cjs hook copies); ai-context just no longer calls it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(cli): unify hook-helper copy into one non-silent routine

installClaudeCodeHooks copied its four hook helpers in separate try/catch blocks
that silently swallowed failures, while installAntigravityHooks recorded an
error per failed copy. Extract one copyHookHelpers(srcDir, destDir, label,
result) with a single canonical helper list (including resolve-analyze-cmd.cjs)
and the antigravity loop's error-reporting policy, and use it from both paths so
a missing helper surfaces as a setup error instead of a silent runtime crash.

Assert both the Claude and Antigravity install paths co-locate
resolve-analyze-cmd.cjs next to the adapter, and that a failed copy records an
error rather than passing silently.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(cli): reattach installClaudeCodeHooks JSDoc after helper extraction

The extracted HOOK_HELPERS/copyHookHelpers block landed between the
installClaudeCodeHooks JSDoc and its function, leaving the doc reading as if it
described the helper list. Move the block above the doc so it documents the
function again. No behavior change.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): enforce TS<->CJS invocation parity and guard CLI startup posture

Tier-2 review found two in-scope gaps in the #1945 follow-up:

- The "mirrors resolve-invocation.ts / test enforces parity" comments overclaimed:
  the parity test only compared the two .cjs copies to each other, so the TS
  source and the CJS hook copies could silently drift (NPX_REF, the per-mode
  command, and the Windows shim regex were hand-edited in all three this PR).
  Add TS<->CJS value parity (NPX_REF + formatAnalyzeCommand for every forced
  mode) and a source-level shim-regex parity check, and make the mirror comments
  accurately describe what is enforced.

- No test locked the R3/R4 startup posture, so re-adding warnIfNpm11NpxRisk()
  (or any resolve-invocation import) at index.ts module scope -- the #207/#1383
  lazy-startup regression -- would pass CI. Add a guard asserting index.ts has
  no module-load invocation probe and the warning is wired into analyzeCommand.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(cli): collapse npx-invocation resolver to one source of truth

PR #1945 carried the gitnexus/pnpm/npx selection in three hand-synced
places — the canonical hook helper, its byte-identical plugin copy, and a
full TypeScript re-implementation in resolve-invocation.ts — kept in lockstep
by per-mode-command and regex-extracted-by-regex parity tests. The TS
formatAnalyzeCommand had no production caller (ai-context emits a fixed
string), and the module memoized + exposed a test-only reset for a "repeated
callers" case that has exactly one caller.

Make hooks/claude/resolve-analyze-cmd.cjs the single source: extract the
Windows-shim line-picking into a pure, exported pickPathMatch() and add an
injectable probe to resolveInvocationMode() so the shipped logic is testable
without spawning or global mocks. resolve-invocation.ts (118 -> 59 lines) now
consumes that cjs via createRequire for resolveInvocationMode/NPX_REF and adds
only the CLI-only npm-version probe and warning; the relative path resolves
identically from src/cli/ (tsx, vitest) and dist/cli/ (shipped, hooks/ is a
published sibling of dist/). Tests exercise the real shipped artifact, the
NPX_REF/mode-command parity scaffolding is dropped (one implementation can't
drift), and parity narrows to the two cjs copies staying byte-identical.

No behavior change: hook stale-index hints and the analyze warning are
byte-identical; the pre-existing setup.ts resolveGitnexusBin is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): bound stale-index hook PATH probe under the hook budget (U1)

The PostToolUse stale-index hint calls formatAnalyzeCommand(), which probes which/where; named PROBE_TIMEOUT_MS=2000 keeps git rev-parse (~3s) + up to two probes well under Claude Code's 10s hook timeout while preserving the machine-correct hint. Byte-identical in the plugin copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): steer generated cross-repo group commands off npx (#1939) (U2)

The Cross-Repo Groups block in generated AGENTS.md/CLAUDE.md still emitted bare 'npx gitnexus group ...', funneling npm-11 users into the arborist crash; switch to fixed 'pnpm dlx gitnexus@latest group ...'. Export generateGitNexusContent and add a group-branch test asserting no 'npx gitnexus' literal survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: align steering guidance on pnpm dlx gitnexus@latest (U3)

README troubleshooting uses gitnexus@latest; the repo's own committed CLAUDE.md/AGENTS.md stale-index hint now matches the generated output (pnpm dlx gitnexus@latest analyze) so the repo dogfoods the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hooks): assert exact @latest analyze command and pin invocation mode (U4)

Drop dead PKG_VERSION/NPX_REF version-pinned constants; the cjs always emits gitnexus@latest, so assert exact toContain(...) instead of the /@\\S+/ wildcard; pin GITNEXUS_INVOCATION in the --embeddings tests for host-independent determinism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): cover resolver warn/edge branches; document probe seam (U5)

Add coverage for the gitnexus-mode warn suppression, getNpmMajorVersion edge inputs (empty/pre-release/non-numeric), and the Windows non-wrapper pickPathMatch branch; widen the InvocationResolver interface to document the optional probe param.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): lower hook PATH-probe timeout to 1000ms (U1)

In a linked worktree the stale-index hook runs git rev-parse --git-common-dir (~2s) + rev-parse HEAD (~3s) before up to two PATH probes; PROBE_TIMEOUT_MS=1000 holds the worst case near ~7s under Claude Code's 10s hook budget (was 2000, ~1s headroom). Byte-identical in the plugin copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): fail closed in gitnexus setup on missing required hook helper/adapter (U2)

copyHookHelpers now returns the failed REQUIRED helpers (the .cjs trio; win-rm-list-json.ps1 stays best-effort since it fails open). Both install paths skip hook registration with an actionable error when a required helper failed; the Claude path also gains the adapter-existence guard the Antigravity path already had. Prevents registering a hook that crashes MODULE_NOT_FOUND on every tool event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skills): steer committed skill files off npx to pnpm dlx gitnexus@latest (U3)

All 26 committed skill-file copies (gitnexus/skills, .claude, plugin, cursor) used 'npx gitnexus analyze', contradicting the generated freshness line and funneling npm-11 users into the arborist crash. Replace with 'pnpm dlx gitnexus@latest analyze'; add a regression guard (skills-steering.test.ts) that globs all four locations and fails if any reintroduces it. The cli skill's non-analyze npx subcommands (status/clean/list/wiki) are left as-is (out of the analyze-funnel scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): guard resolver import shape; assert group-impact steering (U4)

Add a load-time guard on the createRequire(resolve-analyze-cmd.cjs) cast so a drifted/renamed cjs export fails loudly at module load instead of as a late TypeError in warnIfNpm11NpxRisk. Add the missing 'group impact' assertion to the ai-context Cross-Repo Groups test, and a resolver-contract test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): auto-select invocation path with pnpm --allow-build (#1939)

Probe npm/pnpm versions and PATH to pick a working analyze command without
user configuration: global gitnexus first, pnpm dlx with --allow-build on
npm 11+ (Ladybug native scripts), npx on npm 10 and earlier. Update docs,
skills, and tests to match the canonical install-free command.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): place pnpm --allow-build before dlx, repair version-injection seam (#1939)

The auto-selected install command emitted `pnpm dlx --allow-build=… analyze`,
but pnpm < 10.14 keeps `dlx` in its argv escape list, so flags placed *after*
`dlx` are parsed as package specs and rejected (ERR_PNPM_SPEC_NOT_SUPPORTED) on
pnpm 10.2–10.13.x — strictly worse than the bare command. Move the flags before
`dlx` (the position pnpm has honored since 10.2.0) in both byte-identical hook
copies, the committed AGENTS.md / CLAUDE.md, and every skill tree.

Also repairs the CI-red resolveInvocationMode seam: injecting `{ npmMajor: null }`
to simulate an absent npm fell through `??` to the host's real `npm --version`
(npm 10.x on the CI runners → routed 'npx' instead of 'pnpm'). Use an
`'npmMajor' in deps` sentinel so an injected null is honored, drop the dead
parseMajorVersion guard, and gate the flags on pnpm >= 10.2 via a single
minor-aware probeVersion spawn (skipped for committed docs). Align the TS
getNpmMajorVersion timeout to the 1s hook budget and strengthen the
skills-steering guard with a pre-dlx positive assertion plus a post-dlx
regression check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: add npm-11 pnpm caveat to README Quick Starts (#1939)

The root, package, and cursor-integration README Quick Starts still steered
first-contact users to bare `npx gitnexus analyze` — the exact npm 11.x
arborist install crash issue #1939 names as a funnel. Add a one-line pnpm
`--allow-build … dlx` caveat (keeping the simple npx default for npm<=10 /
pnpm / yarn users); the package README points to its existing npm-11
workaround section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): route every gitnexus-cli command off npx to pnpm dlx (#1939)

The gitnexus-cli skill demonstrated analyze via `pnpm --allow-build … dlx`
but still showed status/clean/wiki/list via bare `npx gitnexus` — the same
package, the same npm-11 crash-prone install path — and its header claimed
"all commands work via npx". Convert every subcommand to the pnpm form across
all three skill copies and reconcile the header. Broaden the skills-steering
guard to forbid any `npx gitnexus` command in the cli-skill copies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(hook): probe pnpm once on the stale-index path (#1939)

The stale-index hook resolved pnpm twice — `which pnpm` for mode selection
then `pnpm --version` for the allow-build gate — two spawns for one tool in a
~9s/10s budget. Capture the version once in formatAnalyzeCommand and thread it
through the existing deps seam (a successful `pnpm --version` proves presence),
sharing a memoized PATH probe with resolveInvocationMode. Add explicit pnpm
10.0-suppress / 10.2-emit boundary tests and relabel the unknown-minor case.
Both byte-identical cjs copies updated together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setup): single-quote POSIX hook command + assert cliPath patch applied (#1939)

The hook `command` written into editor settings is shell-evaluated; the
double-quoted `node "<path>"` form left `$`, backtick, and other metacharacters
live in an adversarial $HOME. Single-quote the path on POSIX (Windows keeps the
double-quoted form — those chars are illegal in Windows filenames). Also assert
the cliPath source-literal replace() actually matched, recording an actionable
error on drift instead of silently shipping a hook with an unresolved relative
path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(setup): normalize expected hook path for the Windows runner (#1939)

The new POSIX-escaping test built its expected hook path with path.join,
which emits backslashes on the Windows runner, while setup.ts forward-slash-
normalizes the path before quoting — so `expect(cmd).toBe(node '<path>')`
mismatched on tests/windows-latest. Normalize the expected path the same way.
Production code was already correct; only the test's expected value was
platform-fragile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): steer docs/skills via a project-local runner, not a pnpm default (#1939)

The prior approach hardcoded `pnpm --allow-build=… dlx gitnexus@latest <cmd>`
into every committed skill + the generated AGENTS.md/CLAUDE.md, which assumes
pnpm is installed. Replace it with a CLI-neutral project-local runner:

- `gitnexus analyze` drops `.gitnexus/run.cjs` (a copy of the canonical
  `resolve-analyze-cmd.cjs`, which gains `buildRunnerArgv` + a `require.main`
  exec tail) next to the index. Docs/skills reference `node .gitnexus/run.cjs
  <cmd>`, which auto-selects the runner (global `gitnexus` → `pnpm dlx` → `npx`)
  at call time — no package-manager assumption. README first-run + an inline
  bootstrap note stay universal `npx gitnexus analyze`.
- The exec tail uses `shell` on Windows so `.cmd`/`.ps1`/`.exe` shims resolve
  (execFileSync can't otherwise; Node blocks `.cmd` without a shell,
  CVE-2024-27980), and prints a diagnostic instead of a silent exit 1.

Tests: runner exec-tail (real spawn, exit-code propagation + ENOENT diagnostic),
copy-failure graceful degradation, and per-subcommand routing + pnpm-fallback
vacuity guards. The generated CLAUDE.md block stays under the #856 token budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): resolve Windows .cmd version probes so pnpm steering fires (#1939)

probeVersion (and the TS getNpmMajorVersion mirror) spawned npm/pnpm
--version via execFileSync with no shell, so on Windows the .cmd shims
ENOENT'd, the probe reported a present tool as absent, and the stale-index
hook recommended the npx crash path #1939 exists to avoid. Add
shell: process.platform === 'win32' to the version probes (the exec tail
already does this). Parse the first version-shaped line so a Corepack/notice
banner on stdout no longer defeats the parse. Carry pnpm presence separately
from version so a present-but-unparseable pnpm still selects pnpm. Drop the
dead probe ?? resolveOnPath coalesce. Cover resolve-analyze-cmd.cjs (+ plugin
twin) with the shell-injection and windowsHide source-regression guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): widen pnpm allow-build for the --embeddings=N equals form (#1945)

buildRunnerArgv detected embeddings via gitnexusArgs.includes('--embeddings'),
which missed the equals form (--embeddings=5000) that Commander also accepts,
dropping --allow-build=onnxruntime-node on pnpm 10.2+. Match both forms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): cover the runner exec-tail Windows shell branch on CI (#1945)

runner-exec-tail.test.ts was POSIX-only and unregistered in
cross-platform-tests.ts, so the run.cjs Windows shell:true exec branch ran on
no platform despite the file comment claiming windows-latest covered it. Add a
.cmd-shim it.skipIf(onPosix) case and register the file in SPAWN_CLI so the
windows-latest job runs it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: fix broken troubleshooting anchor in gitnexus README (#1945)

The npm-11 quick-start note linked to #npx-gitnexus-crashes-with-nodetarget-is-null-npm-11,
which matches no heading; the actual troubleshooting heading slugifies to
#cannot-destructure-property-package-of-nodetarget-as-it-is-null. Repoint the link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hooks): guard resolve-analyze-cmd.cjs in antigravity e2e sanity check (#1945)

The antigravity adapter top-level require()s resolve-analyze-cmd.cjs, but the
beforeAll helper-presence loop did not check for it — a failed copy would
surface as noisy MODULE_NOT_FOUND in downstream tests instead of the intended
actionable 'Helper not installed' error. Add it to the loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): tie a missing-runner Cannot-find-module error to recovery (#1945)

Generated CLAUDE.md/AGENTS.md make `node .gitnexus/run.cjs` the primary
command, but the runner is gitignored, so a fresh clone or git clean leaves an
agent facing a raw MODULE_NOT_FOUND. The CLAUDE.md block is token-budget-capped
(#856), so the recovery guidance lives in the cli skill (its documented home):
the bootstrap note now names the `Cannot find module` error and points at
`npx gitnexus analyze` to (re)generate the runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): disambiguate the MCP-pinned ref from the @latest hint (#1945)

setup.ts and resolve-analyze-cmd.cjs both exported a constant named NPX_REF
with different values (version-pinned for the persisted MCP entry vs.
gitnexus@latest for hints). Rename setup.ts's module-private constant to
MCP_PINNED_REF (value and behavior unchanged — the MCP pin stays pinned),
leaving the cjs hint ref and its re-export alone. Also route the createRequire
cast through 'unknown' so it reads as an explicit narrowing to the subset this
module uses rather than a claim about the cjs's full export shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:00:34 +01:00
abhigyanpatwari
39b01f101e feat(skills): rewrite skill descriptions for better auto-invocation
Skill descriptions were too tool-centric ("using knowledge graph", "blast
radius") which prevented Claude Code from matching them to user intent.
Rewritten to user-intent-driven format with "Use when..." phrasing and
example trigger phrases so Claude can semantically match user requests.

Updated across all 3 sources: gitnexus/skills/, gitnexus-claude-plugin/skills/,
.claude/skills/, and the ai-context.ts fallback generator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:24:41 +05:30
Linus Beckhaus
238abbd947 refactor(skills): prefix all skill names with gitnexus- for disambiguation
Skill folder names determine invocation paths in Claude Code plugins
(e.g. plugin:gitnexus:gitnexus-cli). Generic names like "cli" or
"debugging" could collide with other plugins, so prefix them all with
gitnexus- for clarity.

Updated across plugin dirs, main package source files, ai-context.ts
generator, setup.ts installer, and all CLAUDE.md/AGENTS.md routing tables.
2026-02-25 14:15:42 +01:00
Renamed from gitnexus/skills/impact-analysis.md (Browse further)