Compare commits

...
Author SHA1 Message Date
gitnexus-release-bot[bot] 53e464d72b release: v1.6.10-rc.106 2026-07-25 08:44:02 +00:00
Gergő Magyar ad1b9227c4 fix: large-repo analyze OOM and false worker-timeout cascade (#2649) (#2679) 2026-07-25 09:16:17 +01:00
Gergő MagyarandGergo Magyar 2ec00b8952 fix(analyzer): reject cross-drive paths in the identity containment guard (#2688)
`isInside()` paired its `..` checks with no absolute-path rejection, so on
Windows it reported an unrelated drive as *inside* the parent. `path.relative`
cannot express a relative path between two drives and returns the absolute
target instead:

  path.win32.relative('C:\\parent\\src', 'D:\\other\\file.js')  // 'D:\\other\\file.js'

That string does not start with '..', so the guard passed it.

Impact, per call site:
- resolveInvokedArtifact: adopts `process.argv[1]` as the invoked analyzer
  artifact whenever it merely sits on another drive. That file is then absent
  from the validated build, so resolveAnalyzerRunnerIdentity throws — `analyze`
  and `status` fail outright on a multi-drive Windows install (e.g. a launcher
  on D: invoking a package installed on C:). This is how the bug surfaced: the
  GitHub Windows runner keeps the repo on D: and temp fixtures on C:.
- cacheDirectory: the "trusted cache directory must be outside the package and
  build roots" guard wrongly fires for a directory on another drive, rejecting a
  legitimate configuration.
- validateIdentityCache / cachedBuildDigestForPath: a containment check that can
  answer "inside" for a path on another drive is weaker than intended.

Fix: reject an absolute `path.relative` result. This is the idiom the repo's
other containment guards already use — server/api.ts, server/git-clone.ts and
group/extractors/fs-utils.ts all pair the '..' check with `path.isAbsolute`;
this function was the outlier.

`pathApi` is injectable (defaulting to the platform-bound `path`) so the win32
semantics are unit-testable from a POSIX runner. The new test is fixture-free
and registered on the cross-platform matrix; its cross-drive case fails without
the guard and the same-drive/POSIX cases pass either way, proving the fix is
narrow.

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-07-25 08:21:34 +01:00
Gergő MagyarandGergo Magyar 7316503ebc perf(analyze): hold structural relationships out of the JS heap, on by default (#2680) (#2685)
* refactor(lbug): extract SyncCsvWriter into a shared module

`PdgEmitSink` (#2202) declared `SyncCsvWriter` as a private, non-exported
class. The structural streaming sink for #2680 needs the same buffered
sync-write + poison/openFailure IO discipline, and importing it is not
possible while it is module-private — so the alternative was copying ~90
lines of it.

Extract the class (and the chunk-rows default it uses) into
`sync-csv-writer.ts` and have `PdgEmitSink` import it.
`DEFAULT_PDG_EMIT_CHUNK_ROWS` stays exported as an alias so no existing
caller changes.

Pure refactor: no behaviour change. pdg-emit-sink.ts 396 -> 302 lines;
tsc clean; the 23 existing #2202 tests pass unchanged.

Refs #2680

* feat(lbug): add GraphEmitSink for streaming structural relationship emit

Structural sibling of PdgEmitSink (#2202): a KnowledgeGraph façade that
routes relationships no mid-pipeline phase reads back to bounded
CSV-on-disk and never stores them. Nothing constructs it yet.

Measurement drove the design. On a kernel-shaped synthetic graph (400k
nodes, 2.7 edges/node):

  nodes only ......  367 B/node
  nodes + edges ... 2075 B/node   <- reproduces the #2649 ~2.1 KB/node
  => the relationship layer is 83% of graph heap, ~646 B/edge

so streaming *relationships* is where the memory is; nodes stay resident
(they are 17%, and two scope-resolution index builders scan them).
Dropping just the redundant relationshipsByType/edgeIdsByNode indexes was
also measured — 174 of 648 B/edge, ~1.3x — and is not a substitute.

RETAINED_REL_TYPES is derived from an exhaustive audit of every
relationship read site under src/, and each entry names its reader. An
earlier draft carried 14 types, 5 of which no reachable phase reads.

Two deliberate departures from PdgEmitSink, both because its invariants
do not hold here:
- dedup by relationship id, since no upstream per-file uniqueness
  guarantee exists for structural edges and COPY would violate the PK;
- removeRelationship on an already-streamed id throws instead of
  no-oping, so a mutating consumer cannot corrupt the graph undetected.

Also exposes hasStreamedSemanticEdge for the local-symbol pruner: without
it a block-local symbol referenced only by a streamed edge looks
unreferenced and gets pruned, leaving a CSV row pointing at a node with
no row.

Refs #2680

* feat(analyze): stream structural relationships to CSV under GITNEXUS_STREAM_GRAPH_EMIT

Wires GraphEmitSink into the pipeline behind a full-rebuild-only flag, so
relationships that no mid-pipeline phase reads back never enter the JS
heap. Measured ~2.9x reduction of graph heap:
0.17 (nodes) + 0.83 * 0.21 (retained edges) = 0.344 retained. This is a
constant factor, NOT O(chunk) — node identity and the resolution
registries stay O(repo).

The sink is armed at the PARSE boundary, not at graph construction. An
exhaustive audit of every relationship read site under src/ found four
mid-pipeline CALLS consumers, not the two an earlier draft assumed:
- local-symbol-pruner (full iterRelationships scan, then removeNode)
- communities / processes (whole-graph forEachRelationship)
- mapCobolToGraph, which scans CALLS and REMOVES the unresolved ones —
  and runs BEFORE parse, so streaming from construction would have
  silently stopped COBOL cross-program call resolution
- taintSummaries, gated on `pdg` and NOT on `skipGraphPhases`, so it
  needs its own gate or --pdg + this flag yields an empty taint layer

Accordingly communities, processes, taintSummaries and callSummaries are
all disabled under the flag, and the run logs what it is giving up.

Two fixes that are correct independently of the flag:
- runPipelineFromRepo keyed its community/process extraction off
  `!skipGraphPhases` while getPhaseOutput THROWS on a phase filtered out
  by any enabledWhen predicate — now a presence check, so filtered
  combinations return undefined instead of crashing.
- loadGraphToLbug COPYs one job per CSV FILE rather than per label pair.
  #2202's throw-on-collision merge is only sound because BasicBlock pairs
  are disjoint; a streamed CALLS edge is Function|Function and always
  collides with the whole-graph CSV for that pair, so the structural
  manifest appends instead.

The buffer-pool hint adds the streamed row count back in: the hint only
ever shrinks the pool, so sizing it from the post-streaming
relationshipCount would starve the COPY at exactly the scale this
targets.

detect_changes: 18 symbols / 10 files / 9 processes, all within the
planned scope. Full suite green with the flag off.

Refs #2680

* fix(mcp): stop impact() under-reporting risk on a streamed index

An index built with streamed structural emit has no Process or Community
rows, and impact()'s risk scorer uses processCount >= 5 and
moduleCount >= 5 as two of its four CRITICAL escalation criteria. The
missing-table errors are swallowed as benign without raising `partial`,
so nothing distinguished 'this repo has no processes' from 'this index
was built without them' — the same change would report LOW off a streamed
index and CRITICAL off a complete one, with no signal either way.

That is the false-clean shape #2283 ruled out for detect_changes, and it
matters more here because the repo's own workflow mandates impact()
before every symbol edit.

Stamp `graphPhases: 'complete' | 'skipped'` into RepoMeta and have
impact() attach riskUnderstated + an explanatory riskNote when the index
is stamped skipped, so the reported level is explicitly a lower bound.
Unlike the rest of RepoMeta.capabilities this stamp has a real
programmatic reader.

Also documents GITNEXUS_STREAM_GRAPH_EMIT in the README env table,
including everything the flag disables.

Refs #2680

* test(lbug): differential set-identity gate for streamed structural emit

The acceptance property for #2680: for the same node/edge set, the rows
reaching the bulk COPY must be identical whether streaming is on or off.
With streaming on they arrive from two places — the residual in-memory
graph via streamAllCSVsToDisk, plus the sink's per-pair CSVs — so the
test asserts their UNION equals the single whole-graph emit.

Also asserts the split is real (retained + streamed == total, streamed >
0), so a sink that silently streamed nothing cannot pass the equality
vacuously. Verified discriminating: with sink.arm() commented out the
test fails ('expected 0 to be greater than 0'); restored, it passes.

Fixture spans both sides of RETAINED_REL_TYPES and includes a self-edge
and a duplicate relationship id — the cases where a naive sink diverges
from the whole-graph emit.

Drives the sink directly rather than running analyze, matching
pdg-emit-streaming-roundtrip.test.ts: the guarantee is about emitted
rows, and the worker pool would add unrelated machinery without
strengthening the assertion.

Refs #2680

* fix(test): remove literal NUL byte and cover streamGraphEmit phase gating

Two review findings, both verified before accepting.

1. The round-trip test contained a literal NUL byte as a key separator,
   which made Git treat the whole .ts file as BINARY —
   `git show --numstat` reported `-\t-` for it, so the file would not
   diff or blame and CI text tooling would skip it. Replaced with the
   escaped \\u0000 sequence; behaviour is identical, the file is text
   again. (Found by the Codex swarm lane.)

2. buildPhaseList's four new streamGraphEmit gating predicates and the
   flag-off default path had no test that would fail on revert — two
   review lanes flagged this independently. Reversing any enabledWhen
   condition would have passed the suite silently, which matters because
   an ungated taintSummaries yields an empty taint layer rather than an
   error.

Added four cases: the streamed run drops communities/processes/
taintSummaries/callSummaries; it keeps mro/di (their reads are all in
RETAINED_REL_TYPES); the flag-off list is untouched; and skipGraphPhases
still works independently.

Refs #2680

* fix(analyze): don't leak a temp dir when streaming is off; correct two overclaims

Three review findings, all verified before accepting.

1. `graphEmitCsvDir: resolveNativeSafeStorageDir(...)` was evaluated
   unconditionally inside the pipeline-options literal. On a Windows
   non-ASCII storage path that helper mkdtempSyncs a REAL directory, so
   every analyze leaked one temp dir even with the flag off. Now resolved
   only when streaming is active, matching how the PDG sibling resolves
   inside its own guard. This was the only finding affecting flag-off
   users.

2. The retain-set comment claimed 'the differential round-trip test is
   what catches drift'. It cannot. addRelationship PARTITIONS edges
   between the graph and the CSVs, and the union of a partition is
   invariant under where the partition line falls — so that test stays
   green no matter how RETAINED_REL_TYPES is drawn. Only the read-site
   audit protects the invariant, and the comment now says so and names
   the grep to re-run.

3. The ~2.9x figure assigned streamed edges a retained cost of zero,
   ignoring the sink's own streamedIds/streamedEndpoints Sets — and
   relationship ids are plain concatenations of both endpoint ids, not
   hashes. Review measured those Sets at ~35% of full per-edge retention,
   not the '~a tenth' assumed, putting the real figure nearer ~1.7-2.2x;
   a member-dense Java/C# repo lands lower still, since the retained
   structural spine is a larger share there than in the TypeScript census
   the 0.21 came from. Code comment and README now give a range and say
   plainly that no end-to-end measurement on a real repository exists yet.

Refs #2680

* fix(mcp): disclose degraded risk in detect_changes; stop pinning the sink

Two more review findings, both cross-lane corroborated.

1. detect_changes derives risk_level SOLELY from affected-process count,
   and a graphPhases:'skipped' index has zero Process rows by
   construction. The STEP_IN_PROCESS query then succeeds with zero rows,
   so queryDegraded stays false and the tool returns risk_level 'low',
   affected_count 0, with no partial marker — for every change, forever.
   That is a false-clean on the gate this repo mandates before every
   commit, and it is the same #2283 shape the previous commit fixed in
   impact() while leaving its sibling untouched. Now carries the same
   riskUnderstated + riskNote disclosure.

2. PipelineResult.graphEmitSink had zero readers — the pruner predicate
   and the manifest are both threaded elsewhere — but returning it kept
   the sink, and therefore its O(streamed-edges) id and endpoint Sets,
   reachable through the entire COPY/FTS/embedding phase. That is
   precisely the phase this feature exists to fit inside RAM, so the
   field actively worked against the change's purpose. Dropped.

Refs #2680

* refactor(2680): one named capability, one risk helper, a shorter header

Pure cleanup pass — no behaviour change, 66 tests across the six affected
suites still green, and the round-trip test still fails when the sink is
left un-started.

Three things were untidy:

1. The phase layer reached the sink through TWO loose callbacks bolted
   onto PipelineContext (`armStreaming`, `hasStreamedSemanticEdge`) —
   two fields, two wiring lines, no name for the thing they belonged to.
   Replaced by one `graphEmit?: GraphEmitControl`, a two-method interface
   declared beside the sink. Phases now say what they mean:
   `ctx.graphEmit?.beginStreaming()`. Also renames `arm()` to
   `beginStreaming()`, which needs no comment to explain.

2. The degraded-index risk disclosure was copy-pasted into impact() and
   detect_changes() — two meta probes, two near-identical prose blocks,
   and two long comments restating the same reasoning. Now one
   `streamedIndexRiskDisclosure()` helper carrying the explanation once;
   each caller passes only the clause naming which count is structurally
   zero for it. Same file, 45 lines in / 45 out, with the duplication gone.

3. The sink's file header had grown into a changelog of my own review
   corrections ('this once assumed', 'review measured'). A reader does not
   care what an earlier draft believed. Rewritten to state the design
   argument once — relationships are ~83% of graph heap, so they are what
   streams; nodes are the other 17% and are scanned, so they stay — under
   headings, with the honest 'this is an estimate, ~1.7-2.2x, no real-repo
   measurement yet' caveat kept in full.

Refs #2680

* feat(analyze): make streamed graph emit the default, with nothing traded away

Streaming was opt-in because it disabled the four phases that consume the
whole CALLS graph — communities, processes, taintSummaries, callSummaries.
That made it unshippable as a default: query() is process-grouped and
clusters/skill-gen are community-backed, so every index would have silently
lost them.

The sink now answers a COMPLETE relationship read. It keeps streamed edges
as four parallel columns over an interned node table — sourceId, targetId,
type, confidence — and iterRelationships/iterRelationshipsByType/
forEachRelationship/relationshipCount return the retained edges
concatenated with those. Every consumer therefore sees the whole graph and
no phase knows streaming happened.

Four fields, not six, because an audit showed community-processor,
process-processor, taint-summaries and the pruner read only those — none
keys on rel.id. That matters: relationship ids are unique long strings, and
retaining them is precisely what made a fully-columnar attempt LOSE to the
object graph (measured 838 MB vs 822 MB). Ids stay out of the columns; a
read synthesizes one, which is safe because buildRelRow never persists it.

Consequently deleted, not merely disabled:
- the four enabledWhen gates and the 'what you give up' warning;
- the pruner's hasStreamedSemanticEdge predicate and its plumbing — a
  complete scan sees streamed edges, so the dangling-edge hazard is gone by
  construction rather than by compensation;
- the whole degraded-index apparatus: the graphPhases RepoMeta stamp,
  streamedIndexRiskDisclosure, and the riskUnderstated markers on impact()
  and detect_changes(). Nothing degrades, so nothing needs disclosing.

Default is ON for full rebuilds; GITNEXUS_STREAM_GRAPH_EMIT=0 (or an
explicit option) is the escape hatch, for bisecting a suspected
streaming fault rather than routine use. Incremental runs still refuse it —
the writeback reads relationships back out of the in-memory graph.

Measured A/B, 400k nodes / 1.08M edges, all edges streamable (worst case
for this design): 823 MB -> 626 MB, ~1.3x, all 1.08M edges still visible.
That is deliberately less than the ~2.9x the retained-share formula
implies — losslessness costs the dedup Set and the columns. The earlier,
bigger number was bought by disabling phases. README and the file header
both state 1.3x measured; neither claims O(chunk).

New coverage: reads are complete (proven discriminating — 3 tests fail when
the streamed leg is removed), endpoints/confidence survive the round trip,
per-type lookup finds streamed types, and every CALLS-consuming phase stays
registered under the flag.

Refs #2680

* docs(2680): pin the invariants the default-on change relies on

Review follow-ups. No behaviour change except the id-uniqueness fix.

- pipeline.ts returns the RAW graph, not the sink, and that is load-bearing:
  phases read the sink so their scans are complete, but loadGraphToLbug feeds
  this value to streamAllCSVsToDisk, whose iterator would then emit every
  streamed edge a SECOND time on top of the per-pair CSVs the sink already
  wrote. Returning the sink there silently doubles every streamed
  relationship in the persisted graph, so the reason is now written down at
  the return site.

- Synthesized ids now carry the column index, making them unique even when
  two streamed edges share (type, source, target) and differ only in
  reason/step. Harmless today because no consumer keys on relationship id,
  but real ids are unique and the synthesized ones should match, so a future
  id-keyed consumer cannot silently collapse two edges.

- Recorded WHY dropping reason/step is safe, which is not the same argument
  as for id: the persisted row keeps their true values because buildRelRow
  receives the original relationship on the way through, so only in-memory
  reads see the 'streamed' placeholder. The ACCESSES reason:'read'|'write'
  distinction that MCP queries depend on therefore survives in the database.
  A future in-pipeline consumer needing either field must add a column rather
  than trust the placeholder.

Also verified while chasing a review lead: removeNodesByFile has no
production callers and removeNode has exactly one (the pruner), which reads
through the sink and so sees streamed edges. The dangling-edge hazard the
deleted hasStreamedSemanticEdge predicate used to compensate for is closed
by construction, not by luck.

Refs #2680

* fix(2680): fail loudly on a missing CSV dir, and guard the retain set

Resolves both findings from the review of this branch.

MEDIUM — pipeline.ts silently skipped streaming when `streamGraphEmit` was
true but `graphEmitCsvDir` was absent. The CLI always supplies the dir, but
streaming is on by DEFAULT now, and the callers that build PipelineOptions
themselves (eval-server, MCP daemon, tests) are exactly the ones that would
omit it — so they would ask for streaming, not get it, and still see a
successful run. That is the silent-degraded-outcome shape the rest of this
work exists to prevent, so it now throws with the resolution hint. Covered by
a test asserting the rejection.

LOW — RETAINED_REL_TYPES had no automated guard, and the round-trip test
structurally cannot be one: addRelationship PARTITIONS edges between the
graph and the CSVs, and a partition's union is invariant under where the line
falls, so that test stays green for any partitioning including a wrong one.
Drift there yields a silently incomplete mid-pipeline edge set, not a crash.
Added a test that derives the required set by grepping every literal
iterRelationshipsByType('X') under src/ and asserts the constant covers it,
with CALLS as the documented exemption (taintSummaries reads it, which is why
the sink answers a complete read rather than retaining it). Proven
discriminating: removing EXTENDS from the constant fails with
"expected [ 'EXTENDS' ] to deeply equal []".

128 tests green across the eight affected suites, including the index-lock
suite that arrived with the #2677 merge.

Refs #2680

* docs(2680): record the measured CPU cost, not just the memory win

I measured memory before shipping and never measured time, which was a gap:
reads now allocate, rebuilding objects instead of returning stored ones, and
a real analyze does SIX full relationship scans (pruner, communities x2,
processes x2, the taint fixpoint's CALLS pass).

Same 400k-node / 1.08M-edge graph:

  heap  820 MB -> 623 MB   (1.32x better)
  scans   96 ms -> 651 ms  (6.8x WORSE)

6.8x on iteration is worth knowing, but the absolute number decides it:
~0.5 s here, ~2 s extrapolated to kernel scale, against an analyze measured
in minutes — under 1% of wall-clock. The ~26M short-lived objects at kernel
scale are young-generation churn (the cheap case), and being ~800 MB further
from the heap ceiling matters more than the churn costs: #2649's cascade came
from GC thrash NEAR the limit, not from allocation volume as such.

Also names the first lever if these scans ever go hot — a per-type index over
the columns, so iterRelationshipsByType stops scanning all streamed edges —
and notes that it trades memory back, so it needs a measurement first.

Refs #2680

* perf(2680): cut the iteration regression from 6.8x to 1.8x

The memory win came with an unmeasured CPU cost. Iteration went from
returning stored objects to rebuilding them, across the SIX full relationship
scans an analyze performs (pruner, communities x2, processes x2, taint's CALLS
pass). First measurement: 90 ms -> 651 ms, 6.8x worse. Fixed properly rather
than documented away.

Two causes, each measured before and after:

1. The ~150-character synthesized `id` was built eagerly on every read — 6.5M
   concatenations per analyze, for a field NO in-pipeline consumer reads.
   Isolating it (constant id) showed 436 ms of the 555 ms regression. Now a
   lazy prototype getter on a fixed-shape `StreamedRelationship` class: the
   string is built only if someone asks, and V8 keeps one hidden class across
   millions of instances.

2. Generator and iterator-protocol overhead on million-edge walks.
   `forEachRelationship` (community detection's form, called twice) now loops
   the columns directly, skipping both. `iterRelationships` keeps an iterator
   but reuses one result record — a hand-rolled version allocating a fresh
   {value, done} per edge measured WORSE than the generator (252 ms), which is
   why the obvious rewrite is not the one that shipped.

  heap  821 MB -> 623 MB   (1.32x better)
  scans   90 ms -> 180 ms  (was 651 ms)

The residual ~90 ms is object allocation, 6.5M instances across six scans, and
it is irreducible while the read API returns objects at all. The remaining fix
for true parity is a field-wise callback passing sourceId/targetId/type/
confidence as primitives — all four hot consumers read only those — but that
changes the KnowledgeGraph interface and its consumers, so it belongs in its
own measured change rather than bolted on here.

Refs #2680

* perf(2680): zero-allocation field scan brings iteration back to parity

Third and final step on the iteration cost. The memory win had come with a
6.8x iteration regression; the previous commit cut that to 1.8x by making the
synthesized id lazy and removing generator overhead. The residual was object
allocation itself — 6.5M instances across the six full relationship scans an
analyze performs — which no amount of tuning removes while the read API hands
back objects.

So the hot consumers stop asking for objects. Adds
`KnowledgeGraph.forEachRelationshipFields`, which passes
(sourceId, targetId, type, confidence) as primitives — exactly and only what
every whole-graph scan reads. On the sink those come straight out of the
columns, allocating nothing; on the object-based graph they are read off the
stored relationship, so the flag-off path is unaffected.

Converted the five whole-graph scans: community detection (x2), process
extraction (x2), and the local-symbol pruner. `isFileDefinesEdge` now takes
(type, sourceId) rather than a relationship. The taint fixpoint's by-type pass
is left alone — one scan of six, and converting it would turn an indexed
bucket lookup into a full scan on the object-based graph.

  heap  820 MB -> 623 MB   (1.32x better)
  scans  ~82 ms -> ~90 ms  (was 651 ms; now parity within noise)

Also deletes the pruner's `hasStreamedSemanticEdge` option, which has had no
caller since the sink's reads became complete — a dead knob is worse than no
knob.

Verified: 104 tests across the eight affected suites, including the pruner's
pipeline integration test (which needs the raised worker-ready timeout on this
host; it passes cleanly with it and its failures are the known 5s handshake).

Refs #2680

* perf(2680): compact dedup keys — 1.32x -> 1.59x, speed unchanged

An audit of where duplicate relationship ids actually come from, then the
saving it unlocked.

The audit (instrumented analyze of this repo): 25 duplicate-id hits across
63,412 streamed edges — 0.04%, all CALLS, every one the SAME call site
re-emitted when a file is resolved in more than one language pass. Three
things follow, and they rule out the cheap options:

- dedup cannot be dropped (25 != 0, and a duplicate reaching COPY is a wrong
  graph);
- it cannot move to row contents, because emit-references builds ids as
  `...->target:line:col`, so two calls between the same pair at different sites
  have byte-identical CSV rows that the whole-graph emit keeps;
- it cannot move to a per-file source guard like `pdgEmittedFiles`, because a
  later language pass can resolve genuinely NEW edges for the same file.

What was left was the key itself. An id embeds both node ids in full (~200
chars here) while the endpoints are ALREADY interned for the columns, so the
Set was storing them twice. Keys are now built from the interner indices plus
the id's trailing disambiguator parsed into NUMBERS.

Numbers, not substrings, and that is load-bearing: a key built by slicing
inside a long string is a V8 sliced/cons string that keeps its parent alive, so
the id would never be freed and the saving would silently fail to appear. An
earlier attempt at this measured no improvement for exactly that reason.
Unrecognized id shapes (`rel:contains:` has no tail) fall back to storing the
id verbatim — correctness first, saving second.

  heap  821 MB -> 518 MB   (1.59x, was 1.32x)
  scans  ~83 ms -> ~88 ms  (parity, unchanged)

Speed is untouched by construction: dedup is on the WRITE path, and none of
the six full scans reads it.

Also fixes removeRelationship, which the test suite caught: it looked up the
raw id in a Set that now holds compact keys, so it silently stopped throwing on
an already-streamed edge. It cannot recompute a key from a bare id, so it is
now conservative — anything the real graph does not hold is treated as
possibly-streamed once streaming has begun and fails loudly. A genuinely-absent
id throws where main returns false; acceptable because the only production
caller (the COBOL resolver) runs before the sink is armed.

89 tests green across the six affected suites.

Refs #2680

* fix(2680): dedup key dropped edges when tail segment counts differed

Both findings from the review of this branch, and the coverage gap named
alongside them.

HIGH — the compact dedup key packed the id's trailing numeric segments as
`|${a}|${b}`, with `b` defaulting to 0 when only one segment was present and
the segment COUNT absent from the key. So `:7` and `:7:0` produced the same
key and the second edge was silently discarded as a duplicate: a lost
relationship, no error, no warning. Found by probe, not by reading — two
distinct ids for one (source, target, type) went in and one edge came out.
The key now carries `seen`.

Nothing existing caught it. The round-trip test compares the UNION of graph
and CSV rows, and a dropped edge is missing from both, so it stayed green;
the duplicate test only feeds a genuinely identical id, which is the case
that SHOULD collapse. Four new cases pin the boundary instead: differing
segment counts stay distinct, two call sites between one pair stay distinct
(the `:line:col` shape from emit-references), a truly repeated id still
collapses, and a non-numeric tail falls back to the full id. Proven
discriminating — reverting the fix fails with "expected 1 to be 2".

This costs ~66 MB at 400k nodes / 1.08M edges (584 MB, was 518 MB), so the
heap win is 1.40x rather than 1.59x. Not a trade worth making the other way:
a silently missing relationship is the exact failure class the rest of this
work exists to prevent. I am not asserting a mechanism for why two extra
characters per key cost that much — it is stable and reproducible across
runs, and inventing a cause is how I got the earlier cons-string diagnosis
wrong.

LOW — removeRelationship throws for an absent id once streaming has begun,
where KnowledgeGraph.removeRelationship returns false. The behaviour is
deliberate (a bare id cannot be turned back into a compact key, and answering
"false" for an edge already on disk is the worse failure) but it was
undocumented and untested. Now stated on the interface itself and pinned by
two cases: absent-id-while-streaming throws, absent-id-before-streaming
returns false.

Coverage gap — added a test asserting forEachRelationshipFields yields the
same (source, target, type, confidence) tuples as iterRelationships. That
guards the five whole-graph scans converted in 9fa18384, where a divergence
would silently skew community detection, process extraction and the pruner.

Also records the verified scaling in the file header: linear at 100k/200k/
400k/800k nodes, per-edge scan cost flat at ~13 ns in both arms, heap ratio
drifting only 1.7x -> 1.5x as interner indices gain digits. No super-linear
term.

135 tests green across the eight affected suites.

Refs #2680

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-07-25 07:43:51 +01:00
Gergő MagyarandGergo Magyar df0110b06f fix: index staleness — false-stale status after analyze (#2668) + inline staleness in query/context/impact/cypher tools (#2655) (#2683)
* fix(analyzer): case-stabilize runner-identity path fields so status isn't false-stale (#2668)

`gitnexus status` reported a freshly-analyzed, untouched repo as stale on
Windows (econia/aptos-core, 1.6.10-aptos.0). `status`'s up-to-date check gates
on `runnerIdentityIsCurrent`, which deep-compares the stamped runner identity
against a freshly recomputed one. That comparison includes `build.rootPath`,
`dependencyRuntime.manifestPath`/`lockfilePath`, and `runtime.executablePath`
(only `invokedArtifact` is stripped), and `identityCacheKey` hashes
packageRoot/buildRoot — all derived from paths that flow through
`realpathSync.native`, which canonicalizes 8.3 names and symlinks but does NOT
normalize the Windows drive-letter case. When `analyze` and `status` are
launched under different drive-letter casing (`c:\...` vs `C:\...`, plausible
across CLI shim / npx / server-worker entries), the two identities differ by
that one byte and `status` reports stale.

Fix: `normalizeAnalyzerRootPath(p, platform)` uppercases the Windows drive
letter (POSIX no-op, platform-explicit for testability; preserves a `\\?\`
extended-length prefix), applied at the single upstream source —
`resolveBuildRoot`'s returned `{packageRoot, buildRoot}` — so every derived
identity path field and the cache key inherit a case-stable root, plus at
`runtime.executablePath` (process.execPath is the same compared class). The
`runnerIdentityIsCurrent` gate is kept intact: a genuine analyzer change still
differs in `build.digest`/`dependencyRuntime`, and analyze still rebuilds on
real mismatch.

Note: the drive-letter divergence was not reproduced on a Windows host (none
available); the mechanical chain is verified in source and the fix is a correct
defensive normalization that is a no-op on POSIX. If a `status --json` identity
field-diff later shows `build.digest`/`dependencyRuntime`/`cliVersion`
diverging instead, that indicates a genuinely different install (where "stale"
is correct), not this bug.

Migration: on Windows, an existing index stamped under the old (non-normalized)
casing mismatches the normalized recompute once, triggering a single forced
full re-analyze on first upgrade (and a one-time identity-cache recompute).
One-time, Windows-only, POSIX no-op.

Tests: pure `normalizeAnalyzerRootPath` unit tests (drive-letter uppercase,
idempotence, drive-only scope, `\\?\` extended-length prefix, POSIX no-op).

* feat(mcp): surface index staleness in query/context/impact/cypher tool responses (#2655)

`checkStalenessAsync` already computes how many commits an index is behind the
checkout's HEAD, and `list_repos` returns it as `staleness: {commitsBehind,
hint}`. But the four hot read tools an agent actually calls in a session —
`query`, `context`, `impact`, `cypher` — never surfaced it: `resolveRepo` only
runs `maybeWarnSiblingDrift` (stderr, sibling-clone drift only), so a direct
tool call gave zero indication the index might be behind HEAD.

Thread the existing signal into those four tools at the single `callTool`
dispatch chokepoint (after the one `resolveRepo`), reusing the `list_repos`
`{commitsBehind, hint}` shape:

- `stalenessForTool` computes `checkStalenessAsync` behind an in-flight-promise
  cache (5s TTL) keyed by lbugPath, so N concurrent tool calls share one
  `git rev-list` and flat/branch handles (same repoPath, different lastCommit)
  don't collide. The cache entry is evicted with the repo's other per-index
  state when the repo leaves the registry.
- `withToolStaleness` skips the `git` spawn entirely for results that can't
  carry the field (via `canCarryStaleness`), so error-returning calls pay
  nothing.
- `attachToolStaleness` adds a `staleness` field to an object result only when
  the index is behind HEAD. It NEVER changes an existing result's shape:
  raw-array results (non-tabular cypher rows) are returned untouched, because
  the CLI's `--limit` and other consumers branch on `Array.isArray`; error
  envelopes and already-annotated results are left as-is. Non-blocking:
  `checkStalenessAsync` swallows git failures to `{isStale:false}`, so a git
  error just omits the field — it never fails the tool.

Deliberately out of scope: `@group`-targeted calls forward to
`callToolAtGroupRepo` before the chokepoint (multi-repo, single-commit
staleness is ill-defined); the legacy `search`/`explore` aliases; and
`list_repos` / the `context` resource, which already carry the signal.

Tests: `attachToolStaleness` branch matrix (stale object -> field; fresh ->
unchanged; raw array -> unchanged; error envelope -> unchanged; idempotent;
non-object -> unchanged; null-safe) and a flat-vs-branch cache-key regression
test that fails when the cache is keyed by repoPath.

* test(mcp): cover staleness tool-signal edge cases + harden the freshness boundary (#2655)

Addresses the coverage gaps the review flagged on the #2655 staleness signal,
plus one defensive guard so a failing freshness check can never fail a tool.

Production (defense-in-depth, no behavior change on the happy path):
- withToolStaleness now awaits stalenessForTool with a `.catch(() => undefined)`
  so a rejection degrades to no-staleness instead of failing query/cypher/
  context/impact.
- stalenessForTool wraps the check in `Promise.resolve(...).catch(...)` that
  evicts the cache entry on rejection — a transient failure isn't served as a
  permanently-rejecting promise for the rest of the TTL window, and the
  `Promise.resolve` wrap makes the boundary robust to a non-thenable return
  (a no-op for the real async checkStalenessAsync). A resolving promise is
  never evicted, so happy-path dedup is unchanged.

Tests (gitnexus/test/unit/calltool-dispatch.test.ts):
- F1: a rejecting checkStalenessAsync leaves the tool payload intact with no
  staleness field, and a later call recovers (proves the entry isn't poisoned).
  Written first and confirmed to fail without the guard.
- F2: staleness attaches on query/context/impact object results and on cypher's
  tabular {markdown,row_count}; a raw-array cypher result keeps its shape.
- F3: drift guard — exactly query/cypher/context/impact route through
  stalenessForTool; explain/pdg_query/detect_changes/check do not.
- F4: the per-index cache dedupes within TOOL_STALENESS_TTL_MS and recomputes
  after it expires (driven via a Date.now spy, not fake timers).

Tests (gitnexus/test/unit/analyzer-identity.test.ts):
- F5: the produced identity's build.rootPath and runtime.executablePath are
  normalizer-stable, guarding that both call sites thread through
  normalizeAnalyzerRootPath (trivial on POSIX, a real regression guard on
  Windows CI). Plus a source comment noting the one-time Windows re-analyze on
  first upgrade.

* test(mcp): run #2668 guard on Windows CI, document staleness field, cover staleness edge cases

Addresses the review follow-ups on the staleness work:

- Wire test/unit/analyzer-identity.test.ts into scripts/cross-platform-tests.ts
  (PLATFORM_LOGIC). Its "identity path fields are normalizer-stable" fixpoint is
  the Windows regression guard for the #2668 drive-letter normalization, but
  normalizeAnalyzerRootPath is a POSIX no-op, so the guard was only ever running
  (trivially green) on the Ubuntu full-suite and never on the windows-latest
  matrix where it actually bites. Now it runs where it matters.

- Document the inline `staleness` field on query/context/impact/cypher responses
  in the gitnexus-guide skill (both the .claude source and the shipped
  gitnexus-claude-plugin mirror, kept in sync).

- Add three staleness tests that pin behavior the prior tests only implied:
  * @group-routed calls never get the signal (forwarded before the wrapping
    switch) — locks the intentional skip so it can't silently flip.
  * one in-flight freshness check is shared across truly concurrent calls
    (two dispatched before checkStalenessAsync settles → a single spawn), not
    just sequential reuse of an already-resolved value.
  * a late rejection from a superseded cache entry does not evict the newer
    entry that replaced it after the TTL rolled over (the `=== entry`
    object-identity guard).

The defensive stack in stalenessForTool/withToolStaleness (Promise.resolve
wrap + guarded evict + outer catch) is retained deliberately: the wrap is
load-bearing for the tests (a sibling describe's vi.resetAllMocks() makes the
mock return undefined), and the guarded evict closes the superseded-entry edge
now covered above.

* fix(test): split the #2668 normalization guard into a portable cross-platform file

Registering analyzer-identity.test.ts on the Windows/macOS matrix (previous
commit) surfaced four pre-existing failures in that file on macOS 3/3 and
windows 3/3. They are not new breakage: those fixture tests compare identity
fields against the RAW temp-dir path while the identity resolves through
realpathSync.native, so on macOS `/var/folders/...` is received as
`/private/var/folders/...`. The file was simply never portable — it had only
ever run in the Ubuntu full-suite. Reproduced locally by pointing TMPDIR at a
symlink: the same four tests fail, and pass again without it.

Move only the portable assertions — the pure `normalizeAnalyzerRootPath` cases
(explicit `platform` argument) and the identity fixpoint guard (which compares
each field against ITSELF normalized, never against the fixture path) — into
test/unit/analyzer-identity-path-normalization.test.ts, and register that file
on the matrix instead. The #2668 Windows regression guard still runs where it
actually bites, without dragging four symlink-sensitive tests onto runners they
were never written for.

Verified: the new file passes with TMPDIR behind a symlink (the macOS
condition); the heavy file is back to Ubuntu-only.

* fix(test): keep the cross-platform #2668 file fixture-free so Windows stays green

The split file still carried the fixture-based fixpoint guard, which fails on
windows-latest:

  Invoked analyzer artifact is absent from the validated build:
    D:\a\...\node_modules\vitest\dist\workers\forks.js

Cause is a pre-existing cross-drive defect in this module's `isInside()`, not the
#2668 change. The GH Windows runner keeps the repo on D: and temp fixtures on C:.
`path.win32.relative('C:\\...fixture', 'D:\\...forks.js')` cannot express a
relative path across drives, so it returns the absolute target — which does not
start with '..', so `isInside()` reports true. `resolveInvokedArtifact` therefore
treats the vitest fork worker as the invoked artifact, it is absent from the
fixture's validated build, and identity resolution throws. (Verified directly:
`isInside` returns true cross-drive and false for the same-drive control.)

Keep the cross-platform file strictly pure — only `normalizeAnalyzerRootPath`
assertions with an explicit `platform` argument, no fixture and no filesystem —
so it is green on every runner while still exercising the transform on real
Windows. The fixture-based threading guard moves back to analyzer-identity.test.ts
(Ubuntu-only), where the rest of that file's fixture tests already live, with a
comment recording why it cannot be on the matrix.

The underlying `isInside()` cross-drive bug is left untouched here (out of scope
for this PR) but is worth its own fix: it also guards the trusted cache directory
and the identity-cache path-escape check in validateIdentityCache, where a false
"inside" verdict weakens validation on multi-drive Windows setups.

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-07-25 07:21:44 +01:00
jecanoreandGergő Magyar a500f70d6f feat(analyze): add opt-in --self-commit flag for AGENTS.md/CLAUDE.md churn (#2640)
* feat(analyze): add opt-in --self-commit flag for AGENTS.md/CLAUDE.md churn

Adds a new `--self-commit` flag to `gitnexus analyze`. When passed, any
AGENTS.md/CLAUDE.md changes the run makes (including first-time creation)
are auto-committed, scoped to only those two files (never `git add -A`).
No-ops silently if neither exists, neither changed, or the repo has no
git identity configured — never fails the surrounding analyze run.

Complements #1478 (--no-stats): that flag removes the volatile counts
entirely, this one keeps them but eliminates the dangling working-tree
diff they otherwise leave behind on every run.

Closes #2639.

* fix(analyze): log a warning when --self-commit fails to commit

Addresses review feedback on #2640: the commit step's catch block was
silently swallowing failures (e.g. missing git identity) with no signal
to the user. Logs via the existing pino logger (matching the rest of
the codebase's convention) with the error and the file list, while
still never throwing — analyze must not fail over this.

New test forces a real commit failure (missing identity, with
useConfigOnly + isolated HOME/XDG_CONFIG_HOME/GIT_CONFIG_NOSYSTEM so no
ambient global git config on the CI runner can mask it) and asserts the
warning is captured via logger's _captureLogger test hook.

* fix(analyze): refuse to sweep pre-existing edits into --self-commit

Addresses both state-safety blockers from review round 2 on #2640:

1. selfCommitContextFiles could not distinguish a pre-existing unstaged
   user edit in AGENTS.md/CLAUDE.md from this run's generated stats
   refresh — both just showed up as "the file is dirty" — so a user
   edit sitting in either file got silently swept into the generated
   commit. Fixed by snapshotting each candidate's cleanliness via the
   new snapshotSelfCommitSafety() BEFORE analyze writes to it; only
   files confirmed safe (nonexistent pre-run, i.e. first-time creation,
   or clean pre-run) are ever added/committed. A file already dirty
   pre-run is skipped and logged, never touched.

2. On a failed `git commit` (e.g. missing identity), the preceding
   `git add` had already staged the safe files, and analyze reported
   nothing happened while silently leaving them staged. Fixed with a
   `git reset -- <safe files>` in the commit-failure catch, restoring
   the index to its pre-add state for exactly the files this helper
   staged.

Wired analyze.ts to call snapshotSelfCommitSafety() once before
runFullAnalysis (which is where the actual AGENTS.md/CLAUDE.md write
happens, on both the fast path and the primary run), threading the
result through both existing selfCommitContextFiles() call sites.

New tests: a pre-dirty AGENTS.md is skipped while a clean CLAUDE.md
still commits normally, and a post-add commit failure leaves nothing
staged. Updated all existing selfCommitContextFiles() call sites for
the new required safety-map parameter.

* i18n(cli): add zh-CN translation for --self-commit help text

Addresses magyargergo's follow-up on #2640: --self-commit was missing
from the analyze command's OPTION_DESCRIPTION_KEYS map, so its help
text never went through localizeCliHelp and always rendered in English
regardless of locale. Adds the help.option.analyze.selfCommit key to
both en.ts and zh-CN.ts and wires it into help-i18n.ts, matching the
existing --no-stats/--skills entries.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-25 06:23:26 +01:00
Gergő Magyar 1e764cd475 fix(analyze): single-writer lock for the index write path (#2658) (#2677) 2026-07-25 05:08:13 +01:00
d3d4fa31bb fix(scope-resolution): gate C#/Kotlin free calls by instance ownership (#2563) (#2654)
* Initial plan

* fix(scope-resolution): gate C# and Kotlin free calls

* fix(scope-resolution): keep Kotlin ownership gate safe

* Apply remaining changes

* perf(scope-resolution): benchmark and cache ownership gates

* test(scope-resolution): simplify benchmark scaling loop

* refactor(scope-resolution): encapsulate ownership cache

* test(scope-resolution): enforce subquadratic ownership scaling

* fix(scope-resolution): address ownership review findings

* test(csharp): regenerate capture golden for #2563 fixtures

The committed expected-captures.json was missing the new
NamespaceOwnerCollision.cs entry and carried a stale SameFileCases.cs
digest/count (56 → 67), so csharp-captures-golden.test.ts was the sole
red check on the PR. Regenerate with UPDATE_GOLDEN=1 to match the
fixtures the bench fingerprint already reflects.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:31:56 +01:00
CopilotandGergő Magyar 450cebc268 fix(java): JLS binary-name identities for local classes, enums, records & interfaces (#2562) (#2653)
* Initial plan

* docs(plans): add Java local class naming plan

* fix(java): model local class binary names

* docs(java): clarify local class naming guards

* fix(java): recognize local classes in compact constructors

* chore: remove Java naming plan

* fix(java): harden local type identities and scope

* perf(java): linearize local type ordinal allocation

* fix(java): harden ordinal benchmark follow-up

* docs(java): clarify ordinal benchmark invariants

* test(java): cover local type ownership paths

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-24 11:58:53 +01:00
MyShining 4af6fe8587 feat(spring): resolve constructor and standard injection (#2632) 2026-07-24 08:25:38 +01:00
dependabot[bot] e34967eed5 chore(deps)(deps): bump express-rate-limit in /gitnexus (#2657)
Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.5.2 to 8.6.0.
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.5.2...v8.6.0)

---
updated-dependencies:
- dependency-name: express-rate-limit
  dependency-version: 8.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 06:48:07 +01:00
Abhigyan Patwari 91b22676ce Merge pull request #2488 from ArgonarioD/main
feat(cli): mirror skills to .agents/skills/ when .agents/ exists
2026-07-23 21:09:04 +05:30
Gergő Magyar bd9889cdec Merge branch 'main' into main 2026-07-23 15:04:24 +01:00
170805647c fix(rust): keep duplicate type names ambiguous in range binding (#2514) (#2652)
* fix(rust): latch duplicate type-name ambiguity in range binding (#2514)

The range-binding prepass tracked cross-file return and field types in two
maps and used map presence itself as the ambiguity flag: the second definition
of a name deleted it, but a third definition found it absent and re-inserted
the last-scanned file's type. Odd duplicate counts (3, 5, ...) therefore
resolved a genuinely ambiguous name to whichever file was scanned last, while
even counts stayed ambiguous.

Latch ambiguity in a dedicated Set per registry (ambiguousReturnTypes,
ambiguousFieldTypes): once a name has two or more workspace definitions it
never resolves again, regardless of duplicate count or file order.

Adds integration coverage for two/three-duplicate functions and structs,
permuted file order, and a unique-name over-suppression guard.

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

* fix(rust): bump INCREMENTAL_SCHEMA_VERSION to 12 for the #2514 range-binding fix

The duplicate-name ambiguity latch changes which cross-file Rust CALLS edges
the range-binding prepass emits. The incremental writeback persists only
changed-file nodes, so an incremental top-up against a pre-v12 index would keep
the old spurious edges on every unchanged Rust file. Bump the schema version to
force a one-time full re-analyze, matching the v7/v11 contract for
edge-affecting resolver changes.

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

* feat(rust): resolve import-disambiguated duplicate types in for-loops & destructuring

Follow-up to the #2514 ambiguity latch. When several modules define the same
function/struct name and a call site disambiguates it with a `use` import
(including aliases and `use x::*` globs), range-binding now resolves the
for-loop element type and the destructured field type to that specific imported
definition, instead of leaving it unresolved.

The bare-name return/field maps are (correctly) ambiguous for duplicates, but
the call site's import pins a definition. range-binding records the full,
untruncated return/field type per defining file, and resolveImportedDef()
resolves a name to the single in-scope definition, mirroring Rust name
resolution:

  - tier 1: explicit `use`/re-export imports and local defs (lookupBindingsAt);
    these shadow globs, so if any exist we decide within them alone;
  - tier 2: glob imports, consulted only when tier 1 is empty; a
    `wildcard-expanded` ImportEdge names the target module, so we resolve only
    when exactly one glob-target file actually defines the name.

Two or more visible definitions stay unresolved, preserving the #2514 latch.
normalizeRustReturnType is untouched (its Vec<T> -> Vec truncation is
load-bearing for receiver resolution), so the full generic is read from the
per-file map instead.

Covered by integration tests: explicit / aliased / single-glob imports resolve
to the imported definition; two globs that both export the name stay ambiguous;
a local definition shadows a glob; no-import duplicates stay unresolved (#2514).

INCREMENTAL_SCHEMA_VERSION stays at 12 (bumped by the #2514 commit in this PR);
its note now also covers these added resolution edges.

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

* perf(rust): parse each file once in range-binding when the workspace fits a budget

populateRustRangeBindings makes two passes over every file and, because the
shared treeCache is empty in the analyze flow, re-parsed each file in both — a
workspace of N files paid 2N parses. It now parses each file once and reuses the
tree across both passes via an in-function store, gated by a source-byte budget:
workspaces up to 16 MiB of Rust source (essentially every real repo) reuse
trees; larger ones fall back to per-pass re-parsing so peak RSS stays bounded on
huge repos (the memory-sensitive case keeps its current profile).

Also collapses the parse+timeout boilerplate that was copy-pasted in both loops
into one getOrParseTree helper, and adds a PROF-gated `rangeBind=` segment to
the scope-resolution profiler for phase-level observability.

Measured on a 500-file synthetic Rust workspace (PROF_SCOPE_RESOLUTION=1): the
range-binding phase drops ~370ms -> ~320ms (~14%), parses 1000 -> 500. Behavior
is unchanged (199 rust + range-binding-order + parse-timeout tests green); repos
above the budget are unaffected.

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

* test(rust): update schema-version gate to v12; regenerate golden + bench baseline for new fixtures

CI surfaced three deterministic-artifact failures, all from this PR's own additions:

- call-summary-schema-version.test.ts hardcoded INCREMENTAL_SCHEMA_VERSION === 11
  (the #2604 window); #2514 bumped it to 12. Update the gate and extend the
  reuse-gate version history so a v11 stamp now forces a full re-analyze.
- rust-captures-golden expected-captures.json drifted (130 -> 174 entries) because
  the new rust-import-* / rust-dup-* fixtures joined the rust-* corpus. Regenerated
  (UPDATE_GOLDEN=1): additions only, no existing captures changed — emitRustScopeCaptures
  is untouched.
- bench/scope-capture/baselines.json rust fingerprint drifted for the same reason.
  Rebaselined with a provenance note; scaling 1.06 < 1.5 budget.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:43:24 +01:00
Gergő MagyarandClaude Opus 4.8 76f9f70183 fix(cli): LadybugDB native-load failures fail closed, incl. truncated-binary SIGBUS (#2441) (#2651)
* test(cli): cover analyzer lazy-action native-load failure (#2441)

createAnalyzerLbugLazyAction — the wrapper the `analyze` command uses — had
only a happy-path test; its native-load-failure branch was untested, so a
regression could silently reintroduce #2441 (analyze exiting 0 after a
LadybugDB native load failure, writing no index while reporting success).

Add a failure-path test asserting that when checkLbugNative() reports the
binary cannot load, the analyzer module is NOT imported, process.exitCode is
set to 1, and the repair message is written to stderr. Mirrors the existing
createLbugLazyAction failure test.

Verified discriminating: the test fails ("expected undefined to be 1") when
the exitCode guard is removed from the analyzer branch, and passes with it
restored.

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

* fix(cli): probe LadybugDB native load out-of-process so a truncated binary fails closed (#2441)

checkLbugNative() loaded lbugjs.node in-process to validate it. That catches
clean load failures (missing dylib, zero-byte, garbage -> "file too short"),
but a merely truncated/corrupted binary (valid header, missing pages) SIGBUSes
the dynamic loader mid-dlopen — a signal, not a catchable throw — taking the
whole CLI down with a raw exit 135 and no guidance.

Load the binary in a throwaway child process instead. Only a child that RAN and
failed (non-zero exit or a fatal signal) marks the binary bad; if the probe
itself could not run — a spawn error or timeout, e.g. a no-subprocess sandbox
or a non-Node execPath — the result is inconclusive and the command's own load
stays authoritative rather than condemning a healthy binary. The probe forces
ELECTRON_RUN_AS_NODE, removes the redundant in-process pre-load, and costs ~20ms.

Regression tests: truncated binary -> ok:false; unspawnable probe -> ok:true.

Verified: a 300KB-truncated native now exits 1 with the repair message
(previously exit 135 SIGBUS); zero-byte/garbage stay graceful; good native
still loads and indexes.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:59:56 +01:00
Abhigyan Patwari 4f59831324 Merge pull request #2648 from abhigyanpatwari/dependabot/github_actions/softprops/action-gh-release-3.0.2
chore(deps): bump softprops/action-gh-release from 3.0.1 to 3.0.2
2026-07-23 14:24:38 +05:30
Gergő Magyar f37c126f0c Merge branch 'main' into dependabot/github_actions/softprops/action-gh-release-3.0.2 2026-07-23 09:23:26 +01:00
ArgonarioD f812f709b6 Merge remote-tracking branch 'upstream/main' 2026-07-23 16:14:32 +08:00
Abhigyan Patwari 437c2bb4b5 Merge pull request #2647 from abhigyanpatwari/dependabot/github_actions/actions/setup-node-7.0.0
chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0
2026-07-23 13:22:16 +05:30
ArgonarioD 39e9dc8b25 Merge remote-tracking branch 'upstream/main' 2026-07-23 14:50:09 +08:00
Abhigyan Patwari fc21e40b64 Merge pull request #2643 from abhigyanpatwari/dependabot/npm_and_yarn/gitnexus-web/lru-cache-11.5.2
chore(deps)(deps): bump lru-cache from 11.5.1 to 11.5.2 in /gitnexus-web
2026-07-23 11:56:12 +05:30
Gergo MagyarandClaude Sonnet 5 768161ceb2 fix(ci): sync review-agent workflow test with setup-node v7.0.0 pin
The dependabot bump to actions/setup-node@8207627860 (v7.0.0)
left the review-agent-workflow.test.ts pin allowlist pointing at the old v6.4.0 SHA, failing CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 06:00:27 +00:00
Abhigyan Patwari adaafa3c4f Merge pull request #2641 from abhigyanpatwari/dependabot/npm_and_yarn/gitnexus-web/vite-8.1.5
chore(deps)(deps-dev): bump vite from 8.1.4 to 8.1.5 in /gitnexus-web
2026-07-23 11:24:23 +05:30
Abhigyan Patwari 0d2cf725e7 Merge pull request #2642 from abhigyanpatwari/dependabot/npm_and_yarn/gitnexus-web/react-i18next-17.0.10
chore(deps)(deps): bump react-i18next from 17.0.8 to 17.0.10 in /gitnexus-web
2026-07-23 11:24:07 +05:30
Abhigyan Patwari ac163d76a7 Merge pull request #2645 from abhigyanpatwari/dependabot/npm_and_yarn/gitnexus-web/langchain/langgraph-1.4.8
chore(deps)(deps): bump @langchain/langgraph from 1.4.7 to 1.4.8 in /gitnexus-web
2026-07-23 11:23:06 +05:30
Abhigyan Patwari ebc281066f Merge pull request #2646 from abhigyanpatwari/dependabot/npm_and_yarn/gitnexus-web/babel/types-8.0.0
chore(deps)(deps-dev): bump @babel/types from 7.29.7 to 8.0.0 in /gitnexus-web
2026-07-23 11:22:52 +05:30
Gergő Magyar c145833518 Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/lru-cache-11.5.2 2026-07-23 05:55:33 +01:00
Gergő Magyar 28d50cb958 Merge branch 'main' into dependabot/github_actions/softprops/action-gh-release-3.0.2 2026-07-23 05:55:14 +01:00
Gergő Magyar a5b24f7bd8 Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/babel/types-8.0.0 2026-07-23 05:55:01 +01:00
Gergő Magyar 2c1bd0d74a Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/vite-8.1.5 2026-07-23 05:54:46 +01:00
Gergő Magyar 55fb0456c3 Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/react-i18next-17.0.10 2026-07-23 05:54:40 +01:00
Gergő Magyar 415d916bde Merge branch 'main' into dependabot/github_actions/actions/setup-node-7.0.0 2026-07-23 04:45:43 +01:00
Gergő Magyar cdbdf219dc fix(lbug): reclaim missing-shadow WAL quarantine files on write-path init (#2638) 2026-07-22 21:30:52 +01:00
dependabot[bot] e50c49949c chore(deps): bump softprops/action-gh-release from 3.0.1 to 3.0.2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.1 to 3.0.2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 20:18:38 +00:00
dependabot[bot] 47f3932c8c chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 20:18:29 +00:00
dependabot[bot] 450a22aaa5 chore(deps)(deps-dev): bump @babel/types in /gitnexus-web
Bumps [@babel/types](https://github.com/babel/babel/tree/HEAD/packages/babel-types) from 7.29.7 to 8.0.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v8.0.0/packages/babel-types)

---
updated-dependencies:
- dependency-name: "@babel/types"
  dependency-version: 8.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 20:14:44 +00:00
dependabot[bot] dbce222310 chore(deps)(deps): bump @langchain/langgraph in /gitnexus-web
Bumps [@langchain/langgraph](https://github.com/langchain-ai/langgraphjs/tree/HEAD/libs/langgraph-core) from 1.4.7 to 1.4.8.
- [Release notes](https://github.com/langchain-ai/langgraphjs/releases)
- [Changelog](https://github.com/langchain-ai/langgraphjs/blob/main/libs/langgraph-core/CHANGELOG.md)
- [Commits](https://github.com/langchain-ai/langgraphjs/commits/@langchain/langgraph@1.4.8/libs/langgraph-core)

---
updated-dependencies:
- dependency-name: "@langchain/langgraph"
  dependency-version: 1.4.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 20:14:38 +00:00
dependabot[bot] 51f8ec0a60 chore(deps)(deps): bump lru-cache from 11.5.1 to 11.5.2 in /gitnexus-web
Bumps [lru-cache](https://github.com/isaacs/node-lru-cache) from 11.5.1 to 11.5.2.
- [Changelog](https://github.com/isaacs/node-lru-cache/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-lru-cache/compare/v11.5.1...v11.5.2)

---
updated-dependencies:
- dependency-name: lru-cache
  dependency-version: 11.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 20:14:28 +00:00
dependabot[bot] 60a2267b1f chore(deps)(deps): bump react-i18next in /gitnexus-web
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.8 to 17.0.10.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.8...v17.0.10)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 20:14:22 +00:00
dependabot[bot] 16f3f01085 chore(deps)(deps-dev): bump vite from 8.1.4 to 8.1.5 in /gitnexus-web
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.1.4 to 8.1.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 20:14:16 +00:00
9538be957d fix(lbug): scale the buffer-pool budget by the OS page-size granule ratio (#2631) (#2636)
* fix(lbug): scale the buffer-pool budget by the OS-page discard-granule ratio (#2631)

LadybugDB bills buffer-pool budget per discard granule, not per 4 KiB frame:
the engine's vm_region.cpp sets discardGranuleSize = max(frameSize, osPageSize),
claimFrame charges the whole granule when its first frame becomes resident, and
releaseFrame refunds only when the granule's last frame leaves — while
BufferManager::reserve measures eviction progress in refunded bytes and throws
'The buffer pool is full and no memory could be freed!' after three zero-refund
passes. On a 64 KiB-page kernel (Ascend/aarch64 openEuler — the #2631
reporter's host) that is 16 frames per granule: the same COPY bills up to 16×
the budget it needs on x86, and whole eviction passes can evict frames yet
refund nothing. Apple Silicon macOS (16 KiB pages) is the same mechanism at 4×.

Measured with the reporter's exact command and version: vllm-ascend needs a
(128, 256] MiB pool on 4 KiB pages — 64/128 MiB reproduce the reporter's
byte-identical error, 256 MiB and the 576 MiB adaptive pool succeed — so their
64 KiB host cannot survive on a page-size-blind budget.

Scale every derived pool size by granuleRatio = max(1, osPageSize/4096):
the per-element estimate, the COPY-safety floor, and the default cap (still
bounded by 80% of RAM). 4 KiB hosts are byte-identical to before — proven by
pinning the existing sizing tests to an explicit 4096 page size, which also
stops them drifting on 16 KiB Apple Silicon runners. GITNEXUS_LBUG_BUFFER_POOL_SIZE
keeps absolute precedence and 0 still restores the native default.

Also: bufferPoolExhaustionRemedy() gives the exhaustion error an actionable
cause→consequence→remedy message; the isLbugPageSizeFrameError comment that
called pool exhaustion 'a sizing problem, not a page-size one' is corrected —
that framing inverted when #2582 made pool size a function of a page-size-blind
estimate. Cannot execute on a 64 KiB kernel here: the scaled path is proven by
unit stubs plus the engine-source math above; the env override remains the
field escape hatch.

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

* feat(cli): actionable pool-exhaustion remedies at the COPY sites and a doctor pool line (#2631)

The node-COPY throw and the relationship-COPY warning now append
bufferPoolExhaustionRemedy() when the failure is the engine's pool-exhaustion
class: the raw binder text gave the operator nothing to act on, and on
non-4K-page hosts the pool bills up to pageSize/4KiB × faster than the sizing
was calibrated for. The relationship path appends the remedy once per bulk
load, not once per failed pair. doctor prints the effective pool size next to
the page-size line ('pool size 2048 MiB', with an '(×N page-size scaling)'
suffix on non-4K hosts) so support triage sees the sizing inputs at a glance.

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

* refactor(lbug): re-anchor getEffectiveBufferPoolSize's placement and reuse granuleRatio in doctor

Self-review fixes: the getter's insertion had orphaned resolveBufferManagerSize's
doc comment (it read as documenting the wrong function), and doctor's scale note
duplicated the granule math with a hardcoded 4096. granuleRatio is now exported
(it already carried the test-seam default param) and doctor consumes it.
No behavioral change — the sizing suite pins byte-identical outputs.

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

* fix(lbug): keep the hintless pool default unscaled and make both remedies visible (#2631)

Review fixes:
- Scale only the analyze-path cap (scaledAnalyzePoolCap), not
  defaultBufferPoolSize: the pool is an eager native allocation at DB open
  (measured, see POOL_BYTES_PER_ELEMENT), so a page-size-scaled hintless
  default would hand a long-lived MCP process up to 80% of RAM — the #2557
  OOM exposure the 2 GiB cap removed. Fix the MAP_NORESERVE claim that
  contradicted that measurement.
- Log the rel-pair pool remedy (loadGraphToLbug returns warnings that no
  call site reads) and dedup it with a local boolean instead of matching
  the remedy's own wording.
- Label the GITNEXUS_LBUG_BUFFER_POOL_SIZE=0 sentinel as the native
  80%-of-RAM default in both the remedy and doctor instead of '0 MiB'.
- Extract poolSizeDoctorLine (pageSizeDoctorLines convention): mark env
  overrides, drop the scaling suffix that misdescribed absolute values.
- Fold _resetOsPageSizeCacheForTest into _setOsPageSizeForTests(undefined).
- Document the analyze-path scaling in both README env tables.

---------

Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:09:48 +01:00
Abhigyan PatwariandGergo Magyar 0eeecb37f3 fix(python): resolve calls through constructor-injected fields (#2628)
* fix(python): resolve calls through injected fields

* fix(ci): update python capture benchmark fingerprint

* fix(python): make constructor field inference conservative

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-07-22 16:32:53 +01:00
Gergő MagyarandClaude Fable 5 e814e28f1f fix(deps): bump @ladybugdb/core to ^0.18.3 — rel-property IN-predicate fix (#2508) (#2634)
LadybugDB ≤0.18.2 mis-evaluated `r.type IN [...]` on relationship table
groups: the boolean-filter fallback skipped writing selection buffers for
single-row unflat chunks, dropping/duplicating callers in context() and
impact() (upstream LadybugDB#692, fixed by LadybugDB#699, shipped in
0.18.3). Floor the dependency at ^0.18.3 and lock core + all five platform
packages.

Resurrect the caller-identity regression test from PR #2553 (closed as
superseded by the upstream fix): it pins context()/impact() to exact
caller IDs across CodeRelation sub-table pairs so any future predicate
regression fails loudly. Note: with CREATE-seeded data the test also
passes on 0.18.2 (the upstream repro needs COPY-written chunk layouts) —
it is a behavioural pin, not a bug reproduction.

Fixes #2508

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:31:56 +01:00
7f7255aef8 fix(analyze): load VECTOR before the incremental writeback touches embedding rows (#2623) (#2624)
* feat(lbug): add ensureEmbeddingRowDmlSafe VECTOR gate for embedding-row DML

LadybugDB refuses every mutation of a table carrying an HNSW index while the
VECTOR extension is not loaded on that connection: DELETE and CREATE raise a
Binder exception, DROP TABLE is refused while the index references it, and SET
segfaults the process. Dropping the index is not an available recovery either —
CALL DROP_VECTOR_INDEX is itself a VECTOR-extension function and is undefined in
exactly that state.

Add a single primitive that loads VECTOR under the analyze install policy and,
only when that fails, reads CALL SHOW_INDEXES (which works without the
extension) to decide whether an index actually exists to trip over. No call
sites yet.

Refs #2623

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

* test(lbug): pin the #2623 VECTOR gate for embedding-row DML

Three cases: no index + VECTOR unavailable stays safe (no needless
escalation); index present + VECTOR unavailable is reported blocked AND the
raw deleteNodesForFiles genuinely throws 'extension is not loaded' (proving the
hazard is real, not theoretical); index present + VECTOR loadable is safe, the
delete works, and the HNSW index survives — the invariant run-analyze relies on
when it keeps the index across a surgical incremental run.

Refs #2623

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

* fix(analyze): load VECTOR before the incremental writeback touches embedding rows

Incremental analyze died on every content change once a repo had built
code_embedding_idx:

  Analysis failed: Binder exception: Trying to delete from an index on table
  CodeEmbedding but its extension is not loaded.

The surgical writeback's first statement is deleteNodesForFiles' CodeEmbedding
join-delete, but nothing on that path loaded VECTOR until Phase 4 — so the
engine refused the delete. This is an ordering defect, not an environment one:
it reproduces on machines where VECTOR loads fine. The dirty-flag recovery then
forced a full rebuild on the next run, which is why it read as 'just slow'.

Call ensureEmbeddingRowDmlSafe() once, before the escalation gate and before any
row is touched — the same 'index lifecycle before row DML' seam dropSearchFTSIndexes
occupies for FTS (#2589). Unconditional, because a DB carrying the index from an
earlier --embeddings run hits the same wall on a plain incremental run. When
VECTOR truly cannot load the table is immutable (the index cannot be dropped
without the extension either), so the run falls through to the existing
wipe-and-COPY escalation with a message naming cause, consequence and remedy.

Fixes #2623

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

* test(analyze): pin the #2623 VECTOR-before-embedding-DML ordering end-to-end

Sibling of the #2589 FTS drop-before-delete suite, same shape: drive the real
runFullAnalysis incremental path over a real git repo and a real LadybugDB,
seed real embedding rows, build the HNSW index, then assert the index state at
the exact moment deleteNodesForFiles is invoked.

Both cases were confirmed to discriminate — with the run-analyze change
reverted they fail with the reported 'Trying to delete from an index on table
CodeEmbedding but its extension is not loaded', and pass with it:
  - surgical path: the run completes, the index is still present AND
    extension_loaded at delete time, exactly one row per nodeId survives, and
    the untouched file's rows are preserved
  - blocked path: with GITNEXUS_LBUG_EXTENSION_INSTALL=never the run escalates
    to a full DB write and says so, instead of crashing

Also applies prettier's reindent to the run-analyze log ternary.

Refs #2623

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

* docs(lbug): cite the pinned LadybugDB version in the #2623 probe note

The probe matrix behind ensureEmbeddingRowDmlSafe was first recorded on
0.18.0, but gitnexus/package-lock.json pins 0.18.2 (#2587). Re-ran every case
on 0.18.2: refused DELETE, refused CREATE, SIGSEGV on SET, DROP_VECTOR_INDEX
undefined, DROP TABLE refused, SHOW_INDEXES readable with extension_loaded
intact. Identical on both, so the design is unchanged — only the citation was
wrong.

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

* fix(analyze): preserve embeddings across the VECTOR-blocked rebuild, and check the catalog before loading

Three follow-ups from reviewing the fix itself.

1. Data loss on the blocked path. Escalating wipes the DB files, and Phase 3.5
   restores embedding rows from cachedEmbeddings — which deriveEmbeddingMode
   only populates when meta.stats.embeddings > 0. A DB holding embedding rows
   that its meta does not account for therefore had every vector destroyed
   silently by a rebuild it never asked for. Probe on a 3-file repo: 3 rows
   before, 0 after, no warning. Read the rows before escalating (a plain MATCH,
   no extension needed) so the existing restore has something to restore, and
   say so in the log. The blocked-path test now asserts the seeded rows survive
   exactly once, and that assertion fails without this rescue.

2. Catalog before extension. ensureEmbeddingRowDmlSafe loaded VECTOR first and
   only read SHOW_INDEXES on failure, so every incremental analyze on a machine
   without VECTOR paid a bounded out-of-process INSTALL attempt plus an
   'extension unavailable' warning — including repos that never built an
   embedding index and can never hit this bug. One local catalog read settles
   that case first; the load is attempted only when an index actually gates DML,
   or when the catalog cannot be read.

3. Dead branch. targetConn is always the module singleton there, so the
   isSharedSingletonConn ternary could never take its second arm. Collapsed to
   withConnLock.

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

* feat(doctor): live-probe the VECTOR extension instead of printing the static platform capability

Review finding on #2624 (MEDIUM), and exactly what #2623's reporter hit:
doctor printed 'VECTOR index: available' — derived from a static platform
check — while every incremental analyze on the same machine was dying on an
unloaded VECTOR extension. The FTS line was switched to a live LOAD probe for
the identical contradiction under #2374; VECTOR now gets the same treatment.

probeVectorExtensionLoad shares the FTS probe's implementation (bounded,
offline-safe, never runs the installer) and doctor's semantic-mode line now
follows the probe, not the platform: without a loadable extension the vector
index can be neither built nor queried, so search really is on exact scan.

The load-error classifier's remedies are label-parameterized so the VECTOR row
stops dispensing FTS-specific advice — 'run analyze --repair-fts' repairs FTS
indexes only and was actively wrong for a missing vector extension. Default
label stays 'FTS'; every existing caller and pinned remedy string is unchanged.

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

* fix(lbug): remove the stale Windows VECTOR gate — the extension ships for win_amd64

The codebase categorically refused VECTOR on Windows (platform !== 'win32' in
isVectorExtensionSupportedByPlatform, plus a hard early-return in
loadVectorExtension) on the strength of an early-era report that in-process
INSTALL VECTOR could SIGSEGV (#1365). That belief is stale, verified directly:

- the extension server hosts win_amd64 VECTOR artifacts for every 0.18.x
  extension version — v0.18.0 and v0.18.1 both serve a real 14 MB PE32+ DLL
  (curl-probed; 'file' confirms PE32+ x86-64)
- the pinned 0.18.2 core resolves its extension directory to 0.18.1
  (strace-verified LOAD open()), so the pinned version's Windows artifact
  exists too
- INSTALL now runs in a spawned child (installDuckDbExtensionOutOfProcess), so
  even a crashing installer kills only the child and degrades to unavailable —
  the original hazard cannot reach the parent process any more

Windows now takes the same runtime path as every other OS: try LOAD, install
out-of-process when policy allows, degrade to exact scan when it truly fails.
The MCP semantic-search lane loses its static platform gate too — it always
attempts the vector index and falls back to the exact scan on runtime failure,
with a once-per-backend diagnostic naming the real error instead of a
platform-policy message. isVectorExtensionSupportedByPlatform is deleted;
getRuntimeCapabilities reports the platform capability as available everywhere
and defers machine truth to the live probe.

Windows CI is the enforcement: the vector suites skip visibly only when the
extension genuinely cannot load, so green Windows lanes now actually exercise
VECTOR instead of silently skipping by policy.

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

* test(lbug): pin the catalog-read-failure fallback in ensureEmbeddingRowDmlSafe

Review finding on #2624 (LOW): the one branch where the gate cannot cheaply
prove safety — SHOW_INDEXES itself erroring — was exercised only by inference.
Force it with a Connection.prototype.query spy over the real DB: the catalog
read fails, and the gate must fall through to actually attempting the
extension load (asserted via the recorded statement stream) rather than
guessing, returning true here because the extension is loadable.

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

* fix(mcp): load VECTOR on the pool's shared Database so the semantic vector lane actually works

Review finding on #2624 (MEDIUM): extension load scope is per-Database
(probe-verified — LOAD on one connection enables QUERY_VECTOR_INDEX on every
connection of the same Database), and the pool pre-warm loaded only FTS. So
LocalBackend's vector lane has ALWAYS raised 'Catalog exception: function
QUERY_VECTOR_INDEX is not defined' through the pool and silently fallen back
to the exact scan — repos above the 10k exact-scan cap got empty semantic
results. The serve path was unaffected (the embedding pipeline loads the
extension itself).

Mirror the FTS line at BOTH load sites — doInitLbug's pre-warm and
initLbugWithDb's external-Database adoption — under the same load-only
contract (the read pool never triggers a network install), tracked by a new
SharedDB.vectorLoaded flag reset where ftsLoaded resets.

The new pool test is discriminating and deliberately closes the writable core
adapter before the pool opens: a shared/injected Database would inherit the
VECTOR load from test seeding and pass either way, so the case forces the pool
onto its OWN fresh read-only Database where only the pre-warm can make the
lane legal. Verified: fails at the pre-fix tree with the exact Catalog
exception, passes with the fix.

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

* ci: run the #2623 ordering suite on Windows/macOS and pre-install VECTOR alongside FTS

Two review findings on #2624, both landing in existing seams:

- scripts/cross-platform-tests.ts gains incremental-vector-extension-ordering
  .test.ts: the win32 VECTOR gate is gone in this PR, so the #2623
  drop-ordering + blocked-path escalation must be proven on the
  windows-latest native addon, not just Ubuntu. (The review's claim that
  lbug-delete-nodes-for-files.test.ts was also missing was wrong — it has
  been on the roster since #2409.)
- scripts/ensure-fts.ts now pre-installs VECTOR under the same best-effort
  auto-policy contract, so every sharded CI process LOADs from ~/.lbdb
  instead of racing its own bounded out-of-process INSTALL; the workflow's
  extension cache already covers it (path is the whole extension dir — key
  kept for cache continuity). The cross-platform job sets
  GITNEXUS_REQUIRE_VECTOR=1 beside GITNEXUS_REQUIRE_FTS so a genuinely
  unavailable VECTOR is a loud failure, never a silent skip.

Windows/macOS cannot be executed locally; the PR's CI lanes are the proof for
this commit. Linux smoke: ensure-fts.ts reports both extensions ready; all 79
roster entries resolve.

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

* test(pool): register loadVectorExtension in the pool unit-suite mocks

The pool adapter's new loadVectorExtension import surfaced in four suites that
mock lbug-adapter.js with explicit factories (vitest fails loudly on a missing
mocked export). Register the export in each — resolving false where the
suite's world assumes no vector, true where it mirrors FTS — and extend
lbug-pool-fts-load.test.ts, the suite that owns pre-warm extension loading,
with the vector pair: successful load cached per shared Database, failed load
retried on the next open, both pinned to policy load-only.

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

* test(analyze): use POSIX literals for graph paths in the #2623 ordering suite

First Windows CI run of this suite (it joined the cross-platform roster this
PR) failed with 'Parser exception: Invalid input <MATCH (n:Function) WHERE
n.filePath = '>' — path.join produces backslashes on Windows, and a backslash
inside the seed helper's single-quoted Cypher literal breaks the parser. The
graph stores repo-relative filePaths with forward slashes on every OS, so
graph-side paths are POSIX literals now (the incremental-orchestration
convention); path.join stays only for real filesystem access.

The same Windows lane also proved the substance this suite exists for:
lbug-vector-extension passed 7/7 on windows-latest — the extension installed,
loaded, and built a real HNSW index there — and the pool vector-lane and DML
gate suites passed too. This commit fixes the harness, not the fix.

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

---------

Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 12:27:00 +01:00
a84e029066 fix(eval): give vitest a writable .vite-temp inside read-only dependency mounts (#2630)
* fix(eval): give vitest a writable .vite-temp inside read-only dependency mounts

Every task verify command and every hidden oracle ends in `npx vitest run
<test>`, and both run through run_verify with read_only_workspace=True. Vite
transpiles a TypeScript config by writing
<node_modules>/.vite-temp/<config>.timestamp-*.mjs before it loads anything, so
against a read-only dependency mount vitest dies with EROFS before a single
test executes:

  EROFS ... /workspace/gitnexus/node_modules/.vite-temp/vitest.config.ts.timestamp-*.mjs

This is pre-existing and was masked: until #2627 the verify command died at
`npx: not found`, short-circuiting the `&&` chain before vitest ran. Confirmed
by reproducing it at that merge base with npx bypassed entirely
(`./node_modules/.bin/vitest`), so it is independent of the node-prefix mount.
Because it blocks the oracle as well as the authored-test verify, `resolved`
stays 0/N without this.

bwrap cannot create a mount point inside an already-read-only bind -- the same
constraint that put SANDBOX_NODE under /opt/claude -- so overlaying a tmpfs only
works if the directory already exists in the mounted bytes. It cannot be
mkdir'd into the dependency snapshot after capture either: the snapshot is
digest-bound and validate_dependency_binding fails closed on drift. So the empty
directory is captured during dependency capture, before the manifest and both
dependency digests are computed, making it part of the snapshot rather than an
untracked mutation of it. The sandbox then overlays a tmpfs on exactly that
path; everything else in the mount, and the whole workspace, stays read-only,
and the overlay never reaches the host clone the credited patch comes from.

Scoped to dependency mounts whose target basename is node_modules, so hidden
oracle and skill mounts stay wholly read-only with no writable island.

Note: this shifts sandbox_dependency_content_digest and
sandbox_dependency_manifest_digest, so promotion evidence recorded before this
change is no longer comparable. That is already true of any harness fix that
changes what the sandbox exposes.

Verified on the self-hosted runner through the real path -- TaskAssetCache
.prepare -> stage_task_assets -> prepare_sandbox -> run_verify with the actual
trivial-version-alias verify string: passed, 15/15 tests, no EROFS. Full eval
suite there with GITNEXUS_REQUIRE_BWRAP_CANARY=1: 337 passed, 4 skipped.

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

* fix(eval): only overlay .vite-temp where the mount source actually carries it

The tmpfs overlay keyed purely on the mount target basename being
node_modules, which also matched the trusted GitNexus runtime mount at
/opt/gitnexus/node_modules. That mount's source is the built runtime and does
not carry a .vite-temp, and bwrap cannot create a mount point inside an
already-read-only bind, so the containment CI job failed:

  bwrap: Can't mkdir /opt/gitnexus/node_modules/.vite-temp: Read-only file system
  FAILED test_real_bubblewrap_runtime_mount_imports_cli_without_exposing_checkout

My runner probe only exercised the dependency-mount path, so it missed this.

Gate the overlay on the mount SOURCE actually containing the directory rather
than on the target name. task_assets.py captures .vite-temp only into
dependency-snapshot node_modules, so the overlay now fires exactly there and
never on the runtime mount -- and the gate is correct by construction, since a
tmpfs can only overlay a mount point that already exists in the bound bytes.

Adds a regression test for a node_modules mount whose source has no captured
.vite-temp (the runtime-mount shape) getting no overlay, and updates the
positive test to create the directory in its mount source.

Verified on the self-hosted runner: the exact failing test now passes, and the
full containment selection is 124 passed, 4 skipped.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:16:25 +01:00
dependabot[bot] 735289e399 chore(deps)(deps): bump fast-uri from 3.1.2 to 3.1.4 in /gitnexus (#2626)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 08:27:11 +01:00
13095bc4bc fix(eval): mount the node prefix for npx and catch nested Claude Code bootstrap noise (#2627)
* fix(eval): ignore Claude Code bootstrap noise nested below the workspace root

The planning-phase boundary check excluded Claude Code's own sandbox-bootstrap
paths only at the workspace root: workspace_snapshot tested relative.parts[0]
against WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE. But Claude Code bootstraps into
whatever directory it is running in, and the benchmark's task prompts cd into
gitnexus/, so the same noise landed one level down as
gitnexus/.claude/.cc-writes -- whose parts[0] is "gitnexus", so it was never
excluded.

In skill-evolution run 29861768554 that accounted for 13 of 18 sessions, each
failing with error_kind plan-evidence-invalid and the identical error_detail
"phase changed unauthorized workspace path(s): gitnexus/.claude/.cc-writes".
The same code path also guards the review phase (runner.py:499), so review arms
hit it as review-evidence-invalid.

Widening the whole set to match at any depth would be wrong: it also contains
package.json, package-lock.json, node_modules and the .env family, and both
gitnexus/package.json and gitnexus/.claude/settings.local.json are real tracked
files whose edits must still be caught. So the root-anchored rule is unchanged,
and a second narrow rule matches only the entries Claude Code itself creates
inside a .claude directory (.cc-writes, agents, commands) at any depth -- never
.claude itself.

The predicate moves into _is_bootstrap_noise so it is directly testable. It is
still evaluated before pending.append, so an excluded directory is never
descended into.

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

* fix(eval): mount the node install prefix so npx and npm resolve in the sandbox

_runtime_mount_args bound only the `node` binary itself to SANDBOX_NODE. npm
and npx are not standalone binaries -- they are symlinks into
../lib/node_modules/npm/bin/*-cli.js -- so the install prefix carrying both
bin/ and lib/node_modules has to be mounted for them to resolve at all.

On GitHub-hosted images node lives in /usr/local/bin, whose prefix (/usr/local)
is already inside the wholesale /usr read-only bind, so npm and npx came along
for free and the gap stayed invisible. A self-hosted runner's actions/setup-node
installs into its own tool cache, outside /usr, so only the single node file was
bound. Every task's verify command is "cd gitnexus && npx tsc --noEmit && npx
vitest run <test>", so in skill-evolution run 29861768554 all 18 of 18 result
records carried the identical verify_output "/bin/sh: 1: npx: not found" -- no
run could resolve regardless of model output. It reached the model too: the
session transcripts show 12 "npm: not found" failures, with
gitnexus/scripts/build.js dying on `npm ci` with status 127.

Binds Path(node_bin).resolve().parent.parent read-only at /opt/claude/nodejs,
a fresh target outside the already-read-only trees (same constraint that put
SANDBOX_NODE under /opt/claude), and adds its bin/ to SANDBOX_PATH. The bind is
skipped when the prefix already sits inside /usr, /bin, /lib or /lib64, so the
already-covered case does not widen the mount surface redundantly.

SANDBOX_NODE is deliberately unchanged -- sanitized_graph.py and
runner_sessions.py invoke it directly. SANDBOX_PATH is now derived from
SANDBOX_NODE_PREFIX so the two cannot drift, and the minimal-mounts probe
asserts against the constant instead of a duplicated literal.

The real-Bubblewrap npx canary lives in test_proposer_sandbox.py deliberately:
test_workflow_bench.py pins the set of files carrying the canary marker, and it
runs in the eval-containment-linux job, where actions/setup-node also installs
into the tool cache -- so the canary exercises the real failure shape.

Combines plan steps 3-5 into one commit: the mount, SANDBOX_PATH and the pinned
probe assertion are one behavioural change, and splitting them would leave a
commit whose asserted PATH disagrees with the mounted reality.

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

* fix(eval): only bind a verified node prefix, and stop excluding .claude/agents

Addresses two findings from the branch review of the two preceding commits.

1. The prefix was derived as Path(node_bin).resolve().parent.parent with no
   check that the layout is really <prefix>/bin/node. Probed: /opt/bin/node
   bound ALL of /opt (every tool cache on a hosted runner), /mnt/tools/node
   bound /mnt, and a bare <dir>/node bound <dir>'s parent. That last shape is
   not hypothetical -- the pre-existing real-Bubblewrap node canary builds
   exactly it (tmp_path/toolcache/node), so eval-containment-linux would have
   silently read-only mounted the whole pytest tmp_path inside a containment
   test, passing while doing it. This function exists to keep the sandbox
   surface minimal, so an unrecognized layout now binds nothing extra and
   simply leaves npx unavailable, exactly as before the mount was added.

2. CLAUDE_BOOTSTRAP_ENTRIES also excluded "agents" and "commands" on the theory
   that they might appear nested too; only .cc-writes ever was observed. Every
   excluded name is a blind spot: once a .claude directory exists
   (gitnexus/.claude/settings.local.json is tracked) anything written under an
   excluded entry is invisible to the phase-boundary check, and Claude Code
   loads .claude/agents relative to its cwd -- which these tasks point at
   gitnexus/. Probed: a planning phase could plant
   gitnexus/.claude/agents/planted.md with the check reporting nothing, then
   the work phase reads it. Narrowed to .cc-writes alone; extend the set from
   an observed failure, never pre-emptively.

Re-probed after both fixes: the over-broad mounts are gone while a genuine
tool-cache prefix carrying npm still binds; planted agents/commands content is
caught again; gitnexus/.claude/.cc-writes (the real run-29861768554 failure)
stays ignored; and edits to gitnexus/.claude/settings.local.json are still
caught.

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

* fix(eval): gate the node-prefix bind on a working npx, not on an npm directory

The guard tested (prefix)/lib/node_modules/npm as a proxy for "this prefix
supplies npx". Test the property actually required instead: a working npx
sitting beside node in a real bin/ directory. .exists() follows the symlink, so
a dangling npx correctly fails the check -- it would not survive the mount
either. The "bin" name requirement stays, because it is what keeps the
parent.parent derivation honest; an npx sitting directly beside node in a flat
directory would make that derivation name the wrong prefix.

This matters because the guard can silently disable the fix it guards: if a
runner's layout failed the proxy check, the prefix would not be bound and npx
would still be missing, reproducing the original failure with no signal.
Testing npx directly means the guard can only pass when the bind will actually
achieve its purpose.

Validated against a real extracted Node distribution (the official nodejs.org
tarball layout that actions/setup-node unpacks into the tool cache) staged at a
tool-cache-shaped path: bin/node is a real file, bin/npx resolves to
../lib/node_modules/npm/bin/npx-cli.js, and the prefix binds while SANDBOX_NODE
is preserved.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:25:46 +01:00
c35403b59d chore(deps)(deps): bump js-yaml from 4.3.0 to 5.0.0 in /gitnexus (#2618)
* chore(deps)(deps): bump js-yaml from 4.3.0 to 5.0.0 in /gitnexus

Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 5.0.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...5.0.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(spring-config): migrate YAML parsing to js-yaml 5 event API

js-yaml 5 removed the loadAll `listener` callback, the EventType/State
types, and DEFAULT_SCHEMA that spring-config relied on, breaking the build.

Rebuild the per-key line tree from parseEvents()/constructFromEvents()
(positions are source offsets → mapped to lines), apply the `<<` merge tag
via CORE_SCHEMA.withTags(mergeTag) (CORE alone leaves merge keys unexpanded),
and resolve aliases by anchor name, which lets the object-identity WeakMap go.

Behavior preserved: 9 unit + 8 integration spring-config tests pass, including
merged-key declaration-line, cyclic-alias termination, and the depth budget.

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

* fix(spring-config): restore v4 tag coverage, cover the v5 rewrite with tests

Review follow-up for #2618.

CORE_SCHEMA.withTags(mergeTag) was a narrowing, not a port: js-yaml 5
throws "unknown tag" on !!timestamp/!!binary/!!set/!!omap/!!pairs, and an
unknown tag aborts the whole parse, which readConfigKeys swallows — so an
application.yml using any of them would have gone from its full key set to
zero keys, silently. Carry the rest of what DEFAULT_SCHEMA was; none of
these tags can execute code.

Add tests for every path the review flagged as uncovered: multi-document
files, empty/comment-only/bare-`---`/bare-scalar documents, sequence-form
merge keys, and explicitly tagged values (which fail against the one-tag
schema, so they target the changed line).

Clear the anchor map per document. It cannot change output today —
constructFromEvents rejects a cross-document alias before the event tree is
built, now asserted — but it keeps both layers on YAML's scoping rule.

Drop the stale @types/js-yaml devDependency; js-yaml 5 ships its own types
and tsc --noEmit is clean without it. Lockfile hand-edited because npm
uninstall also strips every libc field.

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

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(spring-config): flatten !!set members, walk YAML iteratively

Review follow-up for #2618.

js-yaml 5 constructs `!!set` as a native Set; v4 built a plain
`{member: null}` object. Object.entries of a Set is empty, so a tagged set
collapsed to a bare leaf key and lost every member. Enumerate the Set
instead. Sets arrive as mapping events with key/value scalar pairs, so
member lines resolve through the usual lookup. !!binary and !!timestamp are
unaffected — both are scalar events and take the leaf path, which is why a
Uint8Array never explodes into one key per byte.

Convert findYamlMappingLocation and flattenYamlValue from recursion to an
explicit stack. Children are pushed in reverse so pops happen in
declaration order, preserving "first match" and `out` insertion order;
`leave` frames release the cycle guard where the old `finally` did. The
depth budget still throws at the same boundary with the same message.

Cover the gaps the review named: !!pairs (both duplicate entries survive),
anchor-name reuse resolving to the nearest preceding declaration, and
marker-only leading documents staying index-aligned across the two streams.

buildYamlEventTree keeps no node budget by design — one node per event over
an already-materialized array, bounded by MAX_CONFIG_FILE_BYTES. The
docstring now says so rather than implying MAX_YAML_TRAVERSAL_NODES covers
it.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-22 07:50:51 +01:00
ArgonarioDandClaude 6150a793e8 docs(cli): mention .agents/skills/ mirror in --skip-skills help + test
Address review finding (LOW — docs/help staleness): the --skip-skills help
text and README omitted that skills also mirror to .agents/skills/ when
.agents/ exists.

- index.ts + i18n (en/zh): --skip-skills now reads "directly under
  .claude/skills/ and .agents/skills/".
- skip-git-cli.test.ts: assert the help text covers .agents/skills/.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 11:24:30 +08:00
ArgonarioD 38d0256474 Merge remote-tracking branch 'upstream/main' 2026-07-22 11:00:10 +08:00
dependabot[bot] 7bcf35c3f5 chore(deps-dev): bump the npm_and_yarn group across 1 directory with 2 updates (#2621)
Bumps the npm_and_yarn group with 2 updates in the / directory: [brace-expansion](https://github.com/juliangruber/brace-expansion) and [js-yaml](https://github.com/nodeca/js-yaml).


Updates `brace-expansion` from 1.1.13 to 1.1.16
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.13...v1.1.16)

Updates `js-yaml` from 4.2.0 to 4.3.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.0)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.16
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 22:43:34 +01:00
dependabot[bot] 7e6a4ef3e8 chore(deps)(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#2620)
Bumps the npm_and_yarn group with 2 updates in the /gitnexus-web directory: [dompurify](https://github.com/cure53/DOMPurify) and [fast-uri](https://github.com/fastify/fast-uri).


Updates `dompurify` from 3.4.11 to 3.4.12
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

Updates `fast-uri` from 3.1.2 to 3.1.4
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 22:43:11 +01:00
dependabot[bot] 50b0f2f775 chore(deps)(deps): bump hono from 4.12.26 to 4.12.31 in /gitnexus (#2619)
Bumps [hono](https://github.com/honojs/hono) from 4.12.26 to 4.12.31.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.26...v4.12.31)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.31
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 22:42:44 +01:00
dependabot[bot] 2d4e24811e chore(deps)(deps-dev): bump @types/node in /gitnexus (#2617)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.0.0 to 26.1.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 21:58:44 +01:00
Abhigyan PatwariandGergő Magyar 9efc6bfcad fix(lbug/analyze): atomic index swap + read-pool staleness invalidation (#2614)
* fix(lbug): re-open the read pool when analyze rebuilds the index under it

The MCP read pool's initLbug early-returned on an existing pool entry with no
freshness check, so after analyze rebuilt or mutated the on-disk index the
pool kept serving the old (POSIX: unlinked-but-open) inode until LRU/idle
eviction — a silent stale-read window of up to IDLE_TIMEOUT_MS (5 min).

Record the file identity {ino, mtimeMs, size} on each PoolEntry at open, and
re-stat in initLbug: unchanged → reuse; changed & idle → closeOne + reopen the
new file; changed while a query is in flight → serve the current handle (a
later idle initLbug reopens, since closing an in-use connection is a native
use-after-free). A stat failure (ENOENT during a full rebuild's unlink window)
is treated as unchanged so the reader keeps its valid open inode until the new
file appears. Mirrors the bridge cache's mtime-invalidation pattern.

Step 1 of docs/plans/2026-07-21-...-analyze-atomic-swap-invalidation. The
end-to-end reopen-on-swap path is exercised by the reader-during-rebuild
integration test in a later step.

* fix(analyze): publish a full rebuild via an atomic swap (POSIX)

The full-rebuild path wiped the live index (wipeLbugDbFiles(lbugPath)) and
rebuilt it in place, so a concurrent MCP reader that opened mid-build could
see an empty/half-loaded DB, and a crash between the wipe and the end-of-run
left the index destroyed (recoverable only by --force).

Build the fresh index at <lbugPath>.new and swap it over the live index in one
atomic rename at the end. All DB work flows through the singleton connection,
so only initLbug/wipeLbugDbFiles take the temp target; the close already
checkpoint-consolidates the build to a single file (verified: no residual
.wal/.shadow), so the rename publishes a complete index in one step. A reader
opening mid-build only ever sees the previous complete index; a reader holding
the old inode keeps a consistent stale snapshot until the pool re-opens onto
the new one (the pool staleness invalidation from the prior commit). On
failure the swap is skipped, leaving the previous index byte-for-byte intact.

POSIX only: the common CLI/serve-worker analyze paths skip the native close
(closeLbugBeforeExit, #2264) and leave the build handle open at swap time.
POSIX renames an open file cleanly; a same-process open handle blocks the
rename on Windows, so Windows keeps the current in-place behavior
(buildPath === lbugPath) until that is resolved. The Windows atomic swap and a
deterministic concurrent reader-during-rebuild test are deferred follow-ups.

Steps 2b + partial 3 of docs/plans/2026-07-21-...-analyze-atomic-swap-invalidation.
Integration test asserts the no-temp-leak + inode-swap invariants and the
crash-safety guarantee (a load failure leaves the live index untouched).

* test(analyze): end-to-end read-pool reopen after an atomic swap

Adds the deferred reader-during-rebuild / pool-reopen integration test:
analyze v1 -> read pool serves it -> rebuild with a renamed function (atomic
swap) -> the same repoId's initLbug detects the swapped inode and re-opens the
pool onto the new index. Asserts the pool sees the renamed function and NOT the
stale v1 name, exercising #1 (invalidation) and #2 (swap) together end to end.

* fix(lbug): bound pooled read queries with setQueryTimeout

The read pool relied only on a JS-side Promise.race (QUERY_TIMEOUT_MS) that
frees the waiter but leaves the native call running. Set the engine-level
setQueryTimeout on every pooled connection so a pathological query is bounded
at the source too.

* fix(lbug): name the held-open cause for WAL checkpoint failures (#2599)

A WAL-checkpoint IO error that also carries a busy/lock signal means another
handle (a gitnexus mcp server, or this process's own reader) holds the store
open, not a disk fault. Add isLbugCheckpointBusyError (reusing the tested
isDbBusyError keyword set) and, when the checkpoint driver exhausts its retry
budget on such an error, annotate the surfaced error with the actionable
held-open cause instead of a raw IO string.

Note: overlaps in-flight work on repro/issue-2599-windows-wal-checkpoint;
bundled here at the maintainer's request.

* feat(analyze): opt-in atomic incremental + best-effort Windows swap

Extends the atomic-swap publish (POSIX full rebuild) to two more cases:

- Windows: the swap now applies when a real close is safe to release the build
  handle before the rename — i.e. non-pdg runs (windowsSwapOk excludes --pdg,
  the #2264 destructor-crash case), forcing a real close on the swap path.
  UNVERIFIED on Windows (no Windows runner here); --pdg and any failure fall
  back to today's in-place behavior, so it can never corrupt.

- Incremental (opt-in, GITNEXUS_ATOMIC_INCREMENTAL=1): copies the live index
  into the temp, applies the incremental delete/writeback to the copy, and
  swaps at the end. Off by default because the whole-file copy negates
  incremental's speed premise — kept behind a flag pending a benchmark. The
  escalation valve also targets the temp so an escalated write stays atomic.

Integration test covers the opt-in incremental path end to end (no temp leak,
the incremental change is reflected after the swap).

* refactor(lbug): centralize the read-pool + bridge open-retry budgets

The lbug-config retry registry documented the open/handle-release/query-time
budgets but the read pool's LOCK_RETRY_* (pool-adapter) and the bridge's
LBUG_OPEN_RETRY_* (group/bridge-db) kept private copies that could drift. Move
both into the registry as exported constants (POOL_OPEN_LOCK_RETRY_*,
BRIDGE_OPEN_RETRY_*) and alias the local names to them — one tuning surface,
no behavior change.

* fix: address CI regressions from the bundled follow-ups

- setQueryTimeout: guard the call so test doubles that don't model the engine
  method don't break connection creation.
- atomic swap: skip the rename when the build produced no DB at buildPath (an
  empty repo / mocked pipeline) instead of throwing ENOENT.
- #2599: don't wrap the checkpoint error in the driver (it hid the IO signature
  the CLI's --wal-checkpoint-threshold hint keys on); name the held-open cause
  at the CLI instead, beside that hint, keeping the original error intact.
- retry consolidation: revert to documentation-only — moving the pool/bridge
  budgets into lbug-config broke every explicit lbug-config test mock. The
  registry now catalogues all budgets with their in-file locations.
- analyze-wal-checkpoint-failure test: block both lbug.wal.checkpoint and
  lbug.new.wal.checkpoint, since a full rebuild now checkpoints the temp.

* fix(analyze): publish the swap before stamping meta; identity-gate the reader (#2614 F1)

Review found a HIGH regression: the full-rebuild wrote the freshness stamp
(saveMeta, indexedAt=T_new) BEFORE the atomic swap, so a concurrent MCP reader
that reinited in the saveMeta->swap window opened the OLD inode, recorded
observed=T_new, and then never reinited again (ensureInitialized returns early
on 'current') — serving the pre-rebuild graph indefinitely. The build-into-temp
change inverted the pre-PR invariant that 'meta shows T_new' implied 'lbugPath
holds T_new data'.

Two coordinated fixes:
- run-analyze: move the final saveMeta AFTER the swap, so meta.indexedAt only
  becomes visible once lbugPath resolves to the new inode. Verified nothing in
  the span reads on-disk meta and registerRepo writes only the registry.
  Leaving the dirty flag set across the swap also improves crash-safety.
- local-backend: the reader staleness gate now also compares the lbug file
  IDENTITY (ino/mtime/size), reiniting on an inode change even when
  meta.indexedAt is unchanged. This closes the swap-window latch and covers the
  in-place incremental case — and is what actually makes the pool's dbIdentity
  net reachable for the MCP reader (the indexedAt gate otherwise bypassed it).

* fix(lbug/analyze): WAL-aware incremental, residual-sidecar reconcile, Windows opt-in, #2599 anchor (#2614 F2-F4)

Review remediations:
- F3: gate atomic incremental on a CLEAN live index (inspectLbugSidecars) — the
  main-file-only copy would drop an orphan .wal's delta; fall back to in-place.
- F4: on the swap, MOVE a residual <buildPath>.wal/.shadow beside the published
  index (not orphan it) so a swallowed final checkpoint's delta is replayed.
- F2: record identity on the shared read-only Database and warn when a cached
  handle is reused after its on-disk index was rebuilt while another consumer
  holds it (unreachable via MCP — one consumer per lbugPath; a complete fix
  needs per-inode handles, documented).
- Windows swap: opt-in (GITNEXUS_ATOMIC_WINDOWS_SWAP=1), default off — the
  forced real close re-bets an unproven #2264 assumption and can't be verified
  without a Windows runner, so the default Windows path stays in-place.
- #2599: anchor isLbugCheckpointBusyError to real held-open wording instead of
  isDbBusyError's bare .includes('lock') over a message that embeds the DB path
  (a repo under blockchain-app misclassified a disk fault as held-open).
- Docs: corrected retry-catalogue budgets (linear, not exp) and the
  checkedOut>0 bound comment (load-bounded, not IDLE_TIMEOUT_MS).

* test(analyze): cover the production close path in the atomic swap (#2614 F5)

Adds a full-rebuild swap test with skipNativeCloseOnExit:true — the close path
the CLI and serve-worker actually ship (build handle left open at swap time),
distinct from the default real-close the other swap tests exercise. Asserts the
POSIX swap still publishes a single consolidated lbug with no .new temp and no
orphan sidecar.

* test(analyze): give the follow-up git commits an inline identity (CI fix)

The end-to-end reopen and atomic-incremental tests' second commits used a bare
`git commit`, which fails on CI runners with no global git identity (empty
ident name). makeRepo's initial commit already passes -c user.name/-c
user.email inline; apply the same to the rename/change commits. No code change.

* fix(mcp): route reader reinit through initLbug's active-query guard (#2614 review)

Review found an active-query retirement race: LocalBackend.ensureInitialized
detected an identity/stamp change and called closeLbug(poolKey) DIRECTLY, but
closeOne closes the shared Database at refCount 0 regardless of checked-out
connections. So a reader detecting the new generation could close the Database
a concurrent query is still executing on — a native use-after-free. This
bypassed the checkedOut>0 guard that initLbug itself has.

Fix (delegate, not close directly): initLbug now returns whether it actually
rolled the pool over; ensureInitialized calls initLbug (which serves the
current handle while a query is in flight and reopens only when idle) instead
of closeLbug. The observed IDENTITY is advanced only when the pool actually
reopened — if a query was in flight, the identity stays divergent and the
reopen retries on a later idle check rather than latching on the old handle.
The observed STAMP advances regardless so a same-file stamp change can't loop.

Old generation now stays alive until its in-flight queries drain (lazy
rollover); new requests during the busy window share the old handle until the
pool goes idle, then reopen. No parallel open-both-generations, but no UAF and
no stale latch.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-21 21:58:00 +01:00
Gergő Magyar a259ec6c5a Merge pull request #2613 from magyargergo/fix/2606-global-ignore-file
fix(config): honor core.excludesFile and .git/info/exclude for global ignores (#2606)
2026-07-21 20:34:40 +01:00
Gergo Magyar 382801790c perf(config): memoize core.excludesFile / info/exclude resolution (#2606)
loadIgnoreRules is called once per repo, per language/contract
extractor during group sync -- an N-repo group fans out to 6+
extractors each calling it, turning an uncached execSync per call into
O(extractors x repos) blocking subprocess spawns for the exact
many-repos scenario #2606 describes.

Both getGitInfoExcludePath and getCoreExcludesFilePath resolve to the
same value for the same fromPath for the life of the process, so
memoize by fromPath in a process-lifetime Map. One-shot CLI runs are
unaffected by staleness; the long-lived MCP server would need explicit
invalidation if this becomes a real concern.
2026-07-21 19:09:50 +00:00
Gergő Magyar 9ac87ae60f Merge branch 'main' into fix/2606-global-ignore-file 2026-07-21 19:52:13 +01:00
Gergő MagyarandClaude Sonnet 5 eb116c8a07 fix(eval): exclude Claude Code's own sandbox-bootstrap noise from the planning-phase check (#2615)
The first fully successful real workflow_dispatch run on the self-hosted
runner (https://github.com/abhigyanpatwari/GitNexus/actions/runs/29843028596)
still failed: 17/18 sessions hit error_kind plan-evidence-invalid with
"phase changed unauthorized workspace path(s): .claude/.cc-writes,
.claude/commands, .env, .env.development, ...".

Reproduced directly on the runner (SSM, matching the real sandbox settings
exactly, including enableWeakerNestedSandbox): a single trivial "say OK"
prompt -- no real task, no real API key even -- is enough to make Claude
Code create a synthetic package.json/lockfiles/node_modules, a full set
of .env variants, and .claude/agents, .claude/commands, .claude/.cc-writes
in the workspace on every single session. None of this is something the
model decided to write; it's Claude Code's own internal bootstrap for
running inside an already-sandboxed environment, and it happens
regardless of task or prompt.

enforce_phase_workspace (the planning-phase boundary check: verify the
plan session touched only its one plan doc) already excludes .git for
exactly this class of reason -- harness/tool noise, not substantive diff.
Extends the same exclusion to the empirically-observed bootstrap set.
workspace_snapshot has exactly one use (this check, confirmed via every
caller), so widening its exclusion list can't hide anything in some other
context that actually cares about these paths changing.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 19:19:15 +01:00
Gergo Magyar 0f016dc467 fix(config): read core.excludesFile and .git/info/exclude for global ignores (#2606)
Replace the custom ~/.gitnexus/ignore file with the same two sources
real git itself consults for exactly this purpose (gitignore(5)):

- core.excludesFile: git's own all-repos global ignore file (defaults
  to $XDG_CONFIG_HOME/git/ignore when unconfigured)
- $GIT_COMMON_DIR/info/exclude: per-repo, untracked, so it works
  without push/commit access to the repo

Precedence mirrors git exactly (lowest to highest): core.excludesFile,
then info/exclude, then .gitignore, then .gitnexusignore -- each later
source can negate an earlier one via a `!pattern` line, same
last-match-wins semantics git itself uses.

Adds getCoreExcludesFilePath and getGitInfoExcludePath to git.ts,
following the same execSync + git-common-dir pattern as
getCanonicalRepoRoot. GITNEXUS_NO_GLOBAL_IGNORE (or noGlobalIgnore)
still skips both global sources, mirroring GITNEXUS_NO_GITIGNORE.
2026-07-21 18:06:22 +00:00
Gergo Magyar a4a79ac920 style: fix prettier formatting in ignore-service.test.ts 2026-07-21 17:41:18 +00:00
Gergő Magyar 8a67acb9dd Merge branch 'main' into fix/2606-global-ignore-file 2026-07-21 18:39:22 +01:00
Gergő Magyar 5c1c6c69a6 Merge pull request #2608 from abhigyanpatwari/fix/2605-rename-edit-count
fix(mcp): report every rename edit that apply writes (#2605)
2026-07-21 18:10:19 +01:00
Gergo Magyar 5893de1194 docs(readme): document the global ignore file (#2606) 2026-07-21 16:58:57 +00:00
Gergo Magyar 322e05a6be fix(config): add user-level global ignore file (#2606)
IgnoreService only read per-repo .gitignore/.gitnexusignore, so an
exclusion meant to apply across every indexed repo had to be repeated
per repo or hand-patched into node_modules (wiped on every upgrade).

loadIgnoreRules now also reads a global ignore file at
$GITNEXUS_HOME/ignore (default ~/.gitnexus/ignore), reusing the
existing global directory that already holds registry.json and
config.json. It is added first, so per-repo .gitignore/.gitnexusignore
rules can still negate it, mirroring the .gitignore -> .gitnexusignore
precedence already in place. GITNEXUS_NO_GLOBAL_IGNORE (or
noGlobalIgnore) skips it, mirroring GITNEXUS_NO_GITIGNORE.
2026-07-21 16:58:07 +00:00
Gergő Magyar aa8a441202 Merge branch 'main' into fix/2605-rename-edit-count 2026-07-21 17:34:36 +01:00
Gergő Magyar 3a8b369171 Merge pull request #2610 from magyargergo/fix/2604-rust-trait-object-dispatch
fix(rust): resolve trait-object (&dyn Trait) dispatch producing no CALLS edge
2026-07-21 17:34:01 +01:00
Gergő Magyar 7b43257863 Merge branch 'main' into fix/2605-rename-edit-count 2026-07-21 17:25:35 +01:00
Gergo Magyar 9f57984372 style: fix quote style per prettier in new dyn-normalization test 2026-07-21 16:12:20 +00:00
Gergo Magyar e18b4416c6 test(rust): cover Box<dyn Trait> and dyn-bound-list normalization (#2604)
GitNexus review-agent finding: stripDynBound's documented Box<dyn Trait>,
Rc/Arc<dyn Trait>, and auto-trait/lifetime bound-list (dyn Trait + Send)
shapes had no test anywhere — only the bare &dyn Trait parameter case was
exercised end-to-end. Add direct unit coverage on normalizeRustTypeName and
(via interpretRustTypeBinding) normalizeRustReturnType for these shapes.
2026-07-21 16:10:01 +00:00
Gergo Magyar a7bfe819eb test: update hardcoded schema-version expectations for v11 (#2604)
call-summary-schema-version.test.ts pins INCREMENTAL_SCHEMA_VERSION as a
literal per bump, documenting the reuse-gate boundary for each version.
Update the "current" expectation to 11 and add the v10 pre-current case,
matching the v7/v8/v9/v10 precedent already in the file.
2026-07-21 15:42:58 +00:00
Gergo Magyar 00141d0da2 test(bench): rebaseline rust scope-capture fingerprint for #2604
RUST_SCOPE_QUERY gained a function_signature_item capture, shifting the
capture fingerprint for every bench fixture with a required trait method.
Verified: node --import tsx bench/scope-capture/measure.mjs --check now
passes across all 14 languages (rust scaling 1.036 < 1.5 budget).
2026-07-21 15:32:11 +00:00
Gergo Magyar 66f11badaf style: wrap long filter predicate per prettier (PR autofix) 2026-07-21 15:19:48 +00:00
Gergo Magyar 54c44d91de fix(mcp): reconcile rename report on partial failure; harden enumerate (#2605)
Addresses gitnexus-review-agent findings on PR #2608:

- MED: on a partial apply (a file's write throws), drop that file's edits
  from total_edits/graph_edits/text_search_edits/changes so the reported
  result describes what actually reached disk, not what was attempted. The
  comprehensive enumeration otherwise let a failing file contribute its
  entire line count as phantom 'applied' edits. failed_files still names
  every dropped file. Counts are now derived once from the reported set.
- MED: hoist the word-boundary regexes out of the per-line loop (one compile
  each instead of one per line), reused by the apply loop.
- LOW: apply loop reuses escapedOldName instead of recomputing the escape
  formula inline (removes a preview/apply drift risk).
- Soften the in-code comment: enumeration gives per-call preview/apply
  consistency; the pre-existing two-read TOCTOU (external write between
  preview and apply) is out of scope and noted, not newly introduced.

Tests: add a mixed graph-ref + text_search multi-file case (asserts per-file
confidence and the never-downgrade guard, via a stubbed rg), and a
partial-write-failure case (asserts only landed files are reported). Assert
concrete graph_edits/text_search_edits splits, not just their sum.
2026-07-21 15:14:32 +00:00
Gergő Magyar aaefbda226 Merge branch 'main' into fix/2604-rust-trait-object-dispatch 2026-07-21 16:12:24 +01:00
Gergő MagyarandClaude Sonnet 5 bba25b2103 fix(eval): bind resolved node to a fresh sandbox path (corrects #2607) (#2609)
* fix(eval): bind the resolved node to a fresh sandbox path, not one under /usr

#2607 bound the resolved `node` to /usr/local/bin/node, but that path lives
inside the /usr tree that _runtime_mount_args already read-only-binds
wholesale. The second real workflow_dispatch run on the self-hosted runner
(https://github.com/abhigyanpatwari/GitNexus/actions/runs/29840270554)
failed immediately in the bubblewrap preflight: "bwrap: Can't create file
at /usr/local/bin/node: Read-only file system" -- bwrap can't create a new
mount-point file inside a tree it already bound read-only when the real
path doesn't already exist there on the host, which is exactly the
self-hosted case this bind exists to fix.

Introduces SANDBOX_NODE (/opt/claude/node), a fresh path outside every
tree _runtime_mount_args binds, following the same pattern SANDBOX_CLAUDE
and SANDBOX_PYTHON3 already use. Updates the two real call sites
(sanitized_graph.py, runner_sessions.py) to use the constant instead of
the hardcoded literal, so the fix can't drift out of sync with itself
again, and re-exports it from runner.py alongside the other SANDBOX_*
names for the real-bwrap tests that reference it directly.

Adds a real-bwrap test (gated behind GITNEXUS_REQUIRE_BWRAP_CANARY, same
as the existing ones) that copies a real node binary to a path outside
every bound tree and actually launches bwrap against it -- an
argv-construction test alone can't catch a bwrap-level "Read-only file
system" error, only a real invocation can, and that's exactly the gap
that let #2607's version of this fix through review looking correct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(eval): don't let the new real-bwrap test's node-mock break bwrap's own resolution

CI caught this immediately: the new test_real_bubblewrap_runs_node_from_outside_the_bound_trees
monkeypatched shutil.which to return None for anything but "node", but
prepare_sandbox's own bwrap/claude resolution (_resolve_executable) goes
through shutil.which too -- so the test broke bwrap discovery before the
sandbox it's supposed to exercise could even be built ("SandboxError:
required executable is unavailable: bwrap").

Delegate to the real shutil.which for every other name instead of
blanket-returning None.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:09:45 +01:00
Gergo Magyar 67d55d7e59 fix(storage): bump INCREMENTAL_SCHEMA_VERSION for Rust dyn-dispatch fix
RUST_SCOPE_QUERY gained a function_signature_item capture (previous commit)
so abstract trait methods can now dispatch a CALLS edge through a &dyn
Trait receiver. The incremental write set only covers changed files, so a
top-up against a pre-v11 index would keep silently missing these edges for
every unchanged Rust trait file — same contract as v7/v10; force a full
re-analyze instead.
2026-07-21 15:08:06 +00:00
Gergo Magyar 881c6bccc7 test(rust): regenerate captures golden snapshot for function_signature_item
Expected drift from the query.ts change: abstract trait methods now emit a
scope + declaration capture, shifting captureGroups/digest for every rust-*
fixture containing a trait with a required (bodyless) method.
2026-07-21 14:49:45 +00:00
Gergo Magyar 052319c9cc test(rust): add regression coverage for trait-object dispatch (#2604)
New minimal fixture (single trait + impl + &dyn Trait call site, no other
same-named callers) proves the dyn-dispatch CALLS edge discriminates: fails
against the pre-fix source (0 edges) and passes against the two preceding
commits' fix (exactly 1 edge, verified via the CLI analyze pipeline against
a standalone repo).

The existing rust-abstract-dispatch fixture was NOT extended for this,
deliberately: it already has other callers referencing the same method
names (process()'s repo.find()/save()/count()), and an existing resolution
fallback picks those up via simple-name matching regardless of receiver
type — masking this specific defect in the in-process test-pipeline path.
A dedicated, single-caller fixture keeps the regression test load-bearing.
2026-07-21 14:48:54 +00:00
Gergő Magyar 4dd16ea8c9 Merge branch 'main' into fix/2605-rename-edit-count 2026-07-21 15:43:53 +01:00
Gergo Magyar 902186c4f8 style: prettier-format rename-edit-report test (#2605) 2026-07-21 14:41:19 +00:00
Gergő MagyarandClaude Sonnet 5 5b906c3189 fix(eval): bind the resolved node binary into the sandbox, not a hardcoded host path (#2607)
Surfaced by the first real workflow_dispatch run on the self-hosted runner
(https://github.com/abhigyanpatwari/GitNexus/actions/runs/29836411744):
every session failed with error_kind infra-error, error_detail "bwrap:
execvp /usr/local/bin/node: No such file or directory", tripping the
outage-streak breaker after 5 consecutive failures.

sanitized_graph.py and runner_sessions.py invoke the sandboxed graph CLI
at the fixed path /usr/local/bin/node. _runtime_mount_args only binds
/usr, /bin, /lib, /lib64 wholesale, so that path resolves correctly when
node happens to live under /usr/local/bin on the host -- true on
GitHub-hosted runner images, but not on a self-hosted runner, where
actions/setup-node installs into its own tool-cache directory instead
(outside all four bound trees, so invisible to the sandbox regardless of
what PATH says on the host).

Fix lives entirely in the mount construction: resolve `node` via
shutil.which (correctly picks up wherever actions/setup-node put it,
since its tool-cache dir is already on PATH by the time this runs) and
bind it read-only to the same fixed sandbox path the two call sites
already expect. Neither call site needed to change. Backward compatible
with GitHub-hosted runners, where this resolves to the same path and
binds a harmless no-op self-mount.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:39:56 +01:00
Gergo Magyar 4e97a278d1 fix(mcp): report every rename edit that apply writes (#2605)
rename() reported total_edits from a partial enumeration (definition line
only, one-edit-per-graph-file then break, and text search that skipped any
file already covered by the graph) while the apply step does a whole-file
\boldName\b global replace on every touched file. When a private symbol's
definition and all its call sites live in one file, only the definition line
was reported (total_edits: 1) even though apply rewrote every occurrence, in
both dry-run and apply.

Rebuild changes/total_edits/graph_edits/text_search_edits from one file set:
classify each file to rewrite (definition + graph refs = graph confidence;
rg-only files = text_search, never downgrading a graph file), then enumerate
every matching line per file with apply's exact escaped global regex. The
reported edit list now equals what apply writes. Apply behavior is unchanged.

Adds a regression test reproducing the issue's single-file Rust case (def +
3 same-file call sites, empty graph): total_edits is 4 in both dry-run and
apply, and equals the replacements that land on disk.
2026-07-21 14:31:47 +00:00
Gergo Magyar 57db7bc166 fix(rust): capture abstract trait methods for scope resolution
fn foo(&self) -> T; (no body) parses as function_signature_item, a grammar
node distinct from function_item that RUST_SCOPE_QUERY never captured. An
abstract trait method therefore had no Function scope and no declaration,
so populateClassOwnedMembers never wired its ownerId to the trait's Class
scope — invisible to the CALLS-edge receiver-bound resolution pass even
after a receiver's type resolves to the trait correctly.

Together with the previous commit's dyn-stripping fix, a call through a
&dyn Trait parameter now emits a CALLS edge to the trait's method (#2604).
2026-07-21 14:29:37 +00:00
Gergo Magyar 3375beec89 fix(rust): strip dyn keyword when normalizing trait-object type names
normalizeRustTypeName/normalizeRustReturnType stripped reference sigils,
pointer sigils, and smart-pointer wrappers but never the `dyn` keyword, so a
`&dyn Trait`-typed receiver normalized to the literal string "dyn Trait"
instead of "Trait" — an unmatchable name that silently broke every
downstream receiver-type lookup for trait-object dispatch.

Part of the #2604 fix (root cause has a second, independent half: abstract
trait methods are invisible to scope resolution until function_signature_item
is captured — next commit).
2026-07-21 14:29:05 +00:00
Gergő Magyar e50a44125a Merge pull request #2602 from magyargergo/fix/2561-enum-constant-receiver-dispatch
fix(java): resolve E.CONST.method() enum-constant receiver dispatch (#2561)
2026-07-21 15:24:39 +01:00
ClaudeandClaude Opus 4.8 70e0a7766c fix(java): address #2561 review — inherited-dispatch test + bodied fail-safe
Two gitnexus-review-agent findings on PR #2602:

- MEDIUM: the bodied-constant MRO-to-host-enum path (a qualified call to an
  inherited, non-overridden enum method) was claimed in a comment but never
  tested. Add EnumConst.A.log() -> EnumConst.log#0, exercising E$N's
  @reference.inherits MRO arm end to end.

- LOW: `bodiedName ?? hostEnum` conflated "body-less" with "name synthesis
  failed on a bodied constant" (reachable only on malformed/error-recovery
  trees), silently binding an overriding constant's receiver to the host
  enum — a wrong edge instead of no edge. Switch to `isBodied ? bodiedName :
  hostEnum` so a bodied constant binds ONLY to its E$N class, mirroring the
  object_creation_expression branch's skip-on-synthesis-failure. Verified
  output-neutral on the well-formed bench corpus.

Rebaseline the java scope-capture fingerprint (a822cef9 -> d04298a9): the
bench corpus IS test/fixtures/lang-resolution, so the new dispatchInherited
fixture method shifts it (+6 capture groups); the logic change contributes
nothing (confirmed by isolating the fixture-only fingerprint). java.test.ts
242 passed; measure.mjs --check PASS (14 languages); tsc/prettier/eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:52:27 +00:00
Gergő Magyar ced02df06f Merge branch 'main' into fix/2561-enum-constant-receiver-dispatch 2026-07-21 14:40:37 +01:00
Gergő MagyarandClaude Opus 4.8 5549403082 fix(eval): self-hosted skill-evolution runner + sandbox Python 3 trust fix (#2600)
* fix(eval): move skill-evolution to a self-hosted runner and fix the sandbox's Python 3 trust gap

GitHub-hosted runners hard-cap job execution at 6 hours, which is too
short once a benchmark session actually invokes Skill/MCP tools for
real (the --bare fix in #2584 means sessions no longer no-op). Move the
job onto a self-hosted runner (5-day cap instead) and document the
activation step in the workflow's own checklist.

Validating the self-hosted run surfaced a real bug: gitnexus-plan
sessions inside the bwrap sandbox failed with "planning must create or
modify exactly one plan artifact; observed 0". Root cause:
evidence-provenance.mjs's atomic plan-writer only trusts a Python 3
binary owned by root or by the current process. Inside this
--unshare-user sandbox only the calling uid is mapped (root isn't), so
the real, root-owned /usr/bin/python3 surfaces as the kernel's overflow
uid and gets correctly refused as untrusted. Fix: provision a small,
self-owned wrapper script (same pattern already used for
shell-prefix) that execs the real interpreter, so the sandbox has a
Python 3 candidate the existing trust check can actually accept --
without touching that security-sensitive validation logic at all.

Also add visibility so this class of failure isn't quiet next time:
report.md now shows why each row failed (error_kinds), not just
resolved 0/1, and the benchmark now exits non-zero when an incumbent
arm -- the currently-shipped skill -- resolves zero across every task,
since that reads as a broken harness rather than a normal candidate
miss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(eval): close the broken_incumbent_arms zero-valid-runs gap; document runner exposure tradeoff

Addresses the two MEDIUM findings from the gitnexus-review-agent on this PR
(https://github.com/abhigyanpatwari/GitNexus/pull/2600#issuecomment-5033363096).

broken_incumbent_arms required valid_runs > 0 before flagging an incumbent,
so an incumbent that fails every run with an excluded-but-non-systemic
error_kind (e.g. evidence-unverified, which the outage-streak breaker
explicitly resets on rather than accumulates) never accumulated a single
valid run and sailed through silently -- the exact quiet no-promotion
outcome this guard exists to catch, and arguably worse than the
some-runs-resolved-zero case since here nothing completed at all.
aggregate() never marks an excluded/unverifiable row resolved=True, so
dropping the valid_runs requirement and checking resolved == 0 alone
correctly covers both cases. Added a test for exactly this all-excluded
scenario, which none of the existing three did.

Updated the workflow's own activation checklist to reflect what's actually
true now (the gitnexus-evolution environment's branch policy and the
self-hosted runner are both live, codified in infra/gitnexus-evolution/ in
a companion PR) and documented the exposure-window tradeoff the review
flagged: the runner is stopped between runs but not destroyed/recreated per
run, so it isn't fully ephemeral. Stopping already bounds the exposure
window to the job's own runtime on one day out of seven; full per-job
ephemeral provisioning is a deliberate non-goal for a job that runs at
most weekly, revisit if that changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(eval): remove public infra/ pointers from the activation checklist

PR #2603 (the Terraform codification this checklist pointed to) got closed
-- publishing the exact IAM roles, security group rules, and self-hosted
runner topology for a real, live AWS account isn't safe to do in a public
repo, even with no literal secrets or resource IDs in the diff. The
underlying AWS/GitHub setup is unaffected and still documented privately;
this just removes the now-dangling references to a directory that won't
exist in this repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* ci(actionlint): register the gitnexus-evolution self-hosted runner label

actionlint rejected `runs-on: [self-hosted, linux, x64, gitnexus-evolution]`
in gitnexus-skill-evolution.yml because it can't discover custom runner
labels. Register it in .github/actionlint.yaml so the Workflow Lint check
passes.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 14:40:20 +01:00
Ko 2a85425ad8 Merge pull request #2542 from GenKoKo/fix/worker-stdout-and-ready-timeout
fix(ingestion): pipe worker stdout and make ready timeout configurable
2026-07-21 13:54:34 +01:00
ClaudeandClaude Opus 4.8 d9437e6d74 test(bench): rebaseline java scope-capture fingerprint for #2561
The enum-constant receiver-dispatch fix adds one @type-binding.* capture
per enum constant, so the java scope-capture fingerprint shifts
(85fc7af9 -> a822cef9). Pure capture-additive drift; no bench fixtures
added; scaling 1.024 < 1.5 budget. Verified `measure.mjs --check` passes
for all 14 languages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:19:51 +00:00
Gergő Magyar c111dfd4ae Merge branch 'main' into fix/2561-enum-constant-receiver-dispatch 2026-07-21 12:50:37 +01:00
ClaudeandClaude Opus 4.8 7666a009f0 fix(java): resolve E.CONST.method() enum-constant receiver dispatch (#2561)
Calling a method on an enum-constant receiver (E.CONST.method()) emitted
no CALLS edge. The receiver "E.CONST" is a two-segment compound receiver;
resolveCompoundReceiverClass walks each dotted segment via the owning
class scope's typeBindings map, but enum constants had no typeBinding, so
the constant segment dead-ended and no target was ever resolved.

#2555/#2558 gave bodied constants a first-class synthesized E$N class with
an MRO that includes the host enum; this is the receiver-side follow-up.
synthesizeJavaAnonymousClassDeclarations now emits a class-scope
typeBinding for every enum constant's simple name -> its E$N class (bodied)
or the host enum itself (body-less), reusing the exact mechanism a field
declaration uses. The generic compound-receiver chain walk then resolves
E.CONST.method() with no change to any shared scope-resolution code.

Bodied dispatch (EnumConst.A.hook() -> EnumConst$1.hook#0) and body-less
inherited dispatch (Plain.A.m() -> Plain.m#0) are covered by new tests in
the existing java-enum-constant-body fixture; both were verified to fail
against the pre-fix tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:24:35 +00:00
Gergő Magyar a45f05e48b Merge pull request #2578 from magyargergo/require-node-22.18
fix(deps)!: require Node >=22.18 (keep Babel 8) and drop @types/uuid stub
2026-07-21 11:51:10 +01:00
ClaudeandClaude Fable 5 694048a987 chore: stop tracking docs/plans (planning output stays local)
Reverses the prior convention: gitnexus-plan/gitnexus-work plan documents
under docs/plans/ are working artifacts and no longer travel with the PR.
Drops the require-node-22.18 plan doc from tracking; the .gitignore now
ignores all of docs/. The workflow_bench snapshot features scan the
filesystem, not git-tracked status, so they are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:09:35 +00:00
ClaudeandClaude Fable 5 bb23d2998a test(eval): update containment-job node-version assertion to 22.18.0
test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs pins the
eval-containment-linux job's setup-node version; move it in lockstep with
the ci-tests.yml pin bumped to the 22.18 floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:09:35 +00:00
ClaudeandClaude Fable 5 1415bd5c2f docs(embeddings): update engines-floor comments for the 22.18 minimum
The module.registerHooks compat seam and the onnxruntime resolvers cited
the old '>=22.0.0' floor as the reason their sub-22.15 fallback was
reachable. With the floor now ^22.18.0 || >=24.11.0 (all >=22.15), every
supported runtime exposes the API; the fallback stays as defensive
handling for below-floor runtimes (engines is advisory, not
engine-strict). Comments only - no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:09:34 +00:00
ClaudeandClaude Fable 5 eea9ac92dc ci: move Node pins to the 22.18 floor
With the supported minimum raised to Node 22.18, retarget every lane and
pinned runtime that sat at a lower version so nothing builds or runs the
package on an unsupported (EBADENGINE-warning) Node:

- ci-tests.yml: node-floor-compat 22.14 -> 22.18.0 (name, comment, pin,
  version assertion) so the floor gate guards the new minimum; its #2372
  registerHooks failure mode cannot recur above 22.15. Containment-canary
  pin 22.16.0 -> 22.18.0.
- gitnexus-review-agent.yml + the pinned review/canary runtime: the
  reproducible runtime is version-locked in lockstep across
  .github/{gitnexus-review-runtime,claude-canary-runtime}/package.json and
  their lockfiles (engines), the workflow's node-version, its two
  'node --version = v22.18.0' assertions, the lockfile-engines guard, and
  NODE_VERSION. Moved all of them 22.16.0 -> 22.18.0.
- gitnexus-skill-evolution.yml: pinned runtime 22.16.0 -> 22.18.0.
- CONTRIBUTING.md prerequisite floor updated.
- review-agent-workflow.test.ts, which enforces the runtime lock, updated
  to expect 22.18.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:09:34 +00:00
ClaudeandClaude Fable 5 c26b78d153 fix(deps)!: require Node >=22.18 and drop @types/uuid stub
Babel 8 (devDep for the bench mutation oracle, pulled in by dependabot
previous floor (>=22.0.0), so every dev install on Node <22.18 emitted
nine EBADENGINE warnings. Rather than pin Babel back to 7, adopt Node
22.18+ as the supported minimum: set engines to ^22.18.0 || >=24.11.0,
matching Babel 8 exactly so the warnings resolve honestly with no
dependabot ignore needed.

@types/uuid@11 is a deprecated stub - uuid@14 ships its own types and no
tsconfig references it. Lockfile edited by hand (engines + @types/uuid
entry) to preserve the libc platform metadata a newer npm wrote;
verified consistent via npm ci (exit 0).

BREAKING CHANGE: the gitnexus package now requires Node ^22.18.0 || >=24.11.0
(previously >=22.0.0). Node 22.0-22.17 are no longer supported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:09:30 +00:00
ClaudeandClaude Fable 5 86cde93652 docs(plans): add require-node-22.18 plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:07:53 +00:00
Gergő Magyar 33d7c03329 Merge pull request #2587 from abhigyanpatwari/dependabot/npm_and_yarn/gitnexus/ladybugdb/core-0.18.2
chore(deps)(deps): bump @ladybugdb/core from 0.18.1 to 0.18.2 in /gitnexus
2026-07-21 11:04:01 +01:00
Gergő Magyar a2cce72fa0 Merge branch 'main' into dependabot/npm_and_yarn/gitnexus/ladybugdb/core-0.18.2 2026-07-21 10:18:38 +01:00
Gergő Magyar 41b03d30ca Merge pull request #2590 from ShiningXu/codex/spring-config-bindings-2412
feat(spring): bind Value and ConfigurationProperties
2026-07-21 10:18:20 +01:00
Gergő Magyar c7701613ec Merge branch 'main' into dependabot/npm_and_yarn/gitnexus/ladybugdb/core-0.18.2 2026-07-21 09:52:37 +01:00
Gergő Magyar 450f641b36 Merge branch 'main' into codex/spring-config-bindings-2412 2026-07-21 09:51:42 +01:00
Gergő Magyar 786e0d7841 Merge pull request #2597 from magyargergo/fix/2564-record-newexpr-callgraph
fix(java): record container node + new-expression chained call receiver typing (#2564)
2026-07-21 09:51:16 +01:00
Gergő Magyar 522d1ee62a Merge branch 'main' into fix/2564-record-newexpr-callgraph 2026-07-21 09:29:45 +01:00
Gergő Magyar 8791e95ccc Merge pull request #2598 from magyargergo/repro/issue-2589
fix(analyze): drop FTS indexes before the incremental DETACH DELETE
2026-07-21 09:29:34 +01:00
Gergo Magyar dac2b770a0 fix(test): address gitnexus-review-agent findings on PR #2598
- Anchor isBenignDropFtsIndexError to the START of the message
  (startsWith, not includes) so a future genuine failure that merely
  mentions "Binder exception" or "Catalog exception" mid-message can't
  be misclassified as benign. New test proves the old substring match
  would have swallowed such a message.
- incremental-fts-drop-ordering.test.ts: probe FTS availability once in
  beforeAll and skip VISIBLY via ctx.skip() in beforeEach (matching the
  withTestLbugDB/lbug-vector-extension convention) instead of a silent
  console.warn+return inside the test body, which reported a false pass
  with zero coverage of the ordering invariant when FTS was unavailable.
  The post-first-run FTS-index-built check is now a hard assertion
  instead of a second soft skip, since the beforeEach gate already
  proved the extension loads.
2026-07-21 07:57:28 +00:00
Gergő Magyar 10545a52f0 Merge branch 'main' into dependabot/npm_and_yarn/gitnexus/ladybugdb/core-0.18.2 2026-07-21 08:40:14 +01:00
Gergo Magyar 993abbb7ea Merge branch 'main' auto-update (GitHub PR sync) into repro/issue-2589 2026-07-21 07:25:53 +00:00
Gergo Magyar f460157470 style(test): fix prettier formatting in incremental-fts-drop-ordering.test.ts
CI's prettier --check flagged one over-long line; no behavior change.
2026-07-21 07:25:15 +00:00
Gergo Magyar 5099e8ff1e test(bench): rebaseline java scope-capture fingerprint for record support (#2564)
CI caught this: adding the record_declaration capture legitimately
changes the pinned java capture fingerprint, same as every prior
capture-behavior change to this language (#2550, #2555). Rebaselined
following the established _rebaselined_* precedent; scaling ratio
1.059 stays well within the 1.5 budget.
2026-07-21 07:18:38 +00:00
Gergő Magyar bbd1c4cd47 Merge branch 'main' into repro/issue-2589 2026-07-21 08:12:59 +01:00
Gergő Magyar 6c4c93533b Merge branch 'main' into codex/spring-config-bindings-2412 2026-07-21 08:07:42 +01:00
Gergő Magyar 1864309872 Merge branch 'main' into fix/2564-record-newexpr-callgraph 2026-07-21 08:06:15 +01:00
dependabot[bot] b028cc0212 chore(deps)(deps): bump body-parser from 2.2.2 to 2.3.0 in /gitnexus (#2594)
Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.2.2 to 2.3.0.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 2.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 08:05:49 +01:00
dependabot[bot] cdd0ce9b8e chore(deps)(deps): bump brace-expansion from 5.0.6 to 5.0.7 in /gitnexus (#2593)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.6 to 5.0.7.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.7)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 08:05:33 +01:00
Gergő Magyar 2601506be3 Merge pull request #2592 from abhigyanpatwari/dependabot/npm_and_yarn/gitnexus/tar-7.5.20
chore(deps)(deps): bump tar from 7.5.16 to 7.5.20 in /gitnexus
2026-07-21 08:05:09 +01:00
Gergo Magyar d7a4f47580 fix(analyze): drop FTS indexes before the incremental DETACH DELETE
Fixes #2589: incremental analyze intermittently crashed with "FTS
index 'file_fts' is inconsistent: term is missing during delete" after
markdown-only commits, and --repair-fts also failed in that state.

deleteNodesForFiles' batched DETACH DELETE ran against tables that
still carried the FTS index built at the end of the PREVIOUS analyze
run -- createSearchFTSIndexes only drops+rebuilds every index in Phase
3, well after that delete already ran. LadybugDB's FTS extension is
not proven to survive DML against an indexed table (its own docs never
demonstrate the sequence). Call the new dropSearchFTSIndexes() up front
in the non-escalated incremental branch, before deleteNodesForFiles --
Phase 3 still rebuilds every index from the final row set regardless.

New end-to-end test drives a real runFullAnalysis full+incremental
cycle and confirms it fails without this change (file_fts and 50
sibling indexes still present at delete time) and passes with it.
2026-07-21 07:04:50 +00:00
Gergo Magyar c548652eca fix(lbug): stop dropFTSIndex from swallowing genuine engine failures
dropFTSIndex previously caught and discarded every DROP_FTS_INDEX error
unconditionally. Extract isBenignDropFtsIndexError, a pure classifier
for the two legitimate "nothing to drop" cases (Binder/Catalog
exceptions: index never created, or the FTS function isn't registered)
verified end-to-end against @ladybugdb/core 0.18.x's real conn.query()
error text. Anything else -- e.g. the Runtime exception "FTS index is
inconsistent" class from #2589 -- now rethrows instead of being masked,
so a corrupted index can no longer persist across analyze runs
undetected.
2026-07-21 07:04:50 +00:00
Gergo Magyar 1fd1f14cee refactor(search): extract dropSearchFTSIndexes from createSearchFTSIndexes
Pulls the existing per-index dropFTSIndex loop out into its own exported
function so the incremental writeback can drop FTS indexes up front,
before deleteNodesForFiles runs (#2589). No behavior change here —
createSearchFTSIndexes calls the new function and still rebuilds every
index afterward.
2026-07-21 07:04:49 +00:00
Gergo Magyar 1595a90a13 fix(storage): bump INCREMENTAL_SCHEMA_VERSION for the Java record fix (#2564)
Review finding: the record_declaration container-node fix (894110bf)
makes previously-uncaptured Record nodes and HAS_METHOD edges appear
for the first time, but the incremental write set only covers changed
files. Without this bump, an existing index would silently keep
omitting the Record node and its HAS_METHOD edges for unchanged
record files after an ordinary incremental analyze.

Same contract as v7 (#2437/#2522) and the two closest precedents, v8
(#2550) and v9 (#2555), which bumped this constant for the identical
"model X as first-class node" class of change.
2026-07-21 07:04:08 +00:00
Gergo Magyar 0b933aa43f fix(java): treat a new-expression as a typed receiver for its chained call (#2564)
new Local().inner() bound the whole object_creation_expression as
@reference.receiver, so its raw source text ("new Local()") became the
receiver name. That text can never match a scope binding, so the call
silently fell through to name-only fallback resolution and could
resolve to an unrelated same-named method on a collision.

Normalize the receiver to the constructed type's simple name (reusing
javaBaseSimpleNameOf, already used for the anonymous-class inheritance
edge) so Case 2 (class-name / static receiver) in
receiver-bound-calls.ts resolves it via its normal MRO walk. Mirrors
the existing normalizePhpReceiver precedent in php/captures.ts - a
language-local capture rewrite, no shared-pipeline change.
2026-07-21 07:04:07 +00:00
Gergo Magyar 1e190e6fdd fix(java): emit a graph node for record_declaration (#2564)
JAVA_QUERIES had no @definition.record capture, unlike its
class_declaration/interface_declaration/enum_declaration siblings and
unlike CSHARP_QUERIES' own record_declaration pattern. A Java record's
container node was never created, so its HAS_METHOD edges were dropped
at persistence even though ownership resolution computed a valid
ownerId for its methods.

Downstream label mapping, the class-extractor config, the dispatch
table, and ownership reconciliation already treated 'Record' correctly
- this was purely a missing structure-phase capture.
2026-07-21 07:04:07 +00:00
Gergo Magyar a638867400 Merge branch 'dependabot/npm_and_yarn/gitnexus/ladybugdb/core-0.18.2' of origin into local 2026-07-21 06:16:48 +00:00
Gergo MagyarandClaude Sonnet 5 30ec68fa7a fix(test): harden findInstalledFtsExtension for cross-OS filesystem quirks
Wrap the version-directory scan in try/catch so a transient FS error
(permission denial, an AV file lock on Windows, a directory vanishing
mid-scan) fails closed to null instead of throwing — matching the
original callers' contract, and safer across the Windows/macOS/Linux
CI matrix where these error modes differ.

Also drop the redundant USERPROFILE/HOME manual chain in
extension-binary-real.test.ts in favor of the repo's established
os.homedir() convention (already used ~15 other places here), which
Node resolves correctly per-OS and already honors env overrides.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 06:14:58 +00:00
dependabot[bot] 7c22975048 chore(deps)(deps): bump tar from 7.5.16 to 7.5.20 in /gitnexus
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.16 to 7.5.20.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.16...v7.5.20)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.20
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 06:13:22 +00:00
Gergő Magyar cdeb9b59a5 Merge branch 'main' into dependabot/npm_and_yarn/gitnexus/ladybugdb/core-0.18.2 2026-07-21 07:10:13 +01:00
Gergő Magyar 180ba27ec9 Merge branch 'main' into codex/spring-config-bindings-2412 2026-07-21 07:08:52 +01:00
Gergő Magyar 281664eb0a Revert "chore(deps)(deps): bump js-yaml from 4.3.0 to 5.0.0 in /gitnexus (#2586)" (#2596)
This reverts commit 3f93bf22d6.
2026-07-21 07:08:37 +01:00
Gergo MagyarandClaude Sonnet 5 b751418985 fix(test): discover the installed FTS extension version dir instead of assuming it equals lbug.VERSION
resolveInstalledFtsExtension (extension-binary-real.test.ts) and
resolveSeedExtension (fts-extension-e2e.test.ts) both hardcoded the
on-disk FTS extension path as .lbdb/extension/<lbug.VERSION>/..., but
LadybugDB's native INSTALL/LOAD resolves its own extension-ABI version
directory, which does not always track the npm package version. Bumping
@ladybugdb/core from 0.18.1 to 0.18.2 in this PR still installs into a
0.18.1 directory, so both hardcoded lookups came up empty and failed
hard under GITNEXUS_REQUIRE_FTS=1 in CI (all platforms, shard 3).

Add findInstalledFtsExtension() to discover the real installed file by
scanning every version subdirectory, and use it from both test files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 06:06:42 +00:00
Gergő Magyar ba0cfed18c Merge branch 'main' into codex/spring-config-bindings-2412 2026-07-21 06:53:59 +01:00
Shining 41e590fed7 fix(spring): harden configuration bindings 2026-07-21 13:44:43 +08:00
dependabot[bot] 2dabdd391e chore(deps)(deps-dev): bump tar (#2591)
Bumps the npm_and_yarn group with 1 update in the /gitnexus-web directory: [tar](https://github.com/isaacs/node-tar).


Updates `tar` from 7.5.16 to 7.5.20
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.16...v7.5.20)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.20
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 06:44:37 +01:00
Gergő Magyar ce6cefe696 Merge branch 'main' into codex/spring-config-bindings-2412 2026-07-21 06:19:19 +01:00
84b4402cd1 fix: remove hardcoded 300-flows cap for large repositories (#2198)
* fix: remove hardcoded 300-flows cap for large repositories

The dynamicMaxProcesses was capped at 300 via Math.min(300, ...),
causing large repositories (280K+ nodes) to lose execution flows.

Change: Remove the Math.min(300, ...) cap, keep dynamic calculation.
Effect: 280K-node repo: 300 → 1617 flows.

* test: add regression for dynamic maxProcesses sizing (#2198)

Verify that processProcesses honours maxProcesses > 300 without truncation.
Addresses the optional follow-up suggested by @koriyoshi2041.

* test: exercise computeDynamicMaxProcesses at the phase layer (#2198)

Extract  from the inline
expression in  so the regression test can exercise the
function that actually contained the removed  cap.

The previous test called  directly with
, which passes regardless of whether the phase-level
cap is present —  never had the cap.

The new test suite covers:
  - floor (20) for tiny repos
  - linear scaling in the 0–3000 range
  - growth past 300 for large repos (the actual regression)
  - explicit assertion that reintroducing Math.min(300, …) would fail

Addresses review feedback from @azizur100389.

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-21 05:48:55 +01:00
7534f53c27 feat(embeddings): control request-body dimensions via GITNEXUS_EMBEDDING_REQUEST_DIMS (#2574)
* feat(embeddings): support GITNEXUS_EMBEDDING_REQUEST_DIMS=omit

What: Honor GITNEXUS_EMBEDDING_REQUEST_DIMS=omit by suppressing the request-body
`dimensions` field sent to HTTP embedding backends.

Why: Strict OpenAI-compatible backends return vectors in the model's native size
but reject an unfamiliar `dimensions` field, breaking `analyze --embeddings`
against them. The var was parsed but never propagated, so `omit` was a no-op.

How: Add `requestDimensions` to HttpConfig, return it from readConfig, and forward
`config.requestDimensions` (not the validation-only `config.dimensions`) to
`httpEmbedBatch`. Local dimension checks still use `config.dimensions`.

Details: Coexists with the retry/pacing fields introduced upstream; both feature
sets are preserved. Default behavior unchanged when REQUEST_DIMS is unset.

Impact: gitnexus/src/core/embeddings/http-client.ts; README; unit tests.

* fix(embeddings): name GITNEXUS_EMBEDDING_REQUEST_DIMS in its own config error

Address the review findings on #2574.

What:
- A malformed GITNEXUS_EMBEDDING_REQUEST_DIMS now throws an error naming
  GITNEXUS_EMBEDDING_REQUEST_DIMS, not the sibling GITNEXUS_EMBEDDING_DIMS.
- isHttpEmbeddingDimsError recognizes both leads, so the CLI still classifies
  the REQUEST_DIMS config mistake as a clean config error, not a stack dump.
- Tests: numeric-override decoupling (DIMS=1024 validates the response while
  REQUEST_DIMS=512 is sent in the body), the omit aliases (none/off/false/0),
  and the malformed-value error path (which also pins the naming fix).
- README documents the full accepted values: omit-aliases and integer override.

Why: readConfig reused the DIMS error lead for the REQUEST_DIMS branch, so
REQUEST_DIMS=garbage misdirected the operator to edit the wrong variable. The
feature's actual decoupling and its non-omit inputs had no test coverage.

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

---------

Co-authored-by: wangxc <wangxc_a_bj@si-tech.com.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 05:46:20 +01:00
Gergő Magyar 2c4e0af64a Merge branch 'main' into dependabot/npm_and_yarn/gitnexus/ladybugdb/core-0.18.2 2026-07-21 05:45:04 +01:00
dependabot[bot] 5935921079 chore(deps)(deps-dev): bump @types/node in /gitnexus (#2588)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.5 to 26.0.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 05:44:37 +01:00
dependabot[bot] 3f93bf22d6 chore(deps)(deps): bump js-yaml from 4.3.0 to 5.0.0 in /gitnexus (#2586)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 5.0.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...5.0.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 05:44:25 +01:00
ArgonarioD 2cc3a96d9a Merge remote-tracking branch 'upstream/main' 2026-07-21 10:39:31 +08:00
Shining 9096f6924c feat(spring): bind configuration consumers 2026-07-21 10:07:20 +08:00
Gergő MagyarandClaude Sonnet 5 52b06b2642 fix(eval): stop using --bare for arms that need Skill or MCP tools (#2584)
--bare hard-disables the Skill tool and every mcp__* tool by Claude
Code design (confirmed against the pinned 2.1.214 binary; --allowedTools
cannot restore what --bare removes). Every workflow_bench arm except
baseline_nomcp needs Skill and/or GitNexus MCP tools, so every one of
those sessions has been silently unable to invoke gitnexus-plan/work/
review or the CE comparator skills -- the last skill-evolution run
(gen 0) scored 0/3 resolution on both arms across every task with
error_kind "skill-not-invoked", not because the candidate was bad but
because the harness could never invoke either arm's skill at all.

Only baseline_nomcp keeps --bare (it explicitly wants zero Skill/MCP
access anyway). The rest drop --bare and rely on ANTHROPIC_API_KEY
alone; the sandboxed HOME has no OAuth/keychain state to conflict
with it, and there's no committed .claude/settings.json in this repo
for dropping --bare to newly pick up.

Outside --bare the built-in toolset defaults to everything (WebFetch,
Task, subagents, ...), and --allowedTools only pre-approves within
whatever's available -- it doesn't narrow it. Added --tools for
non-bare sessions so the intended tool scope is still enforced instead
of silently widening.


Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 21:26:11 +01:00
dependabot[bot] 9c87030edf chore(deps)(deps): bump @ladybugdb/core in /gitnexus
Bumps [@ladybugdb/core](https://github.com/LadybugDB/ladybug) from 0.18.1 to 0.18.2.
- [Release notes](https://github.com/LadybugDB/ladybug/releases)
- [Commits](https://github.com/LadybugDB/ladybug/compare/v0.18.1...v0.18.2)

---
updated-dependencies:
- dependency-name: "@ladybugdb/core"
  dependency-version: 0.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 20:15:31 +00:00
8fd1f8a8d8 test(skills-e2e): give the Idempotency setup hook the 120s budget its siblings use (#2583)
The Idempotency beforeAll runs runSkillsCli (analyze --skills) twice, each
capped at 45s, under a 90s hook budget — exactly 2x the per-call timeout,
with no headroom for fixture creation and git init. On slow Windows CI
runners the two analyzes plus setup exceed 90s and the hook times out
('Hook timed out in 90000ms'), failing the shard before the test's own
status===null timeout tolerance can apply. Every other describe hook in
this file already uses 120s; align this one.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:54:54 +01:00
Gergő Magyar ecf6a94a1e Merge pull request #2582 from magyargergo/fix/buffer-pool-adaptive-sizing
perf(lbug): size the buffer pool to the repo (adaptive, COPY-safe)
2026-07-20 17:58:08 +01:00
Gergő Magyar 3dfe113179 Merge branch 'main' into fix/buffer-pool-adaptive-sizing 2026-07-20 17:29:56 +01:00
ClaudeandClaude Fable 5 05dadb7950 fix(lbug): raise the adaptive pool floor to a COPY-safe 256 MiB
The 64 MiB floor was too small: LadybugDB's bulk COPY needs working
buffer-pool memory that scales with the repo, so a 64 MiB pool fails with
"buffer pool is full and no memory could be freed" on any non-trivial
repo (empirically: the 6-file skills-e2e idempotency fixture needs
>=128 MiB; the ~1800-file GitNexus checkout needs >=256 MiB). Introduce a
distinct ADAPTIVE_POOL_FLOOR (256 MiB) for the hint clamp, kept separate
from BUFFER_POOL_FLOOR (64 MiB), which still guards defaultBufferPoolSize
on tiny-RAM machines; the hint is still clamped up to the machine default
so it can never over-commit.

This keeps the change as what it actually is — a large-repo optimization:
GitNexus full analyze is 51s (2 GiB) -> 35s (adaptive ~414 MiB). Small
repos now open COPY-safely at 256 MiB instead of the 2 GiB default (same
wall time on Linux, where commit is lazy; the eager commit is far cheaper
than 2 GiB on Windows). The reframed comments drop the earlier
unrepresentative "3-file repo / 64 MiB fast" claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:29:17 +00:00
ClaudeandClaude Fable 5 7f03a4ed2d docs(lbug): correct the POOL_BYTES_PER_ELEMENT tuning note
The factor is validated by timing a full `analyze --force` of a large
repo, not a build-free bench (the pool is a native eager allocation).
Benchmark (GitNexus self, 101k graph elements): adaptive 414MiB pool =
35.3s vs forced 2GiB = 50.8s vs forced 64MiB = 26.5s — the adaptive pool
is 31% faster than the old 2GiB default even on a large repo (the eager
commit dominates), with no under-sizing thrash. 4KiB/element kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:29:17 +00:00
ClaudeandClaude Fable 5 087b44a865 feat(analyze): size the buffer pool to the graph before the DB open
runFullAnalysis now sets the buffer-pool size hint from the built graph's
node+relationship count (after the pipeline, before initLbug), and clears
it at the top of each run so a prior run's size can't leak into a
pre-pipeline open. A small repo opens with the fast 64MiB floor instead of
eagerly committing the full 2GiB pool.

Measured on a 3-file repo (this box): analyze drops 4.78s -> 1.9s for both
fresh and incremental, matching a forced 64MiB pool; the 2GiB path still
reproduces the old 4.78s. This roughly halves the skills-e2e Idempotency
hook (two analyze passes) that was timing out on Windows CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:29:17 +00:00
ClaudeandClaude Fable 5 ad5ff42804 feat(lbug): add adaptive buffer-pool size hint
Adds the sizing lever without changing behavior yet: a module-scoped
buffer-pool size hint plus estimateBufferPool(graphElementCount), read by
resolveBufferManagerSize with precedence env-override > clamp(hint, 64MiB,
default) > default. The hint can only shrink the pool from the default
(clamped to [floor, default]), so the 2GiB/80%-RAM cap and the
GITNEXUS_LBUG_BUFFER_POOL_SIZE escape hatch (incl. 0) are preserved. With
no hint set, resolveBufferManagerSize returns exactly what it did before.

Motivation: LadybugDB eagerly commits the buffer pool at DB open, so the
fixed min(2GiB,80%RAM) pool adds a measured ~2.8s to every analyze even on
a 3-file repo (dominant on Windows). Sizing the pool to the graph lets
small repos use the fast 64MiB floor while large repos keep the cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:29:17 +00:00
Gergő Magyar 8e95b58b49 Merge pull request #2581 from abhigyanpatwari/fix/evolution-sandbox-reflink-fallback-budget
fix(eval): size the buffered-fallback budget for the real graph index
2026-07-20 16:06:53 +01:00
Gergo Magyar c723d420d5 fix(eval): size the buffered-fallback budget for the real graph index
trusted_gitnexus_runtime_mounts and sanitized_graph both hand the ~290 MiB
graph index through TaskAssetSnapshot.materialize(), which reflinks into
each arm clone or falls back to a buffered copy under a 16 MiB budget.
Neither ext4 (CI runners) nor 9p (this container) support FICLONE, so
every real materialization fell to the buffered path and blew the budget
instantly (CI run 29750271566).

Raises MAX_BUFFERED_FALLBACK_BYTES to 512 MiB - well above the real index
size, still a full 4x below MAX_TASK_ASSET_BYTES so a genuinely oversized
declaration still fails closed.
2026-07-20 14:42:50 +00:00
Gergő Magyar e3ed82d162 Merge pull request #2580 from abhigyanpatwari/fix/evolution-sandbox-missing-hooks-mount
fix(eval): mount hooks/claude into the benchmark sandbox
2026-07-20 14:28:42 +01:00
Gergo Magyar cd93b8da23 fix(eval): mount hooks/claude into the benchmark sandbox
resolve-invocation.ts requires hooks/claude/resolve-analyze-cmd.cjs at
module load time, reached whenever the analyze command loads. The
sandbox's curated mount list never exposed hooks/, so every
benchmark-arm session failed with MODULE_NOT_FOUND (CI run 29742191562).

Appends the new mount after the existing six so the function's
hardcoded mounts[0]/[1]/[2]/[5] validation reads stay correct. Extends
the real-bwrap canary to require analyze.js directly, since --version
alone never reaches the lazy import that broke.
2026-07-20 12:53:24 +00:00
Gergő MagyarandClaude Opus 4.8 497f117075 fix(eval): drop tags from the benchmark's per-arm clone (#2579)
Every benchmark-arm session failed with "sanitized graph snapshot
preparation failed: clone has more than 1024 references; refusing
incomplete sanitization" (confirmed via a real workflow_dispatch run,
29738099937, after the prior activation fixes let the proposer succeed
end-to-end for the first time).

make_worktree() creates each arm's throwaway clone with a plain `git
clone`, which inherits every tag and branch from the source. This repo's
history has grown to 1144 tags (a v1.6.9-rc.N release-candidate series)
out of 1650 total refs, exceeding oracle_assets.MAX_CLONE_REFS=1024 -- a
fail-closed guard in sanitize_clone_for_hidden_oracles() that refuses to
proceed unless it can enumerate and delete every ref before handing a
sanitized snapshot to a benchmark session (so an agent can never discover
oracle answers via a ref the sanitization missed).

`ref` at every call site (evolve.py, runner.py, sanitized_graph.py) is
always a bare SHA or the literal "HEAD", never a branch name, so
`--single-branch --branch <ref>` isn't viable (git clone's --branch
requires a name). Tags are never used by the checkout fallback or by
sanitization's own delete-everything behavior, so dropping them via
--no-tags removes the 1144-ref majority without touching branch-fetch
behavior or the existing ref/origin-ref checkout fallback, and without
weakening MAX_CLONE_REFS itself.

Verified against the real repository (not just the test fixture): cloning
/workspace (1650 refs, 1144 tags) via the fixed make_worktree() now
produces a clone with 237 total refs and 0 tags.


Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:25:16 +01:00
ArgonarioD 325fee71a2 Merge remote-tracking branch 'upstream/main' 2026-07-20 16:29:03 +08:00
ArgonarioDandClaude 1c98e7c6dd fix(cli): make .agents/ skill mirror best-effort + exclude from dirty check
Address review findings on PR #2488:

- skill-gen.ts: wrap mirror-root mkdir and per-skill mirror writes in
  try/catch + warn, so a mirror failure (e.g. .agents/skills is a file)
  no longer aborts canonical community-skill generation or destroys prior
  output. Mirroring is now a weak side-flow, matching ai-context.ts.
- git.ts: exclude .agents/ + .agents/** from isWorkingTreeDirty so a
  tracked .agents/ dir doesn't permanently defeat the up-to-date fast path.
- README + --skip-skills help (en/zh): note skills also mirror to
  .agents/skills/ when .agents/ exists, and --skip-skills skips both.

Tests: +18 covering mirror failure paths (root-is-file, per-skill fail,
delete-then-rewrite ordering, namespace-scoped cleanup), dirty-check
excludes (real-edit regression, prefix collision, subdir .agents/,
non-git/git-missing conservative fallback), gate on file-not-dir, and
idempotency.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 16:19:59 +08:00
ArgonarioD 6182231cd4 Merge remote-tracking branch 'upstream/main' 2026-07-20 10:58:08 +08:00
ArgonarioD 7ba5483f61 Merge remote-tracking branch 'upstream/main' 2026-07-17 10:10:34 +08:00
ArgonarioD fb2910a05c Merge remote-tracking branch 'upstream/main' 2026-07-16 19:04:08 +08:00
ArgonarioD a5ec631f09 Merge remote-tracking branch 'upstream/main' 2026-07-16 17:58:48 +08:00
ArgonarioD f16b28a4c5 Merge remote-tracking branch 'upstream/main' 2026-07-16 16:37:00 +08:00
ArgonarioD 2c5b390d96 Merge remote-tracking branch 'upstream/main'
# Conflicts:
#	gitnexus/src/cli/ai-context.ts
#	gitnexus/src/cli/skill-gen.ts
2026-07-16 15:47:14 +08:00
ArgonarioDandClaude c7a9b7efc2 feat(cli): mirror skills to .agents/skills/ when .agents/ exists
Some agents prefer repo-local .agents/skills/ over the global install. When
.agents/ is present, mirror the standard and generated skills written to
.claude/skills/ so those agents serve up-to-date copies. Opt-in via .agents/;
absent directory leaves the layout untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 16:14:30 +08:00
296 changed files with 19382 additions and 1588 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10-rc.106",
"source": {
"source": "local",
"path": "./gitnexus-claude-plugin"
+1 -1
View File
@@ -11,7 +11,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10-rc.106",
"source": "./gitnexus-claude-plugin",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
}
+12
View File
@@ -81,6 +81,18 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
These four hot read tools attach a non-blocking `staleness` field to their response when the index is behind the checkout's current HEAD — the same `{ commitsBehind, hint }` shape `list_repos` already reports — so a direct tool call surfaces a behind-HEAD index without a separate `list_repos` call:
```jsonc
{ /* …the tool's normal result… */
"staleness": { "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
}
```
The field is **absent when the index is current** (or when the freshness check can't run), so its presence is the signal. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
### Taint findings (`explain`)
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
+1
View File
@@ -39,6 +39,7 @@ ENV BUN_VERSION=${BUN_VERSION} \
TZ=${TZ} \
DEVCONTAINER=true \
NODE_OPTIONS=--max-old-space-size=4096 \
GITNEXUS_AUTO_HEAP=0 \
POWERLEVEL9K_DISABLE_GITSTATUS=true
# Native build toolchain that gitnexus/postinstall needs. It compiles
+5
View File
@@ -0,0 +1,5 @@
# Custom self-hosted runner labels actionlint can't discover on its own.
# gitnexus-evolution: the skill-evolution EC2 runner (infra/gitnexus-evolution/).
self-hosted-runner:
labels:
- gitnexus-evolution
+1 -1
View File
@@ -11,7 +11,7 @@
"@anthropic-ai/claude-code": "2.1.214"
},
"engines": {
"node": "22.16.0"
"node": "22.18.0"
}
},
"node_modules/@anthropic-ai/claude-code": {
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"engines": {
"node": "22.16.0"
"node": "22.18.0"
},
"dependencies": {
"@anthropic-ai/claude-code": "2.1.214"
+1 -1
View File
@@ -11,7 +11,7 @@
"gitnexus": "1.6.9"
},
"engines": {
"node": "22.16.0"
"node": "22.18.0"
}
},
"node_modules/@emnapi/runtime": {
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true,
"version": "1.0.0",
"engines": {
"node": "22.16.0"
"node": "22.18.0"
},
"dependencies": {
"gitnexus": "1.6.9"
@@ -352,7 +352,7 @@ jobs:
with:
persist-credentials: false # this job uploads artifacts (artipacked)
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
- name: Unit-test the host->container config transforms
@@ -60,7 +60,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
# Builds the image the same way a developer's "Reopen in Container" does.
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
@@ -29,7 +29,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
+32 -24
View File
@@ -46,7 +46,7 @@ jobs:
with:
path: ~/.lbdb/extension
key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }}
- name: Ensure FTS extension installed
- name: Ensure FTS + VECTOR extensions installed
run: npx tsx scripts/ensure-fts.ts
working-directory: gitnexus
- name: Run sharded tests with coverage (blob)
@@ -205,6 +205,10 @@ jobs:
# tsx-on-source path in CI (both entry points stay covered).
env:
GITNEXUS_REQUIRE_FTS: '1'
# #2623: the win32 VECTOR gate is gone, so the vector suites genuinely
# run here — require the extension so an unavailable VECTOR is a loud
# failure, never a silent skip (same contract as GITNEXUS_REQUIRE_FTS).
GITNEXUS_REQUIRE_VECTOR: '1'
GITNEXUS_E2E_CLI: dist
# #2449: hosted Windows runners intermittently push the busiest shard past
# the default 15-minute watchdog. 20 minutes restores real headroom while
@@ -219,19 +223,21 @@ jobs:
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
# Warm-cache the installed LadybugDB FTS extension (~/.lbdb/extension) per
# OS + lockfile so a warm run skips the network install entirely, and the
# parallel shards share one download across runs. Pure reliability/speed:
# on a cache miss the tests self-install FTS on demand (see
# test/helpers/fts-availability.ts), so a miss just falls back to install —
# never a correctness dependency. Keyed by lockfile hash so a LadybugDB
# version bump re-installs; per-OS because the extension is a native binary.
# Warm-cache the installed LadybugDB FTS + VECTOR extensions
# (~/.lbdb/extension) per OS + lockfile so a warm run skips the network
# install entirely, and the parallel shards share one download across
# runs. Pure reliability/speed: on a cache miss the tests self-install on
# demand (see test/helpers/fts-availability.ts), so a miss just falls
# back to install — never a correctness dependency. Keyed by lockfile
# hash so a LadybugDB version bump re-installs; per-OS because the
# extensions are native binaries. (Key name kept as lbug-fts for cache
# continuity — the path covers every extension in the shared home.)
- name: Cache LadybugDB FTS extension
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ~/.lbdb/extension
key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }}
- name: Ensure FTS extension installed
- name: Ensure FTS + VECTOR extensions installed
run: npx tsx scripts/ensure-fts.ts
working-directory: gitnexus
- name: Run platform-sensitive tests
@@ -378,15 +384,16 @@ jobs:
"$PREFIX/bin/gitnexus" --version
fi
# Node engines-floor gate (#2372). The embedding resolvers statically named
# `module.registerHooks`, which only exists on Node >= 22.15 / >= 23.5, so on
# the supported floor (engines: >=22.0.0) those ESM modules failed to LINK —
# a class vitest/tsx transforms structurally mask, and the default
# `node-version: 22` (resolves to latest) never hits. Build the dist on 22.x,
# then import-link every module R1 names as a load surface on a pinned 22.14
# so a regression fails here instead of shipping to users on that Node range.
# Node engines-floor gate (#2372). A module that statically names an API
# newer than the supported floor (e.g. `module.registerHooks`, added in
# 22.15) fails to LINK on the floor — a class vitest/tsx transforms
# structurally mask, and the default `node-version: 22` (resolves to latest)
# never hits. Build the dist on 22.x, then import-link every module R1 names
# as a load surface on the pinned engines floor (22.18.0, per package.json
# `engines: ^22.18.0 || >=24.11.0`) so a regression fails here instead of
# shipping to users on the minimum supported Node.
node-floor-compat:
name: node floor compat (22.14)
name: node floor compat (22.18)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -395,7 +402,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: npm
@@ -413,16 +420,16 @@ jobs:
# Switch to the engines-floor Node AFTER building — native deps built on
# 22.x load across the whole 22.x ABI line, and nothing installs after this
# (so no package-manager cache is needed).
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.14.0'
node-version: '22.18.0'
package-manager-cache: false
- name: Import-link the built dist on Node 22.14
- name: Import-link the built dist on Node 22.18
shell: bash
run: |
set -euo pipefail
node --version
node --version | grep -q '^v22\.14\.' || { echo "expected Node 22.14.x" >&2; exit 1; }
node --version | grep -q '^v22\.18\.' || { echo "expected Node 22.18.x" >&2; exit 1; }
for m in \
core/embeddings/runtime-install \
core/embeddings/onnxruntime-node-resolver \
@@ -516,6 +523,7 @@ jobs:
npx vitest run --no-file-parallelism
test/integration/cobol-pipeline-benchmark.test.ts
test/integration/csharp-pipeline-benchmark.test.ts
test/integration/instance-ownership-pipeline-benchmark.test.ts
test/integration/rust-pipeline-benchmark.test.ts
test/integration/php-pipeline-benchmark.test.ts
test/integration/ruby-pipeline-benchmark.test.ts
@@ -554,9 +562,9 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.16.0'
node-version: '22.18.0'
cache: npm
cache-dependency-path: |
gitnexus/package-lock.json
+6 -6
View File
@@ -323,9 +323,9 @@ jobs:
- name: Set up pinned Node.js
id: setup-node
if: steps.context.outputs.ready == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.16.0'
node-version: '22.18.0'
- name: Install and preflight Claude subprocess isolation
id: isolation
@@ -377,7 +377,7 @@ jobs:
.github/claude-canary-runtime/package-lock.json \
"${runtime_dir}/package-lock.json"
printf '%s\n' 'registry=https://registry.npmjs.org/' 'audit=false' 'fund=false' > "${npmrc}"
test "$(node --version)" = 'v22.16.0'
test "$(node --version)" = 'v22.18.0'
test "$(uname -m)" = 'x86_64'
# The trusted lock and these independent receipts pin both the thin
@@ -398,7 +398,7 @@ jobs:
if (
lock.lockfileVersion !== 3 ||
lock.packages?.['']?.dependencies?.['@anthropic-ai/claude-code'] !== '2.1.214' ||
lock.packages?.['']?.engines?.node !== '22.16.0'
lock.packages?.['']?.engines?.node !== '22.18.0'
) {
throw new Error('Claude runtime lock root is not exact');
}
@@ -506,7 +506,7 @@ jobs:
install -m 0600 .github/gitnexus-review-runtime/package.json "${runtime_dir}/package.json"
install -m 0600 .github/gitnexus-review-runtime/package-lock.json "${runtime_dir}/package-lock.json"
printf '%s\n' 'registry=https://registry.npmjs.org/' 'audit=false' 'fund=false' > "${npmrc}"
test "$(node --version)" = 'v22.16.0'
test "$(node --version)" = 'v22.18.0'
npm ci \
--prefix "${runtime_dir}" \
--userconfig "${npmrc}" \
@@ -1241,7 +1241,7 @@ jobs:
CLAUDE_CONFIG_DIR: ${{ runner.temp }}/gitnexus-review-claude-config
CLAUDE_WORKING_DIR: ${{ runner.temp }}/gitnexus-review-control
NPM_CONFIG_IGNORE_SCRIPTS: 'true'
NODE_VERSION: '22.16.0'
NODE_VERSION: '22.18.0'
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
path_to_claude_code_executable: ${{ runner.temp }}/gitnexus-review-claude-runtime/node_modules/@anthropic-ai/claude-code/bin/claude.exe
+27 -5
View File
@@ -12,12 +12,34 @@
# App that opens the promotion PR). The Mint-App-Token step hard-fails
# without them once a promotion is detected. Verify the App installation
# is scoped to this repo with only Contents: RW + Pull requests: RW.
# [ ] Create the protected Environment `gitnexus-evolution` with a
# [x] Create the protected Environment `gitnexus-evolution` with a
# deployment-branch rule restricting it to `main`, and ideally scope the
# three secrets above to that Environment. workflow_dispatch runs this
# workflow (and eval/workflow_bench/evolve.py) from the *dispatched ref*,
# so this server-side rule — not a code-side guard the branch could edit
# away — is what stops a non-main branch from running with the secrets.
# [x] Register a self-hosted runner labeled `gitnexus-evolution` (a dedicated
# EC2 box works well). GitHub-hosted runners hard-cap job execution at 6
# hours, non-configurable — too short once a benchmark session actually
# invokes Skill/MCP tools for real. Self-hosted runners cap at 5 days
# instead. This job only ever runs on schedule/workflow_dispatch, never
# on fork-PR content, so the usual public-repo self-hosted-runner risk
# doesn't apply — still keep the box dedicated to this workflow, with
# outbound-only network access, and prefer on-demand over Spot (a Spot
# reclaim mid-run loses the same way a 6-hour timeout does). Instance,
# security group, and IAM setup are documented privately, not in this
# repo — publishing the exact topology of a real, live AWS account
# isn't safe to do in a public repo even without literal secrets.
# Accepted tradeoff: the box is stopped between runs (an EventBridge
# schedule starts it ~15min before the Saturday cron and stops it 24h
# later) but is not destroyed/recreated per run, so it isn't fully
# ephemeral — a compromise between the review-flagged ideal (re-image
# between runs, bounding how long the injected model API key could
# matter if the box were ever compromised some other way) and the added
# complexity of per-job ephemeral provisioning for a job that runs at
# most weekly. Revisit if run frequency increases or the threat model
# changes; stopping already bounds the exposure window to the job's own
# runtime on 1 day out of 7.
# [ ] Run workflow_dispatch once and confirm: containment preflight passes,
# the benchmark completes inside the job timeout, the results artifact
# uploads, and a promotion (if any) opens a well-formed PR.
@@ -77,13 +99,13 @@ jobs:
github.event_name == 'workflow_dispatch' ||
vars.GITNEXUS_EVOLUTION_ENABLED == 'true'
)
runs-on: ubuntu-latest
runs-on: [self-hosted, linux, x64, gitnexus-evolution]
# Gate promotion runs on a protected Environment. An admin must attach a
# deployment-branch rule (main only) and ideally scope the three secrets to
# it — server-side enforcement a dispatched non-main ref cannot bypass by
# editing its own workflow copy. See the activation checklist above.
environment: gitnexus-evolution
timeout-minutes: 355 # ceiling just under GitHub's 360-minute hard cap
timeout-minutes: 1440 # self-hosted ceiling is 5 days (7200min); 24h is a generous margin over a single-generation serial run
permissions:
contents: read # The promotion PR uses a short-lived App token minted below.
env:
@@ -108,9 +130,9 @@ jobs:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.16.0'
node-version: '22.18.0'
cache: npm
cache-dependency-path: |
gitnexus/package-lock.json
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
repository: ${{ github.event.pull_request.head.repo.full_name }}
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
+2 -2
View File
@@ -369,7 +369,7 @@ jobs:
exit 1
fi
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
# Node 24 ships with npm >= 11.5.x, which is the minimum that
# supports npm Trusted Publishing OIDC. Node 22 ships with npm
@@ -828,7 +828,7 @@ jobs:
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v2
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v2
with:
tag_name: ${{ steps.vtag-gate.outputs.vtag }}
name: >-
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: npm
+1 -2
View File
@@ -68,9 +68,8 @@ gitnexus-web/test-results/
eval/.coverage
eval/.hypothesis/
# Local docs (docs/plans/ stays tracked — gitnexus-plan output travels with the work)
# Local docs — planning output (gitnexus-plan / gitnexus-work) stays local, not tracked
docs/*
!docs/plans/
gitnexus/test/fixtures/mini-repo/*.md
gitnexus/test/fixtures/mini-repo/.claude
+1 -1
View File
@@ -13,7 +13,7 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
## Development setup
**Prerequisites:** Node.js — `gitnexus/` requires `>=22.0.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
**Prerequisites:** Node.js — `gitnexus/` requires `^22.18.0 || >=24.11.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
1. Clone the repository.
2. **Shared package:** `cd gitnexus-shared && npm install && npm run build`
+7 -4
View File
@@ -181,7 +181,7 @@ flowchart TB
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
| `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams |
### Agent skills installed to `.claude/skills/` automatically
### Agent skills installed to `.claude/skills/` and `.agents/skills/` (if `.agents/` exists) automatically
- **Exploring** — navigate unfamiliar code using the knowledge graph
- **Debugging** — trace bugs through call chains
@@ -198,6 +198,8 @@ flowchart TB
**Repo-specific skills** — run `gitnexus analyze --skills` and GitNexus detects the functional areas of your codebase (via Leiden community detection) and generates each one as a direct project skill under `.claude/skills/gitnexus-area-<name>/`. Each skill describes a module's key files, entry points, execution flows, and cross-area connections, and is regenerated on each `--skills` run to stay current.
When a repo contains an `.agents/` directory, the standard and generated skills are also mirrored to `.agents/skills/` (e.g. `.agents/skills/gitnexus-cli/`, `.agents/skills/gitnexus-area-<name>/`) so agents that read repo-local `.agents/skills/` (like Codex) stay in sync.
## Editor Setup
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. Run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list, e.g. `gitnexus setup -c cursor,codex`.
@@ -395,7 +397,7 @@ gitnexus analyze --skills # Generate repo-specific skill files from detec
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search)
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
gitnexus analyze --skip-skills # Skip installing standard skill files under .claude/skills/ and .agents/skills/
gitnexus analyze --skip-git # Index folders that are not Git repositories
gitnexus analyze --default-branch develop # Branch used in the generated regression-compare example (base_ref)
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
@@ -451,7 +453,7 @@ Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `ana
// over its fix on every analyze. (Alias: "branch".)
"defaultBranch": "develop",
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
"skipSkills": true, // don't install standard .claude/skills/gitnexus-* skills
"skipSkills": true, // don't install standard skill files under .claude/skills/ and .agents/skills/
"embeddings": true, // generate embeddings by default
"workerTimeout": 60,
}
@@ -488,9 +490,10 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
+318 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
import shutil
import stat
import subprocess
import sys
@@ -19,10 +20,16 @@ from workflow_bench.process_control import ManagedProcessResult, run_managed
from workflow_bench.proposer_sandbox import (
MAX_BUNDLE_BYTES,
MAX_EVIDENCE_FILE_BYTES,
SANDBOX_NODE,
SANDBOX_NODE_PREFIX,
VITE_TEMP_DIR,
SANDBOX_PATH,
SANDBOX_PYTHON3,
SANDBOX_SHELL_PREFIX,
SANDBOX_USER_SKILLS,
ReadOnlyMount,
SandboxError,
_runtime_mount_args,
build_claude_settings,
build_sandbox_environment,
prepare_sandbox,
@@ -161,7 +168,28 @@ def test_sandbox_command_has_minimal_mounts_and_no_host_root_bind(tmp_path: Path
check=False,
)
assert probe.returncode == 0, probe.stderr
assert probe.stdout == "/home/agent|/opt/claude:/usr/local/bin:/usr/bin:/bin"
assert probe.stdout == f"/home/agent|{SANDBOX_PATH}"
# The evidence-provenance.mjs plan-writer's PATH-scan trusts a Python 3
# candidate only if it (and its directory) is owned by root or by the
# current process — real /usr/bin/python3 is root-owned on the host,
# which surfaces as the kernel's overflow uid inside this
# --unshare-user sandbox (root itself is never mapped in). This wrapper
# is freshly created by the host process instead, so it's trusted, and
# it must still exec through to a real, working Python 3.
python3_index = argv.index(SANDBOX_PYTHON3)
assert argv[python3_index - 2] == "--ro-bind"
python3_wrapper = Path(argv[python3_index - 1])
assert stat.S_IMODE(python3_wrapper.stat().st_mode) == 0o500
version = subprocess.run(
[str(python3_wrapper), "-I", "-S", "-c", "import sys; print(sys.version_info[0])"],
text=True,
capture_output=True,
check=False,
)
assert version.returncode == 0, version.stderr
assert version.stdout.strip() == "3"
assert SANDBOX_USER_SKILLS in argv
user_skills_index = argv.index(SANDBOX_USER_SKILLS)
assert argv[user_skills_index - 2] == "--ro-bind"
@@ -169,6 +197,223 @@ def test_sandbox_command_has_minimal_mounts_and_no_host_root_bind(tmp_path: Path
assert not private_root.exists()
def test_runtime_mounts_bind_the_resolved_node_to_a_fresh_sandbox_path(monkeypatch) -> None:
# sanitized_graph.py and runner_sessions.py invoke the sandboxed graph CLI
# via SANDBOX_NODE. node's real host location varies (GitHub-hosted
# runner images happen to have one under /usr/local/bin; a self-hosted
# runner's actions/setup-node installs into its own tool-cache directory
# instead), so this must bind to a FRESH sandbox path like /opt/claude/...
# rather than anywhere under /usr, /bin, /lib, or /lib64: those are
# already read-only bound by this same function, and bwrap can't create
# a new mount-point file inside an already-read-only tree when the real
# path doesn't already exist there on the host (observed empirically:
# "bwrap: Can't create file at /usr/local/bin/node: Read-only file
# system" when this bind first targeted that path on a self-hosted
# runner where node isn't really there).
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: "/opt/hostedtoolcache/node/22.18.0/x64/bin/node" if name == "node" else None,
)
args = _runtime_mount_args()
node_index = args.index("/opt/hostedtoolcache/node/22.18.0/x64/bin/node")
assert args[node_index - 1] == "--ro-bind"
assert args[node_index + 1] == SANDBOX_NODE
assert not any(SANDBOX_NODE.startswith(bound + "/") for bound in ("/usr", "/bin", "/lib", "/lib64"))
def test_runtime_mounts_bind_the_node_prefix_so_npx_and_npm_resolve(monkeypatch, tmp_path) -> None:
# npx and npm are not standalone binaries -- they are symlinks into
# ../lib/node_modules/npm/bin/*-cli.js -- so binding the sibling files is
# not enough; the install prefix carrying both bin/ and lib/node_modules
# has to be mounted. Without this, a self-hosted runner (where
# actions/setup-node installs into its own tool cache, outside /usr) gets
# a sandbox with node but no npx, and every task verify command dies with
# "/bin/sh: 1: npx: not found" -- all 18 runs of skill-evolution run
# 29861768554 did exactly that.
prefix = tmp_path / "hostedtoolcache" / "node" / "22.18.0" / "x64"
(prefix / "bin").mkdir(parents=True)
(prefix / "bin" / "node").write_text("#!/bin/sh\nexit 0\n")
(prefix / "lib" / "node_modules" / "npm" / "bin").mkdir(parents=True)
(prefix / "lib" / "node_modules" / "npm" / "bin" / "npx-cli.js").write_text("")
(prefix / "bin" / "npx").symlink_to("../lib/node_modules/npm/bin/npx-cli.js")
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
)
args = _runtime_mount_args()
prefix_index = args.index(str(prefix))
assert args[prefix_index - 1] == "--ro-bind"
assert args[prefix_index + 1] == SANDBOX_NODE_PREFIX
# the single-binary bind stays: sanitized_graph.py and runner_sessions.py
# invoke SANDBOX_NODE directly.
node_index = args.index(str(prefix / "bin" / "node"))
assert args[node_index + 1] == SANDBOX_NODE
# and the prefix's bin/ must actually be on PATH for npx to resolve.
assert f"{SANDBOX_NODE_PREFIX}/bin" in SANDBOX_PATH.split(":")
def test_runtime_mounts_skip_the_prefix_bind_for_an_unrecognized_node_layout(monkeypatch, tmp_path) -> None:
# The prefix is derived from the node binary's path, so it must only be
# trusted when the layout really is <prefix>/bin/node carrying npm.
# Otherwise parent.parent names an unrelated ancestor: /opt/bin/node would
# bind ALL of /opt (every tool cache on a hosted runner) and a bare
# <dir>/node would bind <dir>'s parent -- an over-broad mount into a
# sandbox that runs untrusted model-authored code. The pre-existing
# real-Bubblewrap node canary builds exactly this bare <dir>/node shape.
bare = tmp_path / "toolcache"
bare.mkdir()
(bare / "node").write_text("#!/bin/sh\nexit 0\n")
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(bare / "node") if name == "node" else None,
)
args = _runtime_mount_args()
assert SANDBOX_NODE_PREFIX not in args
assert str(tmp_path) not in args
# the node bind itself is unaffected -- SANDBOX_NODE still works.
assert args[args.index(str(bare / "node")) + 1] == SANDBOX_NODE
def test_runtime_mounts_skip_the_prefix_bind_without_npx_beside_node(monkeypatch, tmp_path) -> None:
# Right <prefix>/bin/node shape, but no working npx beside it: binding the
# prefix would widen the mount surface without making npx resolvable.
prefix = tmp_path / "x64"
(prefix / "bin").mkdir(parents=True)
(prefix / "bin" / "node").write_text("#!/bin/sh\nexit 0\n")
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
)
args = _runtime_mount_args()
assert SANDBOX_NODE_PREFIX not in args
def test_runtime_mounts_bind_a_real_tool_cache_layout(monkeypatch, tmp_path) -> None:
# The positive counterpart: a genuine <prefix>/bin/node install carrying
# npm, outside the system trees, is bound so npx resolves.
prefix = tmp_path / "node" / "22.18.0" / "x64"
(prefix / "bin").mkdir(parents=True)
(prefix / "bin" / "node").write_text("#!/bin/sh\nexit 0\n")
(prefix / "lib" / "node_modules" / "npm" / "bin").mkdir(parents=True)
(prefix / "lib" / "node_modules" / "npm" / "bin" / "npx-cli.js").write_text("")
(prefix / "bin" / "npx").symlink_to("../lib/node_modules/npm/bin/npx-cli.js")
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
)
args = _runtime_mount_args()
prefix_index = args.index(SANDBOX_NODE_PREFIX)
assert args[prefix_index - 2] == "--ro-bind"
assert args[prefix_index - 1] == str(prefix)
def test_runtime_mounts_skip_the_prefix_bind_when_it_is_already_bound(monkeypatch) -> None:
# On an image where node genuinely lives in /usr/local/bin, the prefix is
# /usr/local -- already inside the wholesale /usr read-only bind. Binding
# it again would be redundant and would needlessly widen the argv, so the
# containment surface stays minimal.
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: "/usr/local/bin/node" if name == "node" else None,
)
args = _runtime_mount_args()
assert SANDBOX_NODE_PREFIX not in args
assert args[args.index("/usr/local/bin/node") + 1] == SANDBOX_NODE
def test_runtime_mounts_skip_the_node_bind_when_node_is_unresolvable(monkeypatch) -> None:
monkeypatch.setattr("workflow_bench.proposer_sandbox.shutil.which", lambda name: None)
args = _runtime_mount_args()
assert SANDBOX_NODE not in args
def test_node_modules_mounts_get_a_writable_vite_temp_overlay(tmp_path: Path) -> None:
# vite writes <node_modules>/.vite-temp/<config>.timestamp-*.mjs before
# loading a TypeScript config, so a read-only dependency mount makes vitest
# fail with EROFS before any test runs -- and every task verify command and
# every hidden oracle ends in "npx vitest run <test>". Reproduced on the
# self-hosted runner with npx bypassed entirely, proving it is independent
# of the node-prefix mount.
clone = tmp_path / "clone"
clone.mkdir()
deps = tmp_path / "deps"
deps.mkdir()
# task_assets.py captures this directory into the dependency snapshot; the
# overlay is gated on the mount source actually carrying it.
(deps / VITE_TEMP_DIR).mkdir()
executable = tmp_path / "executable"
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)
with prepare_sandbox(
clone=clone,
claude_bin=executable,
bwrap_bin=executable,
preflight=False,
read_only_mounts=(ReadOnlyMount(source=deps, target="/workspace/gitnexus/node_modules"),),
) as sandbox:
argv = sandbox.command_prefix
bind_index = argv.index("/workspace/gitnexus/node_modules")
assert argv[bind_index - 2 : bind_index + 1] == ["--ro-bind", str(deps), "/workspace/gitnexus/node_modules"]
overlay = f"/workspace/gitnexus/node_modules/{VITE_TEMP_DIR}"
overlay_index = argv.index(overlay)
assert argv[overlay_index - 1] == "--tmpfs"
# the overlay must come AFTER the read-only bind, or the bind would mask it
assert overlay_index > bind_index
def test_node_modules_mount_without_a_captured_vite_temp_gets_no_overlay(tmp_path: Path) -> None:
# The trusted GitNexus runtime mounts /opt/gitnexus/node_modules, whose
# source is the built runtime and does NOT carry a .vite-temp. bwrap cannot
# mkdir a mount point inside a read-only bind, so overlaying it would fail
# with "Can't mkdir .../node_modules/.vite-temp: Read-only file system".
# Regression for that CI failure: the overlay must fire only where the
# source actually contains the directory, not for every node_modules mount.
clone = tmp_path / "clone"
clone.mkdir()
runtime = tmp_path / "runtime-node-modules"
runtime.mkdir() # deliberately no .vite-temp
executable = tmp_path / "executable"
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)
with prepare_sandbox(
clone=clone,
claude_bin=executable,
bwrap_bin=executable,
preflight=False,
read_only_mounts=(ReadOnlyMount(source=runtime, target="/opt/gitnexus/node_modules"),),
) as sandbox:
argv = sandbox.command_prefix
assert "/opt/gitnexus/node_modules" in argv
assert not any(str(item).endswith(f"/{VITE_TEMP_DIR}") for item in argv)
def test_non_node_modules_mounts_get_no_vite_temp_overlay(tmp_path: Path) -> None:
# Scoped to dependency mounts: a hidden-oracle or skill mount stays wholly
# read-only, with no writable island inside it.
clone = tmp_path / "clone"
clone.mkdir()
other = tmp_path / "oracle"
other.mkdir()
executable = tmp_path / "executable"
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)
with prepare_sandbox(
clone=clone,
claude_bin=executable,
bwrap_bin=executable,
preflight=False,
read_only_mounts=(ReadOnlyMount(source=other, target="/workspace/.wfbench-oracle-abc"),),
) as sandbox:
argv = sandbox.command_prefix
assert not any(str(item).endswith(f"/{VITE_TEMP_DIR}") for item in argv)
def test_stricter_prefix_freezes_evaluated_skills_and_can_unshare_network(tmp_path: Path) -> None:
clone = tmp_path / "clone"
skill = clone / ".claude" / "skills" / "gitnexus-work"
@@ -197,6 +442,78 @@ def test_stricter_prefix_freezes_evaluated_skills_and_can_unshare_network(tmp_pa
assert prefix[user_index - 2] == "--ro-bind"
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
)
def test_real_bubblewrap_runs_node_from_outside_the_bound_trees(tmp_path: Path, monkeypatch) -> None:
# Reproduces the self-hosted-runner failure directly: node resolved from
# a path outside /usr, /bin, /lib, /lib64 (actions/setup-node's own
# tool-cache convention) must still be reachable inside the sandbox at
# SANDBOX_NODE. A real node copied to a fresh, non-system location stands
# in for the tool-cache install; argv-construction tests alone can't
# catch a bwrap-level "Can't create file ...: Read-only file system"
# (the actual error this fix resolves), only a real bwrap invocation can.
real_node = shutil.which("node")
if not real_node:
pytest.skip("no node on PATH to relocate for this canary")
toolcache = tmp_path / "toolcache"
toolcache.mkdir()
relocated_node = toolcache / "node"
shutil.copy2(real_node, relocated_node)
relocated_node.chmod(0o755)
# Only fake "node"'s resolution -- prepare_sandbox's own bwrap/claude
# lookups (_resolve_executable) also go through shutil.which, and must
# keep resolving for real or preflight fails before the sandbox is even
# built.
real_which = shutil.which
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(relocated_node) if name == "node" else real_which(name),
)
clone = tmp_path / "clone"
clone.mkdir()
with prepare_sandbox(clone=clone, claude_bin=Path(sys.executable), preflight=True) as sandbox:
result = sandbox.run([SANDBOX_NODE, "--version"], timeout=10)
assert result.ok, result.stderr_tail
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
)
def test_real_bubblewrap_runs_npx_from_outside_the_bound_trees(tmp_path: Path, monkeypatch) -> None:
# The npx half of the self-hosted-runner failure. Relocating a real node
# INSTALL (bin/ + lib/node_modules, not just the binary) to a fresh path
# outside /usr, /bin, /lib and /lib64 reproduces actions/setup-node's
# tool-cache convention. Every task verify command is
# "cd gitnexus && npx tsc ... && npx vitest ...", so npx must resolve
# inside the sandbox; argv assertions cannot prove a bwrap-level mount
# actually works, only a real invocation can.
real_node = shutil.which("node")
if not real_node:
pytest.skip("no node on PATH to relocate for this canary")
real_prefix = Path(real_node).resolve().parent.parent
if not (real_prefix / "lib" / "node_modules" / "npm").is_dir():
pytest.skip(f"node at {real_node} has no npm under its install prefix")
toolcache = tmp_path / "toolcache" / "node" / "22.18.0" / "x64"
shutil.copytree(real_prefix, toolcache, symlinks=True)
relocated_node = toolcache / "bin" / "node"
assert relocated_node.exists()
real_which = shutil.which
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(relocated_node) if name == "node" else real_which(name),
)
clone = tmp_path / "clone"
clone.mkdir()
with prepare_sandbox(clone=clone, claude_bin=Path(sys.executable), preflight=True) as sandbox:
result = sandbox.run(["/bin/sh", "-c", "command -v npx && npx --version"], timeout=60)
assert result.ok, result.stderr_tail
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
@@ -750,4 +1067,3 @@ for line in sys.stdin:
assert bash_result.get("is_error") is not True, bash_result
assert (clone / "bash-called").read_text() == "canary"
assert (clone / "mcp-called").read_text() == "ok"
+119
View File
@@ -262,3 +262,122 @@ def test_phase_workspace_accepts_new_regular_review_output(tmp_path):
artifact.write_text("new review")
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_ignores_claude_sandbox_bootstrap_noise(tmp_path):
# Reproduced empirically: Claude Code's own enableWeakerNestedSandbox
# bootstrap creates this exact set of paths on every session regardless
# of task or model output (a trivial "say OK" prompt was enough). None
# of it is something the model decided to write, so it must not read as
# an unauthorized planning-phase change.
before = runner_artifacts.workspace_snapshot(tmp_path)
(tmp_path / ".claude" / "agents").mkdir(parents=True)
(tmp_path / ".claude" / "commands").mkdir(parents=True)
(tmp_path / ".claude" / ".cc-writes").write_text("{}")
(tmp_path / ".env").write_text("")
(tmp_path / ".env.development.local").write_text("")
(tmp_path / ".npmrc").write_text("")
(tmp_path / "package.json").write_text("{}")
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / ".bin").mkdir()
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_still_rejects_a_genuinely_unauthorized_change(tmp_path):
# The bootstrap-noise exclusion must stay narrow: an actual source-file
# edit outside the allowed artifact still has to be caught.
before = runner_artifacts.workspace_snapshot(tmp_path)
(tmp_path / "src.py").write_text("changed")
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_ignores_nested_claude_sandbox_bootstrap_noise(tmp_path):
# Claude Code bootstraps into whatever directory it is running in, not just
# the workspace root. The benchmark's task prompts cd into gitnexus/, so the
# same noise lands one level down -- observed verbatim in skill-evolution run
# 29861768554, where 13 of 18 sessions failed with
# "phase changed unauthorized workspace path(s): gitnexus/.claude/.cc-writes".
nested = tmp_path / "gitnexus" / ".claude"
nested.mkdir(parents=True)
(nested / "settings.local.json").write_text("{}")
before = runner_artifacts.workspace_snapshot(tmp_path)
(nested / ".cc-writes").write_text("{}")
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_does_not_descend_into_nested_bootstrap_directories(tmp_path):
# The exclusion must skip an entry before it is queued for traversal, so
# content created *inside* the ignored directory stays invisible too.
nested = tmp_path / "gitnexus" / ".claude" / ".cc-writes"
nested.mkdir(parents=True)
before = runner_artifacts.workspace_snapshot(tmp_path)
(nested / "pending.json").write_text('{"writes": 1}')
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_still_rejects_nested_real_claude_config(tmp_path):
# gitnexus/.claude/settings.local.json is real tracked repository content.
# Excluding ".claude" wholesale at depth would blind the check to it, so the
# exclusion must name only the entries Claude Code itself creates.
nested = tmp_path / "gitnexus" / ".claude"
nested.mkdir(parents=True)
settings = nested / "settings.local.json"
settings.write_text("{}")
before = runner_artifacts.workspace_snapshot(tmp_path)
settings.write_text('{"permissions": "changed"}')
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_still_rejects_nested_package_json(tmp_path):
# package.json is in WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE, but only as a
# workspace-root entry: gitnexus/package.json is real tracked content whose
# edits must still be caught.
nested = tmp_path / "gitnexus"
nested.mkdir()
manifest = nested / "package.json"
manifest.write_text("{}")
before = runner_artifacts.workspace_snapshot(tmp_path)
manifest.write_text('{"version": "9.9.9"}')
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_still_sees_writes_under_a_pre_existing_nested_claude_dir(tmp_path):
# Every excluded name is a blind spot. .claude/agents and .claude/commands
# are deliberately NOT excluded at depth: once a .claude directory exists
# (gitnexus/.claude/settings.local.json is tracked), anything written
# underneath an excluded entry is invisible to this check, and Claude Code
# loads .claude/agents relative to its cwd -- which these tasks point at
# gitnexus/. A planning phase must not be able to plant a definition there
# for the later work phase to read.
nested = tmp_path / "gitnexus" / ".claude"
nested.mkdir(parents=True)
(nested / "settings.local.json").write_text("{}")
before = runner_artifacts.workspace_snapshot(tmp_path)
(nested / "agents").mkdir()
(nested / "agents" / "planted.md").write_text("planted agent definition")
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
+55 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
import pytest
from workflow_bench.proposer_sandbox import SandboxError
from workflow_bench.proposer_sandbox import VITE_TEMP_DIR, SandboxError
from workflow_bench.oracle_assets import TaskOracleSnapshot
from workflow_bench.runner_tasks import resolve_task_bindings
from workflow_bench.task_assets import TaskAssetCache, stage_task_assets
@@ -113,6 +113,27 @@ def test_small_assets_use_a_bounded_buffered_fallback(monkeypatch, tmp_path: Pat
assert (clone / "second").read_bytes() == b"def"
def test_default_buffered_fallback_budget_covers_a_realistic_large_asset(
monkeypatch,
tmp_path: Path,
) -> None:
# 20 MiB exceeds the old 16 MiB default but must fit comfortably under
# the current default, proving the real (non-monkeypatched) budget
# constant is sized for a realistic large sandbox_copy asset such as the
# harness's own pre-built graph index, not just tiny fixtures.
payload = os.urandom(20 * 1024 * 1024)
repo, task = _repo_and_task(tmp_path, {"large": payload})
clone = tmp_path / "clone"
clone.mkdir()
monkeypatch.setattr(task_assets, "_try_reflink", lambda *_args: False)
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
snapshot.materialize(clone)
assert (clone / "large").read_bytes() == payload
def test_large_asset_without_reflink_fails_before_publish_and_cleans_staging(
monkeypatch,
tmp_path: Path,
@@ -389,3 +410,36 @@ def test_resolved_task_binding_carries_dependency_digests_and_rejects_live_drift
(repo / "dependency" / "package.json").write_bytes(b'{"version":2}')
with pytest.raises(ValueError, match="definition drifted"):
resolve_task_bindings([task], [binding], oracle_snapshots=[oracle])
def test_node_modules_dependency_snapshot_captures_the_vite_temp_mount_point(tmp_path: Path) -> None:
# bwrap cannot mkdir a mount point inside an already-read-only bind, so the
# directory vite needs must exist in the captured dependency bytes. It is
# recorded during capture, which puts it inside the manifest and both
# dependency digests rather than leaving it an untracked mutation of a
# digest-bound snapshot.
repo, _ = _repo_and_task(tmp_path, {"dependency/package.json": b'{"version":1}'})
task = {
"sandbox_copy": [],
"sandbox_dependencies": [{"source": "dependency", "target": "gitnexus/node_modules"}],
}
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
captured = {entry.path.as_posix() for entry in snapshot.dependencies[0].entries}
assert f"payload/{VITE_TEMP_DIR}" in captured
vite_temp = next((snapshot.root / "dependencies").glob(f"*/payload/{VITE_TEMP_DIR}"))
assert vite_temp.is_dir()
def test_non_node_modules_dependency_snapshot_has_no_vite_temp(tmp_path: Path) -> None:
# The capture is scoped to dependency mounts whose target is node_modules;
# an unrelated vendored dependency is captured byte-for-byte as declared.
repo, _ = _repo_and_task(tmp_path, {"dependency/package.json": b'{"version":1}'})
task = {
"sandbox_copy": [],
"sandbox_dependencies": [{"source": "dependency", "target": "vendor/dependency"}],
}
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
captured = {entry.path.as_posix() for entry in snapshot.dependencies[0].entries}
assert not any(path.endswith(VITE_TEMP_DIR) for path in captured)
+58 -1
View File
@@ -10,6 +10,7 @@ import yaml
from workflow_bench.runner import (
aggregate,
broken_incumbent_arms,
build_parser,
infra_error_record,
normalized_model_identifier,
@@ -64,6 +65,7 @@ def test_aggregate_takes_medians_and_counts_resolved():
"valid_runs": 3,
"excluded_runs": 0,
"transcripts_missing": 0,
"error_kinds": {},
}
@@ -172,7 +174,7 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
}
assert containment["timeout-minutes"] == 20
assert containment_node_setup["with"] == {
"node-version": "22.16.0",
"node-version": "22.18.0",
"cache": "npm",
"cache-dependency-path": "gitnexus/package-lock.json\ngitnexus-shared/package-lock.json\n",
}
@@ -333,6 +335,61 @@ def test_render_report_surfaces_excluded_and_unverified_runs():
assert "no locatable session transcript" in report
def test_render_report_surfaces_why_each_row_failed():
results = {
"t": {
"workflow": aggregate(
[record(resolved=False, error_kind="plan-evidence-invalid")],
),
}
}
report = render_report(results)
assert "plan-evidence-invalid×1" in report
def test_broken_incumbent_arms_flags_an_incumbent_that_resolved_nothing():
results = {
"t1": {"workflow": aggregate([record(resolved=False, error_kind="plan-evidence-invalid")])},
"t2": {"workflow": aggregate([record(resolved=False, error_kind="plan-evidence-invalid")])},
}
assert broken_incumbent_arms(results, {"workflow"}) == ["workflow"]
def test_broken_incumbent_arms_ignores_a_merely_underperforming_candidate():
# The incumbent works fine; only the candidate arm fails. That's a normal,
# expected "bad candidate" outcome and must not read as a broken harness.
results = {
"t1": {
"workflow": aggregate([record(resolved=True)]),
"candidate_workflow": aggregate([record(resolved=False, error_kind="verify-failed")]),
},
}
assert broken_incumbent_arms(results, {"workflow"}) == []
def test_broken_incumbent_arms_flags_an_incumbent_with_zero_valid_runs():
# Every run excluded via an excluded-but-non-systemic error_kind
# ("evidence-unverified"): valid_runs == 0 for every task, which the old
# `valid_runs > 0` guard let sail through silently, and which the outage
# streak breaker also doesn't catch (it resets rather than accumulates
# on this exact error_kind -- see test_systemic_outage_streak_resets_on_non_outage).
results = {
"t1": {"workflow": aggregate([record(resolved=False, error_kind="evidence-unverified")])},
"t2": {"workflow": aggregate([record(resolved=False, error_kind="evidence-unverified")])},
}
assert results["t1"]["workflow"]["valid_runs"] == 0
assert broken_incumbent_arms(results, {"workflow"}) == ["workflow"]
def test_broken_incumbent_arms_ignores_partial_incumbent_failure():
# Resolved in at least one task — struggling, not broken.
results = {
"t1": {"workflow": aggregate([record(resolved=False, error_kind="verify-failed")])},
"t2": {"workflow": aggregate([record(resolved=True)])},
}
assert broken_incumbent_arms(results, {"workflow"}) == []
def test_infra_error_record_captures_the_failure_and_is_excluded():
exc = subprocess.TimeoutExpired(cmd="claude -p", timeout=5)
rec = infra_error_record(exc)
+133 -2
View File
@@ -167,6 +167,56 @@ def test_run_claude_forwards_the_named_model_to_every_session(monkeypatch, tmp_p
assert captured[captured.index("--model") + 1] == "claude-sonnet-4-20250514"
def test_run_claude_restricts_tools_via_tools_flag_outside_bare(monkeypatch, tmp_path):
# Outside --bare, the built-in toolset defaults to everything (subagents,
# WebFetch, Task, ...) and --allowedTools only pre-approves within that —
# it does not narrow it. --tools is what actually restricts the set, so a
# non-bare arm session must pass it or it silently gets a far wider
# toolset than intended.
captured: list[str] = []
def fake_run(command, **kwargs):
captured.extend(command)
return fake_cli_result(VALID_REPORT)
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
runner.run_claude(
"task",
tmp_path,
claude_bin="claude",
timeout=5,
bare=False,
allowed_tools=["Read", "Edit", "Bash", "Skill"],
)
tools_idx = captured.index("--tools")
assert captured[tools_idx + 1 : tools_idx + 5] == ["Read", "Edit", "Bash", "Skill"]
allowed_idx = captured.index("--allowedTools")
assert captured[allowed_idx + 1 : allowed_idx + 5] == ["Read", "Edit", "Bash", "Skill"]
def test_run_claude_omits_tools_flag_under_bare(monkeypatch, tmp_path):
# --bare already hard-restricts to Bash/Edit/Read on its own (a Claude
# Code design choice, not something --tools/--allowedTools can widen or
# narrow further), so bare sessions must not also pass --tools.
captured: list[str] = []
def fake_run(command, **kwargs):
captured.extend(command)
return fake_cli_result(VALID_REPORT)
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
runner.run_claude(
"task",
tmp_path,
claude_bin="claude",
timeout=5,
bare=True,
allowed_tools=["Read", "Edit", "Bash", "Skill"],
)
assert "--tools" not in captured
assert "--allowedTools" in captured
@pytest.mark.parametrize(
("proc", "expected_kind"),
[
@@ -282,6 +332,15 @@ def test_agent_tool_grants_are_exact_and_nomcp_has_no_graph_tools(monkeypatch, t
assert captured[3]["mcp_config_json"] == '{"mcpServers":{}}'
assert captured[3]["disallowed_tools"] == ["Skill", "mcp__gitnexus"]
# --bare hard-disables the Skill tool and every mcp__* tool regardless of
# --allowedTools (a Claude Code design choice, not something the harness
# can override) -- every arm here except baseline_nomcp needs Skill
# and/or MCP tools, so only baseline_nomcp may still run under --bare.
assert captured[0]["bare"] is False # workflow: planning session
assert captured[1]["bare"] is False # review
assert captured[2]["bare"] is False # workflow_direct
assert captured[3]["bare"] is True # baseline_nomcp
def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tmp_path):
runtime = tmp_path / "gitnexus"
@@ -290,10 +349,12 @@ def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tm
runtime / "dist" / "cli",
runtime / "node_modules",
runtime / "vendor",
runtime / "hooks" / "claude",
shared / "dist",
):
directory.mkdir(parents=True)
(runtime / "dist" / "cli" / "index.js").write_text("")
(runtime / "hooks" / "claude" / "resolve-analyze-cmd.cjs").write_text("")
(runtime / "package.json").write_text(json.dumps({"version": runner.PINNED_GITNEXUS_VERSION}))
(runtime / "node_modules" / "gitnexus-shared").symlink_to(shared, target_is_directory=True)
(shared / "package.json").write_text(json.dumps({"name": "gitnexus-shared"}))
@@ -316,6 +377,7 @@ def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tm
(runtime / "vendor", f"{runner.SANDBOX_GITNEXUS}/vendor"),
(shared / "dist", f"{runner.SANDBOX_GITNEXUS_SHARED}/dist"),
(shared / "package.json", f"{runner.SANDBOX_GITNEXUS_SHARED}/package.json"),
(runtime / "hooks" / "claude", f"{runner.SANDBOX_GITNEXUS}/hooks/claude"),
]
package = json.loads((runtime / "package.json").read_text())
assert package["version"] == runner.PINNED_GITNEXUS_VERSION
@@ -330,6 +392,12 @@ def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tm
assert shared / forbidden not in mounted_sources
assert f"{runner.SANDBOX_GITNEXUS_SHARED}/{forbidden}" not in mounted_targets
# Only hooks/claude is exposed, not the whole hooks/ directory (which also
# has an unrelated hooks/antigravity/ tree) and not the runtime root itself.
assert runtime / "hooks" not in mounted_sources
assert runtime / "hooks" / "antigravity" not in mounted_sources
assert f"{runner.SANDBOX_GITNEXUS}/hooks" not in mounted_targets
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
@@ -347,6 +415,7 @@ def test_real_bubblewrap_runtime_mount_imports_cli_without_exposing_checkout(tmp
f"{runner.SANDBOX_GITNEXUS}/vendor",
f"{runner.SANDBOX_GITNEXUS_SHARED}/dist/index.js",
f"{runner.SANDBOX_GITNEXUS_SHARED}/package.json",
f"{runner.SANDBOX_GITNEXUS}/hooks/claude/resolve-analyze-cmd.cjs",
]
forbidden = [
f"{runner.SANDBOX_GITNEXUS}/{relative}"
@@ -369,16 +438,26 @@ def test_real_bubblewrap_runtime_mount_imports_cli_without_exposing_checkout(tmp
preflight=True,
) as sandbox:
visibility = sandbox.run(
["/usr/local/bin/node", "-e", visibility_script],
[runner.SANDBOX_NODE, "-e", visibility_script],
timeout=10,
)
imported = sandbox.run(
["/usr/local/bin/node", runner.SANDBOX_GITNEXUS_ENTRYPOINT, "--version"],
[runner.SANDBOX_NODE, runner.SANDBOX_GITNEXUS_ENTRYPOINT, "--version"],
timeout=10,
)
# --version never reaches the `analyze` command, which is loaded via a
# lazy dynamic import and is the only path that pulls in
# resolve-invocation.ts's module-load-time require of hooks/claude/
# resolve-analyze-cmd.cjs. Require the compiled analyze module
# directly so this canary actually exercises that chain.
analyze_imported = sandbox.run(
[runner.SANDBOX_NODE, "-e", f"require('{runner.SANDBOX_GITNEXUS}/dist/cli/analyze.js')"],
timeout=10,
)
assert visibility.ok, visibility.stderr_tail
assert imported.ok, imported.stderr_tail
assert analyze_imported.ok, analyze_imported.stderr_tail
assert imported.stdout_tail.strip() == runner.PINNED_GITNEXUS_VERSION
@@ -1025,3 +1104,55 @@ def test_review_phase_rejects_workspace_or_skill_mutation(
assert rec["resolved"] is False
assert rec["error_kind"] == "review-evidence-invalid"
assert expected_detail in rec["error_detail"]
def _git(repo, *args, check=True):
return subprocess.run(["git", "-C", str(repo), *args], check=check, capture_output=True, text=True)
def _git_commit(repo, message):
_git(
repo,
"-c",
"user.name=test",
"-c",
"user.email=test@invalid",
"commit",
"--quiet",
"--allow-empty",
"-m",
message,
)
return _git(repo, "rev-parse", "HEAD").stdout.strip()
def test_make_worktree_clone_has_no_tags_but_keeps_all_branches(tmp_path):
# oracle_assets.MAX_CLONE_REFS refuses to sanitize a clone with more than
# 1024 refs; this repo's own history has 1000+ release-candidate tags, so
# a plain `git clone` of it (inheriting every tag) trips that cap on every
# benchmark session. make_worktree must not carry tags into its throwaway
# clone, but callers pass a bare SHA or "HEAD" as `ref` (never a branch
# name -- see evolve.py:476, runner.py:1037, sanitized_graph.py:345), so
# branch-fetching itself must stay untouched: a commit reachable only from
# a non-default branch must still resolve via the existing
# checkout(ref) -> checkout(origin/{ref}) fallback.
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "--quiet")
_git(repo, "checkout", "--quiet", "-b", "main")
_git_commit(repo, "base")
_git(repo, "tag", "v1.0.0-rc.1")
_git(repo, "checkout", "--quiet", "-b", "other")
other_sha = _git_commit(repo, "only on other")
_git(repo, "checkout", "--quiet", "main")
clones = tmp_path / "clones"
clones.mkdir()
target = runner.make_worktree(repo, other_sha, clones)
tags = _git(target, "tag").stdout.split()
assert tags == [], f"clone must carry no tags, found: {tags}"
current = _git(target, "rev-parse", "HEAD").stdout.strip()
assert current == other_sha
+95 -2
View File
@@ -26,7 +26,18 @@ SANDBOX_HOME = "/home/agent"
SANDBOX_TMP = "/tmp"
SANDBOX_CLAUDE = "/opt/claude/claude"
SANDBOX_SHELL_PREFIX = "/opt/claude/shell-prefix"
SANDBOX_PATH = "/opt/claude:/usr/local/bin:/usr/bin:/bin"
SANDBOX_PYTHON3 = "/opt/claude/python3"
SANDBOX_NODE = "/opt/claude/node"
SANDBOX_NODE_PREFIX = "/opt/claude/nodejs"
# Vite transpiles a TypeScript config into <node_modules>/.vite-temp before it
# loads anything, so a read-only dependency mount makes `vitest` die with EROFS
# before a single test runs -- and every task verify command and every hidden
# oracle ends in `npx vitest run <test>`. bwrap cannot create a mount point
# inside an already-read-only bind, so the directory is captured into the
# dependency snapshot (task_assets.py) and a tmpfs is overlaid on it here.
VITE_TEMP_DIR = ".vite-temp"
DEPENDENCY_MOUNT_BASENAME = "node_modules"
SANDBOX_PATH = f"/opt/claude:{SANDBOX_NODE_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin"
SANDBOX_GITNEXUS = "/opt/gitnexus"
SANDBOX_GITNEXUS_SHARED = "/opt/gitnexus-shared"
SANDBOX_GITNEXUS_REGISTRY = "/opt/gitnexus-registry"
@@ -349,10 +360,58 @@ def build_claude_settings() -> str:
def _runtime_mount_args() -> list[str]:
args: list[str] = []
for raw in ("/usr", "/bin", "/lib", "/lib64"):
system_trees = ("/usr", "/bin", "/lib", "/lib64")
for raw in system_trees:
path = Path(raw)
if path.exists():
args += ["--ro-bind", raw, raw]
# sanitized_graph.py and runner_sessions.py invoke the sandboxed graph
# CLI via SANDBOX_NODE. Bind whatever `node` actually resolves to on PATH
# there -- true node location varies by host (GitHub-hosted runner images
# happen to have one under /usr/local/bin; a self-hosted runner's
# actions/setup-node installs into its own tool-cache directory instead).
# Target must be a fresh path like /opt/claude/... rather than anywhere
# under /usr, /bin, /lib, or /lib64: those are already read-only bound
# above, and bwrap can't create a new mount-point file inside an
# already-read-only tree when the real path doesn't already exist there
# (the exact case a self-hosted runner hits, and the reason this bind
# exists at all).
node_bin = shutil.which("node")
if node_bin:
args += ["--ro-bind", node_bin, SANDBOX_NODE]
# The single-binary bind above gives SANDBOX_NODE but NOT npm or npx:
# those are symlinks into ../lib/node_modules/npm/bin/*-cli.js, so the
# install prefix carrying both bin/ and lib/node_modules has to be
# mounted for them to resolve at all. When node really lives under a
# system tree (/usr/local/bin on GitHub-hosted images) the prefix is
# already inside the wholesale read-only binds above and npm/npx came
# along for free -- which is exactly why this gap stayed invisible
# until a self-hosted runner put node in actions/setup-node's tool
# cache, outside /usr, and every task verify command
# ("cd gitnexus && npx tsc ... && npx vitest ...") died with
# "/bin/sh: 1: npx: not found". Skip the redundant bind in the
# already-covered case so the mount surface stays minimal.
#
# The prefix is only ever derived from a real <prefix>/bin/node layout
# that actually carries npm. Deriving it as parent.parent unconditionally
# would mount an unrelated ancestor whenever node sits somewhere else:
# /opt/bin/node would bind all of /opt (every tool cache on a hosted
# runner) and a bare <dir>/node would bind <dir>'s parent. This function
# exists to keep the sandbox surface minimal, so an unrecognized layout
# binds nothing extra and simply leaves npx unavailable, exactly as
# before.
node_bin_dir = Path(node_bin).resolve().parent
node_prefix = node_bin_dir.parent
# Test the property actually needed -- a working npx next to node in a
# real bin/ directory -- rather than a proxy like lib/node_modules/npm.
# .exists() follows the symlink, so a dangling npx correctly fails: it
# would not survive the mount either. Requiring the "bin" name keeps
# the parent.parent derivation honest; an npx sitting directly beside
# node in a flat directory would make that derivation name the wrong
# prefix.
provides_npx = node_bin_dir.name == "bin" and (node_bin_dir / "npx").exists()
if provides_npx and not any(node_prefix.is_relative_to(tree) for tree in system_trees):
args += ["--ro-bind", str(node_prefix), SANDBOX_NODE_PREFIX]
for raw in (
"/etc/ssl",
"/etc/hosts",
@@ -384,6 +443,24 @@ def _create_shell_prefix_wrapper(private_root: Path) -> Path:
return wrapper
def _create_python3_wrapper(private_root: Path) -> Path:
"""A trusted, self-owned Python 3 launcher for evidence-provenance.mjs's atomic mover.
/usr/bin/python3 is a real system binary, but it's root-owned on the host.
Inside this --unshare-user sandbox only the calling uid is mapped (root is
not), so root-owned files surface as the kernel's overflow uid — which
evidence-provenance.mjs's PATH-scan correctly refuses to trust. This
wrapper is freshly created by the same host process that owns
home/temp/shell-prefix, so it maps to the sandbox's own trusted uid
instead, and simply execs the real interpreter through to do the work.
"""
wrapper = private_root / "python3"
wrapper.write_text('#!/bin/bash\nset -eu\nexec /usr/bin/python3 "$@"\n')
wrapper.chmod(0o500)
return wrapper
def _resolve_executable(executable: Path | str | None, default: str) -> Path:
raw = os.fspath(executable) if executable is not None else shutil.which(default)
if not raw:
@@ -609,6 +686,20 @@ def _sandbox_command_prefix(
]
for mount in mounts:
args += ["--ro-bind", str(mount.source), mount.target]
# Overlay an empty writable tmpfs on the one path vite must write.
# Everything else in the mount, and the whole workspace, stays
# read-only, and the overlay lives only inside the sandbox -- it never
# reaches the host clone the credited patch is captured from.
#
# Gate on the mount SOURCE actually containing the directory, not on
# the target name: bwrap cannot create a mount point inside an
# already-read-only bind, so a tmpfs can only be overlaid where the
# directory already exists in the bound bytes. task_assets.py captures
# it into dependency-snapshot node_modules; other node_modules mounts
# (e.g. the trusted GitNexus runtime at /opt/gitnexus/node_modules) do
# not carry it, and overlaying them would fail with EROFS.
if PurePosixPath(mount.target).name == DEPENDENCY_MOUNT_BASENAME and (mount.source / VITE_TEMP_DIR).is_dir():
args += ["--tmpfs", f"{mount.target}/{VITE_TEMP_DIR}"]
args += ["--chdir", SANDBOX_WORKSPACE, "--"]
return args
@@ -641,6 +732,7 @@ def prepare_sandbox(
directory.mkdir(mode=0o700)
directory.chmod(0o700)
shell_prefix = _create_shell_prefix_wrapper(private_root)
python3_wrapper = _create_python3_wrapper(private_root)
# Claude may discover user-level skills below HOME. Keep the rest of HOME
# writable for normal CLI state, but overlay an immutable empty skills root
# so a model cannot shadow the evaluated repository/plugin skill by name.
@@ -651,6 +743,7 @@ def prepare_sandbox(
*read_only_mounts,
ReadOnlyMount(source=user_skills, target=SANDBOX_USER_SKILLS),
ReadOnlyMount(source=shell_prefix, target=SANDBOX_SHELL_PREFIX),
ReadOnlyMount(source=python3_wrapper, target=SANDBOX_PYTHON3),
)
primary: BaseException | None = None
try:
+62 -6
View File
@@ -78,6 +78,7 @@ from .proposer_sandbox import (
SANDBOX_GITNEXUS as SANDBOX_GITNEXUS,
SANDBOX_GITNEXUS_REGISTRY,
SANDBOX_GITNEXUS_SHARED as SANDBOX_GITNEXUS_SHARED,
SANDBOX_NODE as SANDBOX_NODE,
SANDBOX_WORKSPACE,
ReadOnlyMount,
SandboxError,
@@ -402,6 +403,13 @@ def run_arm(
auth_token=args.auth_token,
base_url=args.base_url,
)
# --bare hard-disables the Skill tool and every mcp__* tool — by Claude
# Code design, not a bug (--allowedTools can't restore what --bare
# removes). Every arm except baseline_nomcp needs Skill and/or MCP tools,
# so only baseline_nomcp can keep --bare's tighter isolation; the rest
# rely on ANTHROPIC_API_KEY alone (the sandboxed HOME has no OAuth/
# keychain state to conflict with it).
bare = arm == "baseline_nomcp"
common = {
"claude_bin": sandbox.claude_bin,
"timeout": args.timeout,
@@ -412,7 +420,7 @@ def run_arm(
read_only_paths=_evaluated_skill_roots(worktree, arm),
),
"require_pid_namespace": True,
"bare": True,
"bare": bare,
"settings_json": sandbox.settings_json,
"strict_mcp_config": True,
"mcp_config_json": sandbox_mcp_config(),
@@ -672,13 +680,21 @@ def aggregate(records: list[dict[str, Any]]) -> dict[str, Any]:
# unmeasured run makes the whole median unavailable so the gate won't rank
# a candidate on a cost that was never actually captured.
valid_costs = [r.get("cost_usd") for r in valid]
out["cost_usd"] = None if (not valid or any(cost is None for cost in valid_costs)) else statistics.median(valid_costs)
out["cost_usd"] = (
None if (not valid or any(cost is None for cost in valid_costs)) else statistics.median(valid_costs)
)
out["resolved"] = sum(1 for r in records if r["resolved"])
out["runs"] = len(records)
out["valid_runs"] = len(valid)
out["excluded_runs"] = len(records) - len(valid)
out["transcripts_missing"] = sum(1 for r in records if r.get("transcript_missing"))
out["class"] = records[0].get("class", "")
error_kinds: dict[str, int] = {}
for r in records:
kind = r.get("error_kind")
if kind:
error_kinds[kind] = error_kinds.get(kind, 0) + 1
out["error_kinds"] = error_kinds
return out
@@ -695,6 +711,33 @@ def savings(baseline: dict[str, Any], workflow: dict[str, Any]) -> dict[str, Any
return out
def broken_incumbent_arms(
results: dict[str, dict[str, dict[str, Any]]],
incumbent_arms: set[str],
) -> list[str]:
"""Incumbent arms that resolved nothing across every task they ran.
An incumbent arm is the currently-shipped, presumably-working skill: if it
resolves NOTHING across every task it ran, that reads as an environment or
harness failure (missing trusted interpreter, stale skill fingerprint,
sandbox misconfiguration), not a skill regression. A candidate merely
underperforming is a normal, expected outcome and must not trip this —
only checking incumbents keeps that distinction.
Deliberately does NOT require valid_runs > 0 per task: an incumbent that
fails every run with an excluded-but-non-systemic error_kind (e.g.
"evidence-unverified", which the outage-streak breaker explicitly resets
on rather than accumulates) would otherwise never accumulate a single
valid run and sail through silently — the exact "quiet no-promotion"
outcome this guard exists to catch, and arguably worse than the
some-runs-resolved-zero case since here nothing completed at all.
aggregate() never marks an excluded/unverifiable row resolved=True, so
resolved == 0 alone already covers both cases.
"""
present = incumbent_arms & {arm for arms in results.values() for arm in arms}
return sorted(arm for arm in present if all(arms[arm]["resolved"] == 0 for arms in results.values() if arm in arms))
def _na(value: Any) -> Any:
"""Render an unmeasured metric as ``n/a`` instead of a misleading number."""
return "n/a" if value is None else value
@@ -719,8 +762,8 @@ def render_report(results: dict[str, dict[str, dict[str, Any]]]) -> str:
"efficiency, sum usage from the session transcripts instead",
"(dedup events sharing one message.id).",
"",
"| task | class | arm | resolved | input | cache_create | cache_read | output | cost $ | wall s | turns | churn |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
"| task | class | arm | resolved | input | cache_create | cache_read | output | cost $ | wall s | turns | churn | errors |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
for task_id, arms in results.items():
for arm, agg in arms.items():
@@ -728,12 +771,14 @@ def render_report(results: dict[str, dict[str, dict[str, Any]]]) -> str:
resolved_cell = f"{agg['resolved']}/{agg.get('valid_runs', agg['runs'])}"
if excluded:
resolved_cell += f" ({excluded} excluded)"
error_cell = ", ".join(f"{kind}×{count}" for kind, count in sorted(agg.get("error_kinds", {}).items()))
lines.append(
f"| {task_id} | {agg['class']} | {arm} | {resolved_cell} "
f"| {agg['input_tokens']:.0f} | {agg['cache_creation_input_tokens']:.0f} "
f"| {agg['cache_read_input_tokens']:.0f} | {agg['output_tokens']:.0f} "
f"| {_cost_cell(agg['cost_usd'])} | {agg['duration_s']:.0f} | {agg['num_turns']:.0f} "
f"| {agg['diff_files']:.0f}/+{agg['diff_insertions']:.0f}/−{agg['diff_deletions']:.0f} |"
f"| {agg['diff_files']:.0f}/+{agg['diff_insertions']:.0f}/−{agg['diff_deletions']:.0f} "
f"| {error_cell} |"
)
for arm in arms:
if arm != "baseline" and "baseline" in arms:
@@ -742,7 +787,7 @@ def render_report(results: dict[str, dict[str, dict[str, Any]]]) -> str:
f"| {task_id} | {arms[arm]['class']} | **{arm} savings %** | — "
f"| {s['input_tokens']} | {s['cache_creation_input_tokens']} "
f"| {s['cache_read_input_tokens']} | {s['output_tokens']} "
f"| {_na(s['cost_usd'])} | {s['duration_s']} | — | — |"
f"| {_na(s['cost_usd'])} | {s['duration_s']} | — | — | — |"
)
lines.append("")
all_aggs = [agg for arms in results.values() for agg in arms.values()]
@@ -1326,6 +1371,17 @@ def main() -> None:
}
(out_dir / "promotion.json").write_text(json.dumps(promotion, indent=2) + "\n")
print(f"\n{report}\n\nWritten to {out_dir}/")
broken_incumbents = broken_incumbent_arms(results, set(CANDIDATE_ARMS.values()))
if broken_incumbents:
# Fail loudly rather than let a broken environment read as a quiet
# "no promotion, incumbent stands."
print(
f"[harness-health] incumbent arm(s) {', '.join(broken_incumbents)} resolved zero "
"tasks across every valid run — this looks like an environment/harness failure, "
"not a normal candidate miss. See the errors column in report.md and error_detail "
"in results.jsonl. Exiting non-zero rather than reporting a quiet no-promotion."
)
raise SystemExit(1)
if outage_tripped:
# Non-zero exit so a driver (evolve.py) treats the partial benchmark as a
# failed run and halts instead of proposing from outage-truncated evidence.
+72 -2
View File
@@ -21,6 +21,64 @@ MAX_WORKSPACE_SNAPSHOT_ENTRIES = 100_000
MAX_WORKSPACE_SNAPSHOT_PATH_BYTES = 16 * 1024 * 1024
MAX_WORKSPACE_SNAPSHOT_FILE_BYTES = 1024 * 1024 * 1024
# Claude Code's own enableWeakerNestedSandbox bootstrap creates these paths on
# EVERY session regardless of task or model output -- reproduced empirically
# with a trivial "say OK" prompt: a synthetic package.json/lockfiles/
# node_modules, a full set of .env variants, and .claude/agents,
# .claude/commands, .claude/.cc-writes. None of this is something the model
# decided to write, so it must not count as an "unauthorized" workspace
# change during the planning-phase boundary check (the one thing this
# snapshot is used for -- see workspace_snapshot's callers). Mirrors the
# pre-existing .git exclusion below, which is the same kind of harness/tool
# noise rather than substantive diff.
WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE = frozenset(
{
".claude",
".env",
".env.development",
".env.development.local",
".env.local",
".env.production",
".env.production.local",
".env.test",
".env.test.local",
".gitmodules",
".npmrc",
".yarnrc",
".yarnrc.yml",
"bunfig.toml",
"node_modules",
"package-lock.json",
"package.json",
"pnpm-lock.yaml",
"yarn.lock",
}
)
# The set above is matched at the workspace ROOT only, because most of its
# entries (package.json, node_modules, the .env family) are also legitimate
# repository content further down the tree -- gitnexus/package.json and
# gitnexus/.claude/settings.local.json are both tracked files whose edits must
# still be caught. But Claude Code bootstraps into whatever directory it is
# running in, so a task whose prompt cd's into a subdirectory gets the same
# noise one level down. Observed in skill-evolution run 29861768554: 13 of 18
# sessions failed with "phase changed unauthorized workspace path(s):
# gitnexus/.claude/.cc-writes". That entry is matched at ANY depth -- never
# ".claude" itself, which holds real configuration.
#
# Deliberately only .cc-writes. Every excluded name is a blind spot: once a
# .claude directory already exists (gitnexus/.claude/settings.local.json is
# tracked), anything a phase writes underneath an excluded entry becomes
# invisible to this check, and Claude Code loads .claude/agents relative to
# its cwd -- which these tasks point at gitnexus/. Adding "agents" and
# "commands" here on the theory that they might also appear nested would let a
# planning phase plant a definition that the later work phase reads, with no
# evidence in the boundary check. Only .cc-writes was ever observed nested, so
# only .cc-writes is excluded; extend this set from an observed failure, never
# pre-emptively.
CLAUDE_BOOTSTRAP_DIR = ".claude"
CLAUDE_BOOTSTRAP_ENTRIES = frozenset({".cc-writes"})
IMPLEMENTATION_ARMS = frozenset(
{
"workflow",
@@ -52,8 +110,19 @@ class VerificationResult:
yield self.output
def _is_bootstrap_noise(relative: PurePosixPath) -> bool:
"""Report whether a walked entry is harness noise rather than workspace change."""
parts = relative.parts
if parts[0] == ".git" or parts[0] in WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE:
return True
return len(parts) >= 2 and parts[-2] == CLAUDE_BOOTSTRAP_DIR and parts[-1] in CLAUDE_BOOTSTRAP_ENTRIES
def workspace_snapshot(worktree: Path) -> dict[str, str]:
"""Hash the workspace without following links, excluding Git internals."""
"""Hash the workspace without following links, excluding Git internals
and Claude Code's own sandbox-bootstrap noise (see
WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE)."""
root = worktree.expanduser().absolute()
mode = root.lstat().st_mode
@@ -74,7 +143,7 @@ def workspace_snapshot(worktree: Path) -> dict[str, str]:
raise ValueError(f"workspace snapshot directory is unreadable: {directory}: {exc}") from exc
for entry in children:
relative = relative_dir / entry.name
if relative.parts[0] == ".git":
if _is_bootstrap_noise(relative):
continue
entry_count += 1
path_bytes += len(relative.as_posix().encode())
@@ -240,6 +309,7 @@ def make_worktree(repo: Path, ref: str, parent: Path) -> Path:
"clone",
"--no-local",
"--no-hardlinks",
"--no-tags",
"--quiet",
str(repo),
str(target),
+11 -1
View File
@@ -18,6 +18,7 @@ from .proposer_sandbox import (
SANDBOX_GITNEXUS,
SANDBOX_GITNEXUS_REGISTRY,
SANDBOX_HOME,
SANDBOX_NODE,
SANDBOX_TMP,
SANDBOX_WORKSPACE,
SandboxError,
@@ -50,6 +51,8 @@ def measured_cost(raw: Any) -> float | None:
if not math.isfinite(raw) or raw < 0:
return None
return float(raw)
SANDBOX_GITNEXUS_ENTRYPOINT = f"{SANDBOX_GITNEXUS}/dist/cli/index.js"
SENSITIVE_EVENT_KEYS = frozenset(
{
@@ -113,7 +116,7 @@ def sandbox_mcp_config() -> str:
"PATH=/usr/local/bin:/usr/bin:/bin",
"LANG=C.UTF-8",
"GIT_TERMINAL_PROMPT=0",
"/usr/local/bin/node",
SANDBOX_NODE,
SANDBOX_GITNEXUS_ENTRYPOINT,
"mcp",
],
@@ -377,6 +380,13 @@ def run_claude(
if strict_mcp_config:
cmd += ["--strict-mcp-config", "--mcp-config", mcp_config_json or '{"mcpServers":{}}']
if allowed_tools:
# --bare's own hard-coded Bash/Edit/Read ceiling already scopes bare
# sessions; outside --bare the built-in toolset defaults to
# everything (subagents, WebFetch, Task, ...), so --tools is needed
# to actually restrict it — --allowedTools only pre-approves within
# whatever set is available, it does not narrow that set.
if not bare:
cmd += ["--tools", *allowed_tools]
cmd += ["--allowedTools", *allowed_tools]
if disable_slash_commands:
cmd.append("--disable-slash-commands")
+6
View File
@@ -217,6 +217,12 @@ def trusted_gitnexus_runtime_mounts() -> tuple[ReadOnlyMount, ...]:
f"{SANDBOX_GITNEXUS_SHARED}/package.json",
directory=False,
),
_validated_runtime_component(
runtime,
"hooks/claude",
f"{SANDBOX_GITNEXUS}/hooks/claude",
directory=True,
),
)
entrypoint = mounts[0].source / "cli" / "index.js"
+2 -1
View File
@@ -16,6 +16,7 @@ from .process_control import ManagedProcessError, run_managed
from .proposer_sandbox import (
SANDBOX_GITNEXUS,
SANDBOX_HOME,
SANDBOX_NODE,
SANDBOX_WORKSPACE,
ReadOnlyMount,
SandboxError,
@@ -248,7 +249,7 @@ def _run_graph_cli(
) -> bytes | None:
command = [
*prefix,
"/usr/local/bin/node",
SANDBOX_NODE,
SANDBOX_GITNEXUS_ENTRYPOINT,
*arguments,
]
+51 -21
View File
@@ -25,7 +25,9 @@ from pathlib import Path, PurePosixPath
from typing import Any
from .proposer_sandbox import (
DEPENDENCY_MOUNT_BASENAME,
SANDBOX_WORKSPACE,
VITE_TEMP_DIR,
ReadOnlyMount,
SandboxError,
_prepare_clone_target,
@@ -39,9 +41,14 @@ MAX_TASK_ASSET_ENTRIES = 100_000
MAX_TASK_ASSET_PATH_BYTES = 4_096
MAX_TASK_ASSET_BYTES = 2 * 1024 * 1024 * 1024
# A filesystem without reflink support may still run tiny fixtures. Large
# assets fail closed instead of silently returning to one full copy per arm.
MAX_BUFFERED_FALLBACK_BYTES = 16 * 1024 * 1024
# The largest known real sandbox_copy asset in this harness is the shipped
# index above (~428 MiB estimated, ~290 MiB measured); budget comfortably
# above that so it can still materialize via buffered copy on a filesystem
# that cannot reflink (ext4 CI runners, 9p-backed dev mounts), while staying
# well below MAX_TASK_ASSET_BYTES so a genuinely oversized or malformed
# declaration still fails closed instead of silently paying for a slow full
# copy.
MAX_BUFFERED_FALLBACK_BYTES = 512 * 1024 * 1024
COPY_CHUNK_BYTES = 1024 * 1024
# linux/fs.h: #define FICLONE _IOW(0x94, 9, int)
@@ -155,9 +162,11 @@ class TaskAssetSnapshot:
source = snapshot_root / Path(*dependency.snapshot_path.parts)
metadata = source.lstat()
expected_directory = dependency.kind == "directory"
if stat.S_ISLNK(metadata.st_mode) or (
expected_directory and not stat.S_ISDIR(metadata.st_mode)
) or (not expected_directory and not stat.S_ISREG(metadata.st_mode)):
if (
stat.S_ISLNK(metadata.st_mode)
or (expected_directory and not stat.S_ISDIR(metadata.st_mode))
or (not expected_directory and not stat.S_ISREG(metadata.st_mode))
):
raise SandboxError(f"dependency snapshot changed: {dependency.source}")
target = PurePosixPath(dependency.target)
_prepare_clone_target(
@@ -208,9 +217,7 @@ class TaskAssetCache:
repo_identity = _real_directory(repo, label="task asset repository")
declarations, relative_paths = _sandbox_copy_declarations(task)
dependency_declarations = _sandbox_dependency_declarations(task)
dependency_identity = tuple(
(declaration.source, declaration.target) for declaration in dependency_declarations
)
dependency_identity = tuple((declaration.source, declaration.target) for declaration in dependency_declarations)
definition = (str(repo_identity), resolved_sha, declarations, dependency_identity)
existing = self._by_definition.get(definition)
if existing is not None:
@@ -253,6 +260,21 @@ class TaskAssetCache:
dependency_builder.copy_descriptor(descriptor, PurePosixPath("payload"))
finally:
os.close(descriptor)
# vitest cannot start against a read-only node_modules: vite
# writes <node_modules>/.vite-temp/<config>.timestamp-*.mjs
# before loading a TypeScript config. bwrap cannot create
# that mount point inside an already-read-only bind, so the
# empty directory is captured here -- before the manifest and
# both dependency digests are computed, so it is part of the
# snapshot rather than an untracked mutation of it. The
# sandbox overlays a tmpfs on it; see VITE_TEMP_DIR.
payload_entry = dependency_builder.entries.get(PurePosixPath("payload"))
if (
payload_entry is not None
and payload_entry.kind == "directory"
and PurePosixPath(declaration.target).name == DEPENDENCY_MOUNT_BASENAME
):
dependency_builder.ensure_directory(PurePosixPath("payload") / VITE_TEMP_DIR)
dependency_entries = dependency_builder.finished_entries()
_validate_dependency_symlinks(
container,
@@ -457,10 +479,14 @@ class _SnapshotBuilder:
destination = self.destination / Path(*relative.parts)
os.symlink(target, destination)
after = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
if _mutation_identity(before) != _mutation_identity(after) or os.readlink(
name,
dir_fd=parent_descriptor,
) != target:
if (
_mutation_identity(before) != _mutation_identity(after)
or os.readlink(
name,
dir_fd=parent_descriptor,
)
!= target
):
raise SandboxError(f"dependency symlink changed while snapshotting: {relative}")
self.total_bytes += len(target_bytes)
self.budget.total_bytes += len(target_bytes)
@@ -498,6 +524,15 @@ class _SnapshotBuilder:
self.entries[entry.path] = entry
self.budget.entries += 1
def ensure_directory(self, relative: PurePosixPath) -> None:
"""Record and create one extra directory inside this snapshot.
Used for harness-owned mount points that must exist in the captured
bytes rather than be created against a read-only bind at runtime.
"""
self._record_directory(relative)
def finished_entries(self) -> tuple[AssetManifestEntry, ...]:
return tuple(sorted(self.entries.values(), key=lambda entry: entry.path.as_posix()))
@@ -562,9 +597,7 @@ def _sandbox_dependency_declarations(
or declaration.target_path in other.target_path.parents
or other.target_path in declaration.target_path.parents
):
raise SandboxError(
f"sandbox dependency targets overlap: {declaration.target} and {other.target}"
)
raise SandboxError(f"sandbox dependency targets overlap: {declaration.target} and {other.target}")
return tuple(declarations)
@@ -646,9 +679,7 @@ def _validate_dependency_symlinks(
)
if sandbox_resolved != sandbox_boundary and sandbox_boundary not in sandbox_resolved.parents:
raise SandboxError(f"dependency symlink escapes the sandbox workspace: {entry.path}")
manifest_resolved = PurePosixPath(
posixpath.normpath((entry.path.parent / target).as_posix())
)
manifest_resolved = PurePosixPath(posixpath.normpath((entry.path.parent / target).as_posix()))
if manifest_resolved != manifest_boundary and manifest_boundary not in manifest_resolved.parents:
continue
link = container / Path(*entry.path.parts)
@@ -1016,8 +1047,7 @@ def _dependency_mounts(
snapshot: TaskAssetSnapshot,
) -> list[ReadOnlyMount]:
declarations = tuple(
(declaration.source, declaration.target)
for declaration in _sandbox_dependency_declarations(task)
(declaration.source, declaration.target) for declaration in _sandbox_dependency_declarations(task)
)
if snapshot.dependency_declarations != declarations:
raise SandboxError("task asset snapshot does not match this dependency declaration")
@@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.9",
"version": "1.6.10-rc.106",
"author": {
"name": "GitNexus"
},
@@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.9",
"version": "1.6.10-rc.106",
"skills": "./skills",
"mcpServers": "./.mcp.json",
"hooks": "./hooks/hooks.json",
@@ -81,6 +81,18 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
These four hot read tools attach a non-blocking `staleness` field to their response when the index is behind the checkout's current HEAD — the same `{ commitsBehind, hint }` shape `list_repos` already reports — so a direct tool call surfaces a behind-HEAD index without a separate `list_repos` call:
```jsonc
{ /* …the tool's normal result… */
"staleness": { "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
}
```
The field is **absent when the index is current** (or when the freshness check can't run), so its presence is the signal. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
### Taint findings (`explain`)
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
+8 -8
View File
@@ -127,14 +127,14 @@ export type RelationshipType =
| 'ENTRY_POINT_OF'
| 'WRAPS'
| 'QUERIES'
/** Dependency-injection edge: a consumer class receives every implementer
* of interface `T` via a container-injected collection-typed field
* (`List<T>`, `Set<T>`, `Collection<T>`, or `Map<K,T>`). Precondition: the
* field carries an injection annotation recognized by a per-language
* matcher registered in `di-extractors/` (Java/Spring today: `@Autowired`
* or `@Inject`; `@Resource` is excluded — by-name-first semantics).
* Source = the consumer Class node (the one owning the field).
* Target = an implementing Class node.
/** Dependency-injection edge: a consumer class receives a likely provider
* through constructor, field, method, or collection injection. A
* per-language resolver identifies the site and provider metadata; the
* shared DI phase uses type heritage, qualifier names, and preferred
* provider markers to resolve it. Ambiguous single injection is represented
* by multiple lower-confidence edges instead of a fabricated exact target.
* Source = the consumer Class node (the one owning the injection site).
* Target = a concrete provider Class node.
* Framework specifics live in the `reason` payload (e.g.
* `Spring DI: @Autowired List<T>`), not in this type contract.
* Lets Cypher queries trace which beans the container injects into a given
@@ -351,6 +351,11 @@ export interface BindingRef {
readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport';
/** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */
readonly via?: ImportEdge;
/**
* Optional semantic visibility evidence supplied by a language hook.
* Shared resolution consumes this without inspecting language syntax.
*/
readonly visibility?: 'static-member-import';
}
// ─── §2.5 TypeRef ───────────────────────────────────────────────────────────
+111 -53
View File
@@ -11,14 +11,14 @@
"@langchain/anthropic": "^1.5.1",
"@langchain/core": "^1.2.2",
"@langchain/google-genai": "^2.2.0",
"@langchain/langgraph": "^1.4.7",
"@langchain/langgraph": "^1.4.8",
"@langchain/ollama": "^1.3.0",
"@langchain/openai": "^1.5.3",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.3.2",
"axios": "^1.18.1",
"d3": "^7.9.0",
"dompurify": "^3.4.11",
"dompurify": "^3.4.12",
"gitnexus-shared": "file:../gitnexus-shared",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@@ -29,14 +29,14 @@
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"langchain": "^1.4.6",
"lru-cache": "^11.5.1",
"lru-cache": "^11.5.2",
"lucide-react": "^1.23.0",
"mermaid": "^11.15.0",
"mnemonist": "^0.40.4",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-i18next": "^17.0.10",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
@@ -47,7 +47,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/types": "^7.29.0",
"@babel/types": "^8.0.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -63,7 +63,7 @@
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^8.1.4",
"vite": "^8.1.5",
"vitest": "^4.1.10",
"wait-on": "^9.0.10"
},
@@ -186,13 +186,13 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
"integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
@@ -221,16 +221,17 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"node_modules/@babel/parser/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"node_modules/@babel/parser/node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
@@ -244,6 +245,39 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz",
"integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0",
"@babel/helper-validator-identifier": "^8.0.0"
},
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@babel/types/node_modules/@babel/helper-validator-identifier": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz",
"integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
@@ -1138,13 +1172,13 @@
}
},
"node_modules/@langchain/langgraph": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.7.tgz",
"integrity": "sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ==",
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.8.tgz",
"integrity": "sha512-DN1Np1XefdBEbp1qBKlt39cwoL743AAGpR5Ipja0gY2YbWvsoQnOTIrjnj/orSAhaUYsdTKS8VSWdFzsHZo6Ig==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph-checkpoint": "^1.1.3",
"@langchain/langgraph-sdk": "~1.9.25",
"@langchain/langgraph-sdk": "~1.9.26",
"@langchain/protocol": "^0.0.18",
"@standard-schema/spec": "1.1.0"
},
@@ -1169,9 +1203,9 @@
}
},
"node_modules/@langchain/langgraph-sdk": {
"version": "1.9.25",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.25.tgz",
"integrity": "sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==",
"version": "1.9.28",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.28.tgz",
"integrity": "sha512-4j3XuM0PvtmAbL8mPfBS99ez3+ytRfgbOpAR/nOeaejTRF3Q9dNw2QnaGLGng8wLPtGLoSj+SYgUOVxy9Bv9vg==",
"license": "MIT",
"dependencies": {
"@langchain/protocol": "^0.0.18",
@@ -1208,9 +1242,9 @@
"license": "MIT"
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz",
"integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==",
"version": "9.3.3",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz",
"integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.4",
@@ -4075,9 +4109,9 @@
"peer": true
},
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -4357,9 +4391,9 @@
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"dev": true,
"funding": [
{
@@ -5672,9 +5706,9 @@
}
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -5721,6 +5755,30 @@
"source-map-js": "^1.2.1"
}
},
"node_modules/magicast/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/magicast/node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -6826,9 +6884,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
@@ -7216,9 +7274,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.22",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
"funding": [
{
"type": "opencollective",
@@ -7235,7 +7293,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -7356,9 +7414,9 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
@@ -7368,7 +7426,7 @@
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
@@ -7894,9 +7952,9 @@
}
},
"node_modules/tar": {
"version": "7.5.16",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -8296,15 +8354,15 @@
}
},
"node_modules/vite": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.16",
"rolldown": "~1.1.4",
"postcss": "^8.5.17",
"rolldown": "~1.1.5",
"tinyglobby": "^0.2.17"
},
"bin": {
+6 -6
View File
@@ -21,14 +21,14 @@
"@langchain/anthropic": "^1.5.1",
"@langchain/core": "^1.2.2",
"@langchain/google-genai": "^2.2.0",
"@langchain/langgraph": "^1.4.7",
"@langchain/langgraph": "^1.4.8",
"@langchain/ollama": "^1.3.0",
"@langchain/openai": "^1.5.3",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.3.2",
"axios": "^1.18.1",
"d3": "^7.9.0",
"dompurify": "^3.4.11",
"dompurify": "^3.4.12",
"gitnexus-shared": "file:../gitnexus-shared",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@@ -39,14 +39,14 @@
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"langchain": "^1.4.6",
"lru-cache": "^11.5.1",
"lru-cache": "^11.5.2",
"lucide-react": "^1.23.0",
"mermaid": "^11.15.0",
"mnemonist": "^0.40.4",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-i18next": "^17.0.10",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
@@ -57,7 +57,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/types": "^7.29.0",
"@babel/types": "^8.0.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -73,7 +73,7 @@
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^8.1.4",
"vite": "^8.1.5",
"vitest": "^4.1.10",
"wait-on": "^9.0.10"
},
+49 -3
View File
@@ -284,6 +284,7 @@ Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint i
export GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1
export GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5
export GITNEXUS_EMBEDDING_DIMS=1024 # optional, default 384
export GITNEXUS_EMBEDDING_REQUEST_DIMS=omit # optional: omit "dimensions", or an integer to override it
export GITNEXUS_EMBEDDING_API_KEY=your-key # optional, default: "unused"
export GITNEXUS_EMBEDDING_MAX_ATTEMPTS=3 # optional, total attempts (1-20)
export GITNEXUS_EMBEDDING_RETRY_CAP_MS=5000 # optional, maximum retry delay
@@ -291,6 +292,15 @@ export GITNEXUS_EMBEDDING_MIN_INTERVAL_MS=0 # optional, minimum request spacing
gitnexus analyze . --embeddings
```
`GITNEXUS_EMBEDDING_REQUEST_DIMS` controls only the `dimensions` field sent in
the request body, independently of `GITNEXUS_EMBEDDING_DIMS` (which still
validates the returned vector's length):
- `omit` (or `none`, `off`, `false`, `0`) — do not send `dimensions` at all, for
strict backends that return the right vector size but reject the field.
- a positive integer — send that value instead of `GITNEXUS_EMBEDDING_DIMS`.
- unset — send `GITNEXUS_EMBEDDING_DIMS` (the previous behavior).
Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. Retry and pacing settings are provider-neutral; provider-specific limits should be supplied through configuration. When unset, local embeddings are used unchanged.
## Multi-Repo Support
@@ -472,9 +482,10 @@ Configure the behavior with these environment variables:
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. |
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
| `GITNEXUS_STREAM_GRAPH_EMIT` | `0`, `1` | `1` (on) | **On by default** on a full rebuild (`--force`); incremental runs ignore it. Holds structural relationships (CALLS, IMPORTS, ACCESSES, CONTAINS, ...) as CSV-on-disk plus compact in-memory columns instead of as objects in three overlapping indexes, cutting peak in-memory graph heap by ~1.4x at no measurable CPU cost (measured A/B on a synthetic 400k-node / 1.08M-edge graph: 819 MB -> 584 MB, iteration at parity, scaling verified linear from 100k to 800k nodes, with every edge still visible through the graph interface; no end-to-end measurement on a real repository yet). Nothing is traded away — community detection, process extraction, PDG taint summaries and the local-symbol pruner all read a complete relationship set and behave identically. Set to `0` only to bisect a suspected streaming-related fault. |
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` uses the bundled default path. `icebug` and `auto` currently behave identically: both try the experimental Icebug CSR path and fall back to Graphology if the optional native module is unavailable or incompatible. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. During `analyze` the pool is right-sized to the graph and, on non-4 KiB-page hosts (Apple Silicon 16 KiB, Ascend/aarch64 64 KiB), scaled by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. |
| `GITNEXUS_LBUG_MAX_DB_SIZE` | positive integer (bytes) | `17179869184` (16 GiB) | Upper bound for a single LadybugDB database file. This is an mmap/disk-address-space ceiling, not a memory limit — it does not constrain the buffer pool (use `GITNEXUS_LBUG_BUFFER_POOL_SIZE` for that). Raise it when indexing genuinely huge monorepos; invalid values silently fall back to the default. |
```bash
@@ -495,15 +506,46 @@ GITNEXUS_FTS_CJK_SEGMENTATION=bigram npx gitnexus analyze --force
### Analysis runs out of memory
Memory management is automatic: `analyze` sizes its heap to the machine
(always below physical RAM), caps each parse worker, and — rather than
grinding into a GC death spiral or crash — stops early with a message telling
you the one thing to do. Repeated
`Replacement worker did not report ready within 5000ms` warnings on a large
repository are part of the same picture: memory pressure starving healthy
workers, not a worker bug (#2649).
If analyze says the repository doesn't fit, do what the message says:
- **The machine has more memory to give** (a `NODE_OPTIONS`
`--max-old-space-size` pin from your environment is holding analyze back):
re-run without the pin — no flags needed.
- **The machine is the ceiling**: shrink the scope (exclude generated or
vendored directories, below) or use a machine with more RAM.
Escape hatches (`GITNEXUS_MEMORY=off` to decline the autopilot,
`GITNEXUS_WORKER_HEAP_MB` to size workers yourself) are listed in the
environment-variable table below —
most users never need them.
For very large repositories:
```bash
# Increase Node.js heap size
NODE_OPTIONS="--max-old-space-size=16384" npx gitnexus analyze
# Exclude large directories
# Exclude large directories (this repo only)
echo "vendor/" >> .gitnexusignore
echo "dist/" >> .gitnexusignore
# Exclude a directory across every repo you index, without touching each
# repo's own .gitnexusignore or needing push/commit access to it. GitNexus
# reads the same sources `git` itself does: core.excludesFile (all repos)
# and $GIT_DIR/info/exclude (this repo only, untracked). A repo's own
# .gitignore/.gitnexusignore can still override either with a `!pattern`
# negation. Skip both entirely with GITNEXUS_NO_GLOBAL_IGNORE=1.
git config --global core.excludesFile ~/.gitignore_global # applies to every repo
echo "docs/" >> ~/.gitignore_global
echo "build/" >> .git/info/exclude # this repo only, untracked
```
### Large files are being skipped
@@ -538,7 +580,7 @@ For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BY
### Worker pool resilience tuning
Three env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
Four env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker, startup handshake). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
| Variable | Default | Effect |
| ----------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -546,6 +588,10 @@ Three env vars expose the pool's resilience layers (respawn budget, cumulative-t
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. |
| `GITNEXUS_MEMORY` | `off` | unset (autopilot on) | `off` declines GitNexus's memory autopilot: analyze will neither re-run itself with a RAM-aware heap cap nor abort the parse before V8 enters its ineffective-mark-compact death spiral. Use it when you want to drive memory manually; to simply pin a heap size, pass Node's own `--max-old-space-size`, which is already honoured as your decision. |
| `GITNEXUS_WORKER_HEAP_MB` | `clamp(512, RAM/2/poolSize, 4096)` | Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. |
| `GITNEXUS_SERVER_ANALYZE_HEAP_MB` | `min(8192, auto cap)` | Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. |
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. |
### Graph cleanup tuning
@@ -1 +1 @@
a99e69ab2dfb897ed771c6a8e29c5b32843a7f734db701e0699afc07c090e4d5
36e29abc0780bc857b6df6dd180a0b6036c8a28f927ccc2d4fe50eede24d0c99
+20 -8
View File
@@ -39,19 +39,22 @@
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
"fingerprint": "75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1",
"fingerprint": "e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11)."
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).",
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5."
},
"rust": {
"fingerprint": "df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29",
"fingerprint": "655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db",
"scaling_budget": 1.5,
"_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.",
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.",
"_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5."
},
"php": {
"fingerprint": "4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd",
@@ -89,7 +92,7 @@
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
},
"java": {
"fingerprint": "975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca",
"fingerprint": "6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.",
@@ -97,7 +100,15 @@
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06).",
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget.",
"_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.",
"_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5."
"_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5.",
"_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.",
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.",
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5."
},
"java-local-types": {
"fingerprint": "a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b",
"scaling_budget": 1.5,
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054."
},
"typescript": {
"fingerprint": "3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4",
@@ -122,7 +133,7 @@
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5."
},
"kotlin": {
"fingerprint": "a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091",
"fingerprint": "9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.",
@@ -130,6 +141,7 @@
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).",
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.",
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5."
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.",
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5."
}
}
+18 -1
View File
@@ -264,6 +264,23 @@ const LANGS = [
` public long getId() { return this.id; }\n` +
` public void setName(String v) { this.name = v; }\n}\n\n`,
},
{
name: 'java-local-types',
emit: emitJavaScopeCaptures,
fixturePrefix: 'java-local',
exts: ['.java'],
file: 'bench-local.java',
header:
'package generated;\n\nclass Base {}\n\ninterface Marker {}\n\nclass Bench {\n void run() {\n',
// Co-scale both independent ordinal sequences under one host; construction
// and dispatch keep lexical-alias captures hot. The old per-identity
// host-candidate filter made this combined workload quadratic.
unit: (n) =>
` { class Local extends Base implements Marker { long value() { return ${n}L; } } ` +
`new Local().value(); }\n` +
` Marker marker${n} = new Marker() {};\n`,
footer: ' }\n}\n',
},
{
name: 'typescript',
emit: emitTsScopeCaptures,
@@ -309,7 +326,7 @@ const LANGS = [
function generate(lang, entityCount) {
let src = lang.header;
for (let i = 0; i < entityCount; i++) src += lang.unit(i);
return src;
return src + (lang.footer ?? '');
}
// ---- timing ----
+78 -84
View File
@@ -1,16 +1,16 @@
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10-rc.106",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10-rc.106",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@ladybugdb/core": "^0.18.0",
"@ladybugdb/core": "^0.18.3",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
@@ -24,7 +24,7 @@
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"js-yaml": "^5.0.0",
"jsonc-parser": "^3.3.1",
"mnemonist": "^0.40.3",
"node-addon-api": "^8.0.0",
@@ -58,9 +58,7 @@
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.6.0",
"@types/uuid": "^11.0.0",
"@types/node": "^26.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
@@ -68,7 +66,7 @@
"vitest": "^4.0.18"
},
"engines": {
"node": ">=22.0.0"
"node": "^22.18.0 || >=24.11.0"
},
"optionalDependencies": {
"@huggingface/transformers": "^4.1.0",
@@ -1254,9 +1252,9 @@
}
},
"node_modules/@ladybugdb/core": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.18.1.tgz",
"integrity": "sha512-0c1kXDpdv7z/GB0oyFYnLEjLsXFwPHz1YD4wxtrk9hav8zJX5T1PHQMr+XRfdDI1NQjx4iNdbPQGGT7Bx/X2aw==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.18.3.tgz",
"integrity": "sha512-XjpPKW4MrL28D2gYGTZuIjiEcPx12L21lx58QggrdrItw8o/e9Lmg/Ejoo4Kz08lZj+rIcC1Fu9thzIYOTUlJw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -1265,17 +1263,17 @@
"node-addon-api": "^6.0.0"
},
"optionalDependencies": {
"@ladybugdb/core-darwin-arm64": "0.18.1",
"@ladybugdb/core-darwin-x64": "0.18.1",
"@ladybugdb/core-linux-arm64": "0.18.1",
"@ladybugdb/core-linux-x64": "0.18.1",
"@ladybugdb/core-win32-x64": "0.18.1"
"@ladybugdb/core-darwin-arm64": "0.18.3",
"@ladybugdb/core-darwin-x64": "0.18.3",
"@ladybugdb/core-linux-arm64": "0.18.3",
"@ladybugdb/core-linux-x64": "0.18.3",
"@ladybugdb/core-win32-x64": "0.18.3"
}
},
"node_modules/@ladybugdb/core-darwin-arm64": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.18.1.tgz",
"integrity": "sha512-M5YZuAONRAv3awkr+cfaibn9Da+3pgDzRiek/JabWQuz48xgzW3Vh9yQH4s8Dq/bfQo6YTsaLIBRcUCCUzCtcg==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.18.3.tgz",
"integrity": "sha512-DGZTOlvSS4esEb1vTekY5IDoAvZAeYzR5cXVkECtQj9BVkk05zsvCAdTPo1Rz1BuI0qvqUVF+2WlIerI67iA2g==",
"cpu": [
"arm64"
],
@@ -1286,9 +1284,9 @@
]
},
"node_modules/@ladybugdb/core-darwin-x64": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.18.1.tgz",
"integrity": "sha512-kq+pyTskfCx++Mrbk7QssE/f/CpSuU50T8lhRtv4PaOKhC2Jf8/wAUOA17UxI594wAru3ERpqVBFUBWGcPk2ag==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.18.3.tgz",
"integrity": "sha512-Qp6j0CM/orBlK6KD0p/s4ofkIhNUwi1hdCgMw+fj81UHugWHkVLiYV4grRBdHhyplw+snchZpTxvfpxFbkG1Cw==",
"cpu": [
"x64"
],
@@ -1299,9 +1297,9 @@
]
},
"node_modules/@ladybugdb/core-linux-arm64": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.18.1.tgz",
"integrity": "sha512-fu7ke1haa5rPINcQn0+kxQijZ0A8ZDWP9e+X8xcDH94RagDbPWwG8yFC890cGSdc/j7mTV+xkA/y/kVHpmVI6w==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.18.3.tgz",
"integrity": "sha512-F9miYjBuS43I7uNG199FNMqwdHJ98WA6dU3v2SZCeLXmXCdRzmYcuHQWlbNr2Tba9CX58w2XvBZoUaXZKJ/yKQ==",
"cpu": [
"arm64"
],
@@ -1312,9 +1310,9 @@
]
},
"node_modules/@ladybugdb/core-linux-x64": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.18.1.tgz",
"integrity": "sha512-qp5HilHzDGuArfOyD+VyA7lVJ7IwQDKd81NZKKTmUwIAOJtdwqniYx6JZICPnlr36zFJBx/lGYoSsEzbC+TVdw==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.18.3.tgz",
"integrity": "sha512-AfG5RDp/f/IDctDMpTAT5+2MYNtlWT191xiQNjSaWD4X85DhY3Dzps8Qu5VteIAPih5d6mmoaKGs8q0XIjfkFA==",
"cpu": [
"x64"
],
@@ -1325,9 +1323,9 @@
]
},
"node_modules/@ladybugdb/core-win32-x64": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.18.1.tgz",
"integrity": "sha512-vHcXr7Df2X1dbb5ORK+SBmNstd/3tApGFImbAnaWiTuLDFlAdfY8lbiSBSp3OgFjc0BB7F3GYUUdvgDRJjK3zA==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.18.3.tgz",
"integrity": "sha512-bHuFk0m9cnq0WGd9I4D8or8g6cC/BS58iatMtilqM3JpDPIQIFk6MQl6exL7P4xyWbkLwQgsrv2ToDnyoQNKvg==",
"cpu": [
"x64"
],
@@ -1931,13 +1929,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsesc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
@@ -1946,13 +1937,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/qs": {
@@ -1990,17 +1981,6 @@
"@types/node": "*"
}
},
"node_modules/@types/uuid": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz",
"integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==",
"deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"uuid": "*"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
@@ -2316,20 +2296,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -2339,10 +2319,23 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -3013,11 +3006,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz",
"integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
@@ -3049,9 +3043,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"funding": [
{
"type": "github",
@@ -3410,9 +3404,9 @@
"license": "MIT"
},
"node_modules/hono": {
"version": "4.12.26",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
"integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
"version": "4.12.31",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -3589,9 +3583,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.0.0.tgz",
"integrity": "sha512-GSvaPUbk1U+FMZ7rJzF+F8e5YVtu7KnD40et/5rBXXRBv2jCO9L3qCewvIDDdudC0QycTFlf6EAA+h3kxBsuUw==",
"funding": [
{
"type": "github",
@@ -3607,7 +3601,7 @@
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/jsesc": {
@@ -5135,9 +5129,9 @@
}
},
"node_modules/tar": {
"version": "7.5.16",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@@ -5510,9 +5504,9 @@
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"devOptional": true,
"license": "MIT"
},
+5 -7
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10-rc.106",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@@ -56,7 +56,7 @@
"version": "node scripts/sync-plugin-manifests.mjs"
},
"dependencies": {
"@ladybugdb/core": "^0.18.0",
"@ladybugdb/core": "^0.18.3",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
@@ -70,7 +70,7 @@
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"js-yaml": "^5.0.0",
"jsonc-parser": "^3.3.1",
"mnemonist": "^0.40.3",
"node-addon-api": "^8.0.0",
@@ -105,9 +105,7 @@
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.6.0",
"@types/uuid": "^11.0.0",
"@types/node": "^26.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
@@ -120,6 +118,6 @@
}
},
"engines": {
"node": ">=22.0.0"
"node": "^22.18.0 || >=24.11.0"
}
}
+34
View File
@@ -36,6 +36,19 @@ const PLATFORM_LOGIC = [
// must exercise the Windows backslash branch, so run it on the OS matrix (#2394).
'test/unit/cli-entry.test.ts',
'test/unit/platform-capabilities.test.ts',
// Windows drive-letter case variance in the analyzer runner-identity path
// fields (#2668): normalizeAnalyzerRootPath is a POSIX no-op, so the
// "identity path fields are normalizer-stable" fixpoint guard only bites on
// the windows-latest matrix — it must run there, not just in the Ubuntu
// full-suite where it's trivially green. Deliberately the split-out
// normalization file, NOT analyzer-identity.test.ts: the latter's fixture
// tests compare identity fields against raw temp-dir paths and fail on macOS,
// where /var/... realpaths to /private/var/....
'test/unit/analyzer-identity-path-normalization.test.ts',
// `isInside` containment guard vs Windows cross-drive paths: path.relative
// returns the absolute target across drives, so the guard needs isAbsolute.
// Fixture-free and pathApi-injectable, so it is portable to every runner.
'test/unit/analyzer-identity-is-inside.test.ts',
// getconf page-size probe: explicit process.platform gate (win32 short-circuit)
// plus a live-probe test whose only real non-4K coverage is macos-arm64's
// 16 KiB pages — the exact hardware class #1231 targets (#2424 review).
@@ -79,6 +92,13 @@ const PLATFORM_LOGIC = [
// POSIX and Windows — the fail-closed path-claim semantics must hold on the
// real windows-latest path implementation (#2419/#2420).
'test/unit/server-api-repo-resolution.test.ts',
// The index write-lock (#2658) selects its backend by process.platform — the
// OS socket lock (Windows named pipe / Linux abstract socket) vs the file
// fallback — and its socket-backend describe block is gated to linux/win32.
// The Ubuntu suite only proves the Linux abstract-socket path, so run it here
// to exercise the Windows named-pipe backend and the macOS file fallback on
// their real platforms (#2658 review H3).
'test/unit/index-lock.test.ts',
];
// Native LadybugDB integration tests — exercise the @ladybugdb/core
@@ -117,6 +137,12 @@ const LBUG_NATIVE = [
// to a live native DB, rm-then-rename over an existing parked copy) before
// any open — rename semantics are exactly what differs on Windows.
'test/unit/incremental-dirty-recovery.test.ts',
// #2623: the incremental writeback must load VECTOR before the CodeEmbedding
// join-delete, and the blocked path must escalate instead of crashing. The
// win32 VECTOR gate was removed in the same PR, so this ordering must be
// proven on the windows-latest native addon, not just Ubuntu. Budget: ~25s
// on Linux → expect ~2min on the slowest Windows shard.
'test/unit/incremental-vector-extension-ordering.test.ts',
];
// Process spawning and CLI tests — exercise child_process with real
@@ -141,6 +167,14 @@ const SPAWN_CLI = [
'test/integration/antigravity-hook-e2e.test.ts',
'test/unit/local-cli-subprocess.test.ts',
'test/unit/runner-exec-tail.test.ts',
// Real cross-process single-writer lock coordination (#2658): child processes
// contend for the lock and race to reclaim a dead holder. Process spawning,
// kernel socket auto-release (Win named pipe / Linux abstract socket), and the
// FILE-backend rename-steal reclaim (macOS/BSD default) all vary across OSes —
// the exact behaviors the Windows/macOS matrix must prove. macOS timing first
// exposed a file-backend double-admit race here (#2658 review); the reclaim is
// now judgment-verified so a live holder is never displaced.
'test/integration/analyze-index-lock-concurrency.test.ts',
];
// Worker threads tests — exercise real worker_threads which have
+14 -3
View File
@@ -1,6 +1,6 @@
/**
* Install the LadybugDB FTS extension into the shared home (~/.lbdb) up front, so
* every test in a sharded CI run finds it regardless of which shard it lands in.
* Install the LadybugDB FTS and VECTOR extensions into the shared home (~/.lbdb)
* up front, so every test in a sharded CI run finds them regardless of shard.
*
* FTS-dependent tests split two ways: the LOAD-path gate (skipUnlessFtsAvailable)
* self-installs on miss, but the FILE-path gate (requireFtsResourceOrSkip, e.g.
@@ -17,13 +17,24 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initLbug, loadFTSExtension, closeLbug } from '../src/core/lbug/lbug-adapter.js';
import {
initLbug,
loadFTSExtension,
loadVectorExtension,
closeLbug,
} from '../src/core/lbug/lbug-adapter.js';
const dir = mkdtempSync(join(tmpdir(), 'gn-ensure-fts-'));
try {
await initLbug(join(dir, 'ensure-fts.lbug'));
const ok = await loadFTSExtension(undefined, { policy: 'auto' });
console.log(ok ? 'FTS extension ready.' : 'FTS extension unavailable (continuing).');
// VECTOR rides the same pre-install (#2623): the win32 gate is gone, so the
// vector suites genuinely run on Windows/macOS — installing once here means
// every sharded test process LOADs from ~/.lbdb instead of racing its own
// out-of-process INSTALL (bounded 15s each when the server is unreachable).
const vec = await loadVectorExtension(undefined, { policy: 'auto' });
console.log(vec ? 'VECTOR extension ready.' : 'VECTOR extension unavailable (continuing).');
} catch (err) {
console.warn(`ensure-fts: skipped (${err instanceof Error ? err.message : String(err)})`);
} finally {
+41 -5
View File
@@ -362,13 +362,32 @@ async function upsertGitNexusSection(
}
/**
* Install GitNexus skills as direct children of .claude/skills/
* Works natively with Claude Code, Cursor, and GitHub Copilot
* Some agents read skills from a repo-local `.agents/skills/` directory and
* prefer it over the global `~/.agents/skills/` install. When the repo contains
* an `.agents/` directory, skills written to `.claude/skills/` are mirrored
* there too so those agents serve the up-to-date copies.
*/
async function installSkills(repoPath: string): Promise<string[]> {
export async function shouldMirrorSkillsToAgents(repoPath: string): Promise<boolean> {
try {
const stat = await fs.stat(path.join(repoPath, '.agents'));
return stat.isDirectory();
} catch {
return false;
}
}
/**
* Install GitNexus skills as direct children of .claude/skills/
* Works natively with Claude Code, Cursor, and GitHub Copilot.
* Mirrored to .agents/skills/ when .agents/ exists.
*/
async function installSkills(
repoPath: string,
): Promise<{ skills: string[]; agentsMirror: boolean }> {
const skillsDir = path.join(repoPath, '.claude', 'skills');
const legacySkillsDir = path.join(skillsDir, 'gitnexus');
const installedSkills: string[] = [];
const agentsMirror = await shouldMirrorSkillsToAgents(repoPath);
for (const skill of STANDARD_SKILL_CATALOG.filter(
(entry) => entry.distributions.project && entry.distributions.npm,
@@ -402,6 +421,18 @@ Use GitNexus tools to accomplish this task.
}
await fs.writeFile(skillPath, skillContent, 'utf-8');
// Mirror to .agents/skills/ for agents that read repo-local skills
if (agentsMirror) {
try {
const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name);
await fs.mkdir(agentsSkillDir, { recursive: true });
await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8');
} catch (err) {
logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
}
}
installedSkills.push(skill.name);
// Previous releases installed these known standard skills one level too
@@ -418,7 +449,7 @@ Use GitNexus tools to accomplish this task.
}
}
return installedSkills;
return { skills: installedSkills, agentsMirror };
}
/**
@@ -496,9 +527,14 @@ export async function generateAIContextFiles(
// Install standard skills directly under .claude/skills/ (unless --skip-skills)
if (!options?.skipSkills) {
const installedSkills = await installSkills(repoPath);
const { skills: installedSkills, agentsMirror } = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`);
if (agentsMirror) {
createdFiles.push(
`.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`,
);
}
}
} else {
createdFiles.push('.claude/skills/gitnexus-*/ (skipped via --skip-skills)');
+142 -26
View File
@@ -18,6 +18,7 @@ import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js';
import {
getOsPageSize,
isLbugCheckpointIoError,
isLbugCheckpointBusyError,
isLbugPageSizeFrameError,
isPageSizeAwareLadybug,
isWalCorruptionError,
@@ -32,7 +33,14 @@ import {
assertAnalysisFinalized,
type AnalyzerRunnerIdentity,
} from '../storage/repo-manager.js';
import { getGitRoot, hasGitDir, getDefaultBranch } from '../storage/git.js';
import {
getGitRoot,
hasGitDir,
getDefaultBranch,
selfCommitContextFiles,
snapshotSelfCommitSafety,
} from '../storage/git.js';
import { IndexLockTimeoutError } from '../storage/index-lock.js';
import {
loadAnalyzeConfig,
mergeAnalyzeOptions,
@@ -46,7 +54,8 @@ import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-si
import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js';
import { glob } from 'glob';
import fs from 'fs/promises';
import { cliError } from './cli-message.js';
import { cliError, cliWarn } from './cli-message.js';
import { heapCapMbFor, memoryAutopilotDisabled } from '../core/ingestion/utils/effective-ram.js';
import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.js';
import { formatElapsed } from './format-elapsed.js';
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
@@ -127,25 +136,21 @@ const installFatalHandlers = (): void => {
});
};
/** Historical floor for the re-exec heap cap — the auto-sizer never goes below
* this, so small boxes / CI never regress. */
const DEFAULT_HEAP_MB = 16384;
/**
* RAM-aware re-exec heap cap (MB): `0.75 × effective RAM`, clamped to
* `>= DEFAULT_HEAP_MB`. Kept BELOW physical RAM on purpose — a cap `>=` RAM makes
* V8 collect lazily and inflate the heap into swap-thrash (observed analyzing the
* Linux kernel at a 30GB cap on a 31GB box). `constrainedBytes` is the cgroup
* limit or `null`; it is honored only as a real, smaller-than-physical cap, because
* RAM-aware re-exec heap cap (MB) — the formula itself is single-sourced in
* `core/ingestion/utils/effective-ram.ts` (`heapCapMbFor`), shared with the
* server's analyze fork. `constrainedBytes` is the cgroup limit or `null`;
* it is honored only as a real, smaller-than-physical cap, because
* `process.constrainedMemory()` returns a huge sentinel when UNCONSTRAINED.
* (Observed rationale: a cap ≥ RAM made V8 collect lazily and swap-thrash —
* the #2649 worker-timeout cascade on 16 GB boxes.)
*/
export function computeHeapCapMb(totalBytes: number, constrainedBytes: number | null): number {
const effectiveBytes =
constrainedBytes !== null && constrainedBytes > 0 && constrainedBytes < totalBytes
? constrainedBytes
: totalBytes;
const effectiveMb = Math.floor(effectiveBytes / (1024 * 1024));
return Math.max(DEFAULT_HEAP_MB, Math.floor(0.75 * effectiveMb));
return heapCapMbFor(effectiveBytes);
}
function readConstrainedBytes(): number | null {
@@ -515,21 +520,69 @@ const forceHeapOOMForTestIfEnabled = (): void => {
// `gitnexus/src/core/lbug/lbug-config.ts` in sync with this value.
const RECOMMENDED_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;
/** Re-exec the process with the RAM-aware auto heap cap + larger semi-space/stack
* if we're currently below that. A user-supplied NODE_OPTIONS heap wins (no re-exec). */
async function ensureHeap(): Promise<boolean> {
const nodeOpts = process.env.NODE_OPTIONS || '';
if (nodeOpts.includes('--max-old-space-size')) return false;
/**
* Last `--max-old-space-size` value (MB) in a NODE_OPTIONS string, or `null`
* when absent/unparseable. Last occurrence wins, matching V8's own
* later-flag-wins semantics when NODE_OPTIONS repeats a flag.
*/
export function parseMaxOldSpaceMb(nodeOptions: string): number | null {
// V8 accepts `-` and `_` interchangeably in flag names, and Node accepts a
// space-separated value in NODE_OPTIONS — honor every spelling of the pin
// instead of silently overriding it (#2649 review).
const matches = [...nodeOptions.matchAll(/--max[-_]old[-_]space[-_]size(?:=|\s+)(\d+)/g)];
if (matches.length === 0) return null;
const mb = Number(matches[matches.length - 1][1]);
return Number.isFinite(mb) && mb > 0 ? mb : null;
}
const v8Heap = v8.getHeapStatistics().heap_size_limit;
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
/** Re-exec the process with the RAM-aware auto heap cap + larger semi-space/stack
* if we're currently below that.
*
* Heap-source precedence (#2649):
* - an explicit per-invocation `--max-old-space-size` (execArgv) always wins;
* - `GITNEXUS_MEMORY=off` declines the memory autopilot entirely;
* - an ambient NODE_OPTIONS heap >= the auto cap is honored as-is;
* - an ambient NODE_OPTIONS heap BELOW the auto cap is treated as an
* inherited environment default (devcontainers/CI export one for other
* tooling), not a deliberate per-run choice: warn and respawn with the
* auto cap. Pre-#2649 this returned early and large repos then OOM'd on
* whatever heap the environment happened to specify. */
async function ensureHeap(): Promise<boolean> {
// Explicit opt-out disables auto-sizing ENTIRELY — both the ambient-pin
// override and the default v8-limit respawn — and is honored SILENTLY:
// the operator already made the call, and stderr-sensitive consumers
// (test harnesses, scripts, supervisors that track a single PID) rely on
// a quiet, single-process run.
if (memoryAutopilotDisabled()) return false;
const nodeOpts = process.env.NODE_OPTIONS || '';
if (process.execArgv.some((a) => a.startsWith('--max-old-space-size'))) return false;
const ambientHeapMb = parseMaxOldSpaceMb(nodeOpts);
if (ambientHeapMb !== null) {
if (ambientHeapMb >= RESPAWN_HEAP_MB) return false;
cliWarn(
` NODE_OPTIONS pins the heap to ${ambientHeapMb}MB — below the ${RESPAWN_HEAP_MB}MB this machine's RAM supports.\n` +
` Re-running analyze with the larger auto-sized cap (set GITNEXUS_MEMORY=off to keep the NODE_OPTIONS value).\n`,
);
} else {
const v8Heap = v8.getHeapStatistics().heap_size_limit;
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
}
// --stack-size is a V8 flag not allowed in NODE_OPTIONS on Node 24+, so pass it
// only as a direct CLI argument. --max-semi-space-size IS allowed in NODE_OPTIONS.
const cliFlags = [HEAP_FLAG, SEMI_FLAG];
if (!nodeOpts.includes('--stack-size')) cliFlags.push(STACK_FLAG);
const childArgs = [...cliFlags, ...process.argv.slice(1)];
// Preserve the parent's node flags (execArgv) — dropping them breaks any
// loader-launched CLI: `node --import tsx src/cli/index.ts` respawned
// without `--import tsx` cannot execute TypeScript and dies with a
// swallowed exit 1 (#2649 review). Our heap/semi/stack flags come AFTER
// execArgv so V8's later-flag-wins semantics resolve duplicates our way.
// Inspector flags are the one exception: replaying `--inspect[-brk]` makes
// the child fight the parent for the debug port and die with EADDRINUSE.
const preservedExecArgv = process.execArgv.filter((a) => !a.startsWith('--inspect'));
const childArgs = [...preservedExecArgv, ...cliFlags, ...process.argv.slice(1)];
const childEnv = {
...process.env,
NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG} ${SEMI_FLAG}`.trim(),
@@ -647,6 +700,13 @@ export interface AnalyzeOptions {
* default-on case.
*/
stats?: boolean;
/**
* Opt-in auto-commit of any AGENTS.md/CLAUDE.md changes this `analyze` run
* makes. Scoped to only those two files (never `git add -A`); no-ops
* silently if neither exists, neither changed, or the commit step itself
* fails (e.g. no git identity configured). See #2639.
*/
selfCommit?: boolean;
/** Skip installing standard GitNexus skill files directly under .claude/skills/. */
skipSkills?: boolean;
/**
@@ -1393,6 +1453,15 @@ const analyzeCommandImpl = async (
const bootstrapArgs: [] | [AnalyzerRunnerIdentity] = runnerIdentityAtBootstrap
? [runnerIdentityAtBootstrap]
: [];
// #2639 review round 2: snapshot which of AGENTS.md/CLAUDE.md are safe to
// auto-commit BEFORE runFullAnalysis (and the --skills regeneration
// further down) writes to them, so selfCommitContextFiles can tell a
// pre-existing unstaged user edit apart from this run's stats refresh
// and refuse to sweep the former into the latter's commit.
const selfCommitSafety =
options.selfCommit === true
? snapshotSelfCommitSafety(repoPath, ['AGENTS.md', 'CLAUDE.md'])
: undefined;
const result = await runFullAnalysis(repoPath, runOptions, runCallbacks, ...bootstrapArgs);
if (result.alreadyUpToDate) {
@@ -1437,6 +1506,11 @@ const analyzeCommandImpl = async (
` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`,
);
}
// #2639: opt-in self-commit of any AGENTS.md/CLAUDE.md churn from this
// fast path (e.g. a base_ref refresh above). Best-effort — never throws.
if (options.selfCommit === true && selfCommitSafety) {
selfCommitContextFiles(repoPath, ['AGENTS.md', 'CLAUDE.md'], selfCommitSafety);
}
// Safe to return without process.exit(0) — the early-return path in
// runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
return;
@@ -1526,6 +1600,14 @@ const analyzeCommandImpl = async (
}
}
// #2639: opt-in self-commit of any AGENTS.md/CLAUDE.md churn written by
// this run (the primary generateAIContextFiles call inside
// runFullAnalysis, and/or the --skills regeneration above). Best-effort
// — never throws, so a missing git identity etc. can't fail `analyze`.
if (options.selfCommit === true && selfCommitSafety) {
selfCommitContextFiles(repoPath, ['AGENTS.md', 'CLAUDE.md'], selfCommitSafety);
}
const totalTime = ((Date.now() - t0) / 1000).toFixed(1);
clearInterval(elapsedTimer);
@@ -1552,11 +1634,21 @@ const analyzeCommandImpl = async (
// progress-bar log() that fired mid-run has already scrolled away, so the
// degraded-search state must also appear in the final summary (#1161).
if (result.ftsSkipped) {
console.log(
`\n Warning: full-text/BM25 search is disabled — the LadybugDB FTS extension was unavailable.\n` +
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) then rerun, or\n` +
` run \`gitnexus analyze --repair-fts\` when connected. Run \`gitnexus doctor\` for details.`,
);
// #2658 review L2: a build/verify failure is NOT an extension-unavailable
// problem — sending the user to install the extension is the wrong remedy.
if (result.ftsSkipReason === 'build-failed') {
console.log(
`\n Warning: full-text/BM25 search is disabled — the search index build failed this run.\n` +
` The FTS extension is available; rerun \`gitnexus analyze --repair-fts\`. If it persists,\n` +
` check the disk for space or corruption. Run \`gitnexus doctor\` for details.`,
);
} else {
console.log(
`\n Warning: full-text/BM25 search is disabled — the LadybugDB FTS extension was unavailable.\n` +
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) then rerun, or\n` +
` run \`gitnexus analyze --repair-fts\` when connected. Run \`gitnexus doctor\` for details.`,
);
}
}
try {
@@ -1593,6 +1685,22 @@ const analyzeCommandImpl = async (
return;
}
// Another analyze held the index lock past the configured wait ceiling
// (#2658, GITNEXUS_INDEX_LOCK_TIMEOUT_MS). The on-disk index is being
// refreshed by the holder — this is a clean, expected condition, not a
// crash, so render the message without a stack trace.
if (err instanceof IndexLockTimeoutError) {
cliError(
` Another gitnexus analyze (pid ${err.holder.pid} on ${err.holder.hostname}) is ` +
`already refreshing this index and did not finish within the wait window.\n` +
` The on-disk index is being updated by that run. Retry later, or raise\n` +
` GITNEXUS_INDEX_LOCK_TIMEOUT_MS to wait longer.\n`,
{ recoveryHint: 'index-lock-timeout', holderPid: err.holder.pid },
);
process.exitCode = 1;
return;
}
// Finalize invariant failure (#1169) — keep the rich actionable
// message intact and write through realStderrWrite so it can't be
// erased by a leftover bar refresh on slow terminals.
@@ -1624,8 +1732,16 @@ const analyzeCommandImpl = async (
}
if (isLbugCheckpointIoError(err)) {
// #2599: when the checkpoint IO error also looks busy/locked, another
// handle holds the store open — name that actionable cause alongside the
// threshold hint (the original error is preserved so the hint still fires).
const heldOpen = isLbugCheckpointBusyError(err)
? ` Another process may hold the store open (a running \`gitnexus mcp\` server, or a\n` +
` stale reader) — close other GitNexus processes on this repo, then retry.\n`
: '';
cliError(
` LadybugDB failed while rotating/removing WAL checkpoint files.\n` +
heldOpen +
` This can happen when auto-checkpoint runs at the default threshold (~16MB).\n` +
` Retry with a larger checkpoint threshold to reduce checkpoint frequency:\n` +
` gitnexus analyze --wal-checkpoint-threshold ${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD}\n` +
+2 -1
View File
@@ -59,7 +59,8 @@ export type RecoveryHint =
| 'npm-resolution'
| 'module-not-found'
| 'gitnexusrc-invalid'
| 'default-branch-invalid';
| 'default-branch-invalid'
| 'index-lock-timeout';
/**
* Common shape for the optional structured-field bag passed to
+57 -4
View File
@@ -12,8 +12,16 @@ import {
type EmbeddingRuntimeResolution,
} from '../core/embeddings/runtime-install.js';
import { cudaRedirectDoctorStatus } from '../core/embeddings/onnxruntime-node-resolver.js';
import { checkLbugNative, probeFtsExtensionLoad } from '../core/lbug/native-check.js';
import { getOsPageSize, isPageSizeAwareLadybug } from '../core/lbug/lbug-config.js';
import {
checkLbugNative,
probeFtsExtensionLoad,
probeVectorExtensionLoad,
} from '../core/lbug/native-check.js';
import {
getEffectiveBufferPoolSize,
getOsPageSize,
isPageSizeAwareLadybug,
} from '../core/lbug/lbug-config.js';
import { diagnoseExtensionLoad } from '../core/lbug/extension-load-error.js';
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
import { t } from './i18n/index.js';
@@ -146,6 +154,22 @@ export function pageSizeDoctorLines(
return lines;
}
/**
* The hintless buffer-pool doctor line (#2631) — the pool the next Database
* open in THIS process would get. Same plain-params testable-helper shape as
* pageSizeDoctorLines above. `pool` is getEffectiveBufferPoolSize(): `0` is
* the pass-through sentinel for LadybugDB's native 80%-of-RAM default, never
* printed as "0 MiB". `envRaw` (the raw GITNEXUS_LBUG_BUFFER_POOL_SIZE value)
* marks operator-supplied absolute values as "(env override)" — no scaling
* suffix: the hintless default is deliberately unscaled (#2557), and an env
* value is absolute, so a "×N" note would misdescribe both.
*/
export function poolSizeDoctorLine(pool: number, envRaw: string | undefined): string {
const value = pool === 0 ? 'native 80% of RAM' : `${Math.round(pool / (1024 * 1024))} MiB`;
const envNote = envRaw !== undefined && envRaw.trim().length > 0 ? ' (env override)' : '';
return ` ${padDisplayEnd('pool size', 10)}${value}${envNote}`;
}
export const doctorCommand = async () => {
const fingerprint = getRuntimeFingerprint();
const capabilities = getRuntimeCapabilities();
@@ -164,6 +188,11 @@ export const doctorCommand = async () => {
for (const line of pageSizeDoctorLines(getOsPageSize(), fingerprint.ladybugdb)) {
console.log(line);
}
// Hintless buffer pool for the next DB open (#2631). Literal label like
// the page size line above (no i18n key).
console.log(
poolSizeDoctorLine(getEffectiveBufferPoolSize(), process.env.GITNEXUS_LBUG_BUFFER_POOL_SIZE),
);
const nativeCheck = checkLbugNative();
if (nativeCheck.ok) {
console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`);
@@ -195,8 +224,32 @@ export const doctorCommand = async () => {
console.log(` ${padDisplayEnd('', 18)}${remedy}`);
}
}
console.log(` ${label('doctor.labels.vectorIndex', 18)}${capabilities.vector}`);
console.log(` ${label('doctor.labels.semanticMode', 18)}${capabilities.semanticMode}`);
// Live LOAD probe for VECTOR too (#2623). The static capability is just
// `platform !== 'win32'`, so it printed "available" on the very machines
// where analyze was failing to load the extension — the same contradiction
// #2374 fixed for FTS above, and exactly what #2623's reporter saw while
// every incremental analyze died on an unloaded VECTOR extension.
const vectorProbe = nativeCheck.ok
? await probeVectorExtensionLoad()
: { loaded: false, reason: 'LadybugDB native module (lbugjs.node) failed to load' };
console.log(
` ${label('doctor.labels.vectorIndex', 18)}${vectorProbe.loaded ? 'available' : 'unavailable'}`,
);
if (!vectorProbe.loaded && vectorProbe.reason) {
console.log(` ${padDisplayEnd('', 18)}${vectorProbe.reason}`);
const { kind, remedy } = diagnoseExtensionLoad(vectorProbe.reason, 'VECTOR');
if (kind !== 'unknown') {
console.log(` ${padDisplayEnd('', 18)}${remedy}`);
}
}
// Semantic mode follows the probe, not the platform: without a loadable
// VECTOR extension the index can be neither built nor queried, so search is
// really on exact scan no matter what the platform would allow.
console.log(
` ${label('doctor.labels.semanticMode', 18)}${
vectorProbe.loaded ? capabilities.semanticMode : 'exact-scan'
}`,
);
// Surface the optional-extension install policy so offline users can see
// whether analyze/query will reach the network (extension.ladybugdb.com).
// Literal label (like the 'native' line) to avoid adding i18n keys.
+1
View File
@@ -57,6 +57,7 @@ const OPTION_DESCRIPTION_KEYS = {
'analyze|--skills': 'help.option.analyze.skills',
'analyze|--skip-agents-md': 'help.option.analyze.skipAgentsMd',
'analyze|--no-stats': 'help.option.analyze.noStats',
'analyze|--self-commit': 'help.option.analyze.selfCommit',
'analyze|--skip-skills': 'help.option.analyze.skipSkills',
'analyze|--index-only': 'help.option.analyze.indexOnly',
'analyze|--skip-git': 'help.option.skipGit',
+3 -1
View File
@@ -184,8 +184,10 @@ export const en = {
'help.option.analyze.skipAgentsMd':
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md',
'help.option.analyze.selfCommit':
'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.',
'help.option.analyze.skipSkills':
'Skip installing standard GitNexus skill files directly under .claude/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.',
'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.',
'help.option.analyze.indexOnly':
'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)',
'help.option.skipGit':
+3 -1
View File
@@ -175,8 +175,10 @@ export const zhCN = {
'根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块',
'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
'help.option.analyze.selfCommit':
'在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。',
'help.option.analyze.skipSkills':
'跳过直接安装在 .claude/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。',
'跳过直接安装在 .claude/skills/ 和 .agents/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。',
'help.option.analyze.indexOnly': '纯索引模式:跳过所有文件注入(AGENTS.md、CLAUDE.md、skills)',
'help.option.skipGit': '将提供的路径/cwd 视为索引根目录,并跳过向上查找 git 根目录',
'help.option.analyze.name':
+7 -1
View File
@@ -92,9 +92,15 @@ program
'checked-out working tree. Distinct from --default-branch (cosmetic base_ref).',
)
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
.option(
'--self-commit',
'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). ' +
'Scoped to only those two files (never `git add -A`); no-ops if neither exists, ' +
'neither changed, or the repo has no git identity configured.',
)
.option(
'--skip-skills',
'Skip installing standard GitNexus skill files directly under .claude/skills/. ' +
'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. ' +
'Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). ' +
'Use --index-only to skip all AI-context file injection.',
)
+57
View File
@@ -13,6 +13,7 @@ import { PipelineResult } from '../types/pipeline.js';
import { CommunityNode, CommunityMembership } from '../core/ingestion/community-processor.js';
import { ProcessNode } from '../core/ingestion/process-processor.js';
import { KnowledgeGraph } from '../core/graph/types.js';
import { shouldMirrorSkillsToAgents } from './ai-context.js';
const GENERATED_SKILL_PREFIX = 'gitnexus-area-';
const MAX_SKILL_NAME_LENGTH = 64;
@@ -74,6 +75,12 @@ export const generateSkillFiles = async (
const { communityResult, processResult, graph } = pipelineResult;
const outputDir = path.join(repoPath, '.claude', 'skills');
const legacyOutputDir = path.join(outputDir, 'generated');
// Some agents prioritize repo-local .agents/skills over the global
// ~/.agents/skills install (see shouldMirrorSkillsToAgents). When .agents/
// exists, mirror the generated community skills there too so those agents
// serve the up-to-date copies.
const agentsOutputDir = path.join(repoPath, '.agents', 'skills');
let mirrorToAgents = await shouldMirrorSkillsToAgents(repoPath);
// Community skills used to live under an undiscoverable `generated/`
// grouping directory. Clear that GitNexus-owned legacy output and
@@ -95,6 +102,24 @@ export const generateSkillFiles = async (
/* legacy output may not exist */
}
// Mirror cleanup: clear only stale GitNexus-generated community skills under
// .agents/skills/ (reserved gitnexus-area-* namespace), preserving mirrored
// standard skills and any user-authored skills. Never clear the whole root.
if (mirrorToAgents) {
try {
const entries = await fs.readdir(agentsOutputDir, { withFileTypes: true });
await Promise.all(
entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith(GENERATED_SKILL_PREFIX))
.map((entry) =>
fs.rm(path.join(agentsOutputDir, entry.name), { recursive: true, force: true }),
),
);
} catch {
/* mirror root may not exist yet */
}
}
if (!communityResult || !communityResult.memberships.length) {
console.log('\n Skills: no communities detected, skipping skill generation');
return { skills: [], outputPath: outputDir };
@@ -135,6 +160,20 @@ export const generateSkillFiles = async (
// Step 4: Ensure the shared project-skill root exists. Never clear it: it
// also contains user-authored and standard GitNexus skills.
await fs.mkdir(outputDir, { recursive: true });
// The .agents/ mirror is a side flow: keep it a weak dependency. If the
// mirror root cannot be created (e.g. `.agents/skills` exists as a file),
// warn and disable mirroring for this run instead of aborting canonical
// community-skill generation. Canonical writes below stay unaffected.
if (mirrorToAgents) {
try {
await fs.mkdir(agentsOutputDir, { recursive: true });
} catch (err) {
console.log(
`Warning: Could not create mirror root ${agentsOutputDir} — .agents/skills mirroring disabled for this run: ${err}`,
);
mirrorToAgents = false;
}
}
// Step 5: Generate skill files
const skills: GeneratedSkillInfo[] = [];
@@ -185,6 +224,19 @@ export const generateSkillFiles = async (
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
// Mirror to .agents/skills/ for agents that read repo-local skills
// (see mirrorToAgents above). Best-effort: a per-skill mirror failure
// must not abort canonical community-skill generation.
if (mirrorToAgents) {
try {
const agentsSkillDir = path.join(agentsOutputDir, skillName);
await fs.mkdir(agentsSkillDir, { recursive: true });
await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), content, 'utf-8');
} catch (err) {
console.log(`Warning: Could not mirror skill ${skillName} to .agents/skills: ${err}`);
}
}
const info: GeneratedSkillInfo = {
name: skillName,
label: community.label,
@@ -201,6 +253,11 @@ export const generateSkillFiles = async (
console.log(
`\n ${skills.length} skills generated \u2192 .claude/skills/${GENERATED_SKILL_PREFIX}*/`,
);
if (mirrorToAgents) {
console.log(
` ${skills.length} skills mirrored \u2192 .agents/skills/${GENERATED_SKILL_PREFIX}*/ (.agents)`,
);
}
return { skills, outputPath: outputDir };
};
+29
View File
@@ -3,6 +3,7 @@ import fs from 'fs/promises';
import nodePath from 'path';
import type { Path } from 'path-scurry';
import { logger } from '../core/logger.js';
import { getCoreExcludesFilePath, getGitInfoExcludePath } from '../storage/git.js';
const DEFAULT_IGNORE_LIST = new Set([
// Version Control
@@ -350,6 +351,8 @@ export const isHardcodedIgnoredDirectory = (name: string): boolean => {
export interface IgnoreOptions {
/** Skip .gitignore parsing, only read .gitnexusignore. Defaults to GITNEXUS_NO_GITIGNORE env var. */
noGitignore?: boolean;
/** Skip core.excludesFile and $GIT_COMMON_DIR/info/exclude. Defaults to GITNEXUS_NO_GLOBAL_IGNORE env var. */
noGlobalIgnore?: boolean;
}
export const loadIgnoreRules = async (
@@ -359,6 +362,32 @@ export const loadIgnoreRules = async (
const ig = ignore();
let hasRules = false;
// Mirror git's own precedence for ignore sources (gitignore(5)): patterns
// from core.excludesFile are consulted first (lowest precedence — git's
// real global, all-repos file), then $GIT_COMMON_DIR/info/exclude
// (per-repo, untracked — no write access to the repo needed), then
// .gitignore/.gitnexusignore below. Later ig.add() calls win on
// conflicting patterns, matching git's own last-match-wins semantics (#2606).
const skipGlobalIgnore = options?.noGlobalIgnore ?? !!process.env.GITNEXUS_NO_GLOBAL_IGNORE;
if (!skipGlobalIgnore) {
const globalSources = [
getCoreExcludesFilePath(repoPath),
getGitInfoExcludePath(repoPath),
].filter((candidate): candidate is string => candidate !== null);
for (const sourcePath of globalSources) {
try {
const content = await fs.readFile(sourcePath, 'utf-8');
ig.add(content);
hasRules = true;
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
logger.warn(` Warning: could not read ${sourcePath}: ${(err as Error).message}`);
}
}
}
}
// Allow users to bypass .gitignore parsing (e.g. when .gitignore accidentally excludes source files)
const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE;
const filenames = skipGitignore ? ['.gitnexusignore'] : ['.gitignore', '.gitnexusignore'];
+71 -6
View File
@@ -381,7 +381,14 @@ const LIBC_VARIANT = detectLibcVariant();
function resolveRuntimeVariant(): RuntimeVariant {
return {
executablePath: resolveExistingPath(process.execPath),
// Normalized like build.rootPath (#2668): executablePath is a compared
// identity field (only invokedArtifact is stripped in the comparison), and
// process.execPath carries the same Windows drive-letter case ambiguity —
// so leaving it un-normalized would reintroduce the false-stale via runtime.
executablePath: normalizeAnalyzerRootPath(
resolveExistingPath(process.execPath),
process.platform,
),
nodeVersion: process.version,
platform: process.platform,
architecture: process.arch,
@@ -524,6 +531,36 @@ function resolveExistingPath(candidate: string): string {
return realpathSync.native(path.resolve(candidate));
}
/**
* Case-stabilize a path's Windows drive letter so two processes that observed
* the same directory under different drive-letter casing (`c:\…` vs `C:\…`)
* produce byte-identical analyzer-identity path fields (#2668).
*
* `realpathSync.native` canonicalizes 8.3 short names and symlinks but does not
* guarantee the drive-letter case it returns — it can preserve whatever casing
* the caller's path carried, and `import.meta.url` casing depends on how each
* entry process (CLI shim vs `npx`/npm wrapper vs server worker) was launched.
* When `analyze` stamps `build.rootPath` under one casing and `status`
* recomputes it under another, `analyzerRunnerIdentitiesEqual` deep-compares
* unequal and `status` reports a freshly-analyzed, untouched repo as stale.
* Uppercasing the drive letter (drive letters are case-insensitive; uppercase
* is the conventional form) collapses that variance. POSIX paths are returned
* unchanged. `platform` is explicit so the transform is unit-testable off
* Windows.
*
* The optional `\\?\` extended-length prefix (which `realpathSync.native` can
* emit for paths over MAX_PATH) is preserved and the drive letter after it is
* still normalized; UNC paths (`\\server\share`, `\\?\UNC\...`) have no drive
* letter and are left untouched.
*/
export function normalizeAnalyzerRootPath(p: string, platform: NodeJS.Platform): string {
if (platform !== 'win32') return p;
return p.replace(
/^(\\\\\?\\)?([a-z]):/,
(_match, prefix: string | undefined, drive: string) => `${prefix ?? ''}${drive.toUpperCase()}:`,
);
}
function isFile(candidate: string): boolean {
try {
return statSync(candidate).isFile();
@@ -553,11 +590,30 @@ function manifestLabel(manifest: PackageManifest): string {
return `${name}@${version}`;
}
function isInside(parent: string, candidate: string): boolean {
const relative = path.relative(parent, candidate);
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..');
/**
* Whether `candidate` is `parent` itself or lives beneath it.
*
* The absolute-result rejection is load-bearing on Windows: `path.relative`
* cannot express a relative path between two different drives, so it returns the
* absolute target instead — `path.win32.relative('C:\\parent', 'D:\\other')` is
* `'D:\\other'`. That string does not start with `..`, so the `..` checks alone
* would report an unrelated drive as *inside* the parent. This mirrors the
* containment guards elsewhere in the repo (`server/api.ts`,
* `server/git-clone.ts`, `group/extractors/fs-utils.ts`), which all pair the
* `..` check with `path.isAbsolute`.
*
* `pathApi` is injectable so the win32 semantics are unit-testable from a POSIX
* runner; production callers always use the platform-bound `path`.
*/
function isInside(parent: string, candidate: string, pathApi: typeof path = path): boolean {
const relative = pathApi.relative(parent, candidate);
if (pathApi.isAbsolute(relative)) return false;
return relative === '' || (!relative.startsWith(`..${pathApi.sep}`) && relative !== '..');
}
/** Test seam for {@link isInside} (see `_hashAnalyzerIdentityFramesForTests`). */
export const _isInsideForTests = isInside;
function resolveBuildRoot(analyzerModulePath: string): {
packageRoot: string;
buildRoot: string;
@@ -570,9 +626,18 @@ function resolveBuildRoot(analyzerModulePath: string): {
const packageRoot = path.dirname(cursor);
const packageJson = path.join(packageRoot, 'package.json');
if (lstatSync(packageJson).isFile()) {
// Normalize the drive-letter case at this single upstream source so
// every derived identity path field — build.rootPath, identityCacheKey,
// and (via collectDependencyInputs) dependencyRuntime.manifestPath /
// lockfilePath — inherits a case-stable root and analyze-stamp equals
// status-recompute regardless of launch-path casing (#2668).
// Migration: a Windows index stamped before this fix carries the old,
// un-normalized casing, so the first post-upgrade `status` sees one
// spurious "stale" flip — self-healing on the next `analyze`, which
// re-stamps the normalized (idempotent) form.
return {
packageRoot,
buildRoot: cursor,
packageRoot: normalizeAnalyzerRootPath(packageRoot, process.platform),
buildRoot: normalizeAnalyzerRootPath(cursor, process.platform),
kind: base === 'src' ? 'source' : 'distribution',
};
}
+39 -14
View File
@@ -29,6 +29,7 @@ interface HttpConfig {
maxAttempts: number;
retryCapMs: number;
minIntervalMs: number;
requestDimensions?: number;
}
export interface EmbeddingRequestOptions {
@@ -106,20 +107,26 @@ const paceHttpRequest = async (minIntervalMs: number, signal?: AbortSignal): Pro
};
/**
* Stable lead of the {@link readConfig} malformed-`GITNEXUS_EMBEDDING_DIMS`
* error. `readConfig` throws a plain `Error` (not an {@link HttpEmbeddingError})
* because this is a *config* mistake, not an endpoint failure — so the CLI
* recognizes it by this lead ({@link isHttpEmbeddingDimsError}) and prints a
* clean config message instead of a raw stack dump. See #2385.
* Stable lead of a {@link readConfig} malformed dims-env error. `readConfig`
* throws a plain `Error` (not an {@link HttpEmbeddingError}) for a malformed
* `GITNEXUS_EMBEDDING_DIMS` or `GITNEXUS_EMBEDDING_REQUEST_DIMS` because it's a
* *config* mistake, not an endpoint failure — so the CLI recognizes it by this
* lead ({@link isHttpEmbeddingDimsError}) and prints a clean config message
* instead of a raw stack dump. Each var names itself so the message points the
* operator at the variable they actually set, not a sibling. See #2385.
*/
const EMBEDDING_DIMS_ENV_ERROR_LEAD = 'GITNEXUS_EMBEDDING_DIMS must be a positive integer';
const dimsEnvErrorLead = (name: string): string => `${name} must be a positive integer`;
const EMBEDDING_DIMS_ENV_ERROR_LEAD = dimsEnvErrorLead('GITNEXUS_EMBEDDING_DIMS');
const EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD = dimsEnvErrorLead('GITNEXUS_EMBEDDING_REQUEST_DIMS');
/**
* @internal Exported for the CLI analyze error handler. True when `message` is
* the {@link readConfig} malformed-DIMS config error (a plain `Error`).
* @internal Exported for the CLI analyze error handler. True when `message` is a
* {@link readConfig} malformed dims-env config error (a plain `Error`) — for
* either `GITNEXUS_EMBEDDING_DIMS` or `GITNEXUS_EMBEDDING_REQUEST_DIMS`.
*/
export const isHttpEmbeddingDimsError = (message: string): boolean =>
message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD);
message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD) ||
message.includes(EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD);
/**
* Build config from the current process.env snapshot.
@@ -147,6 +154,23 @@ const readConfig = (): HttpConfig | null => {
dimensions = parsed;
}
const rawRequestDims = process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS?.trim();
let requestDimensions = dimensions;
if (rawRequestDims) {
if (/^(omit|none|off|false|0)$/i.test(rawRequestDims)) {
requestDimensions = undefined;
} else {
if (!/^\d+$/.test(rawRequestDims)) {
throw new Error(`${EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD}, got "${rawRequestDims}"`);
}
const parsed = parseInt(rawRequestDims, 10);
if (parsed <= 0) {
throw new Error(`${EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD}, got "${rawRequestDims}"`);
}
requestDimensions = parsed;
}
}
return {
baseUrl: baseUrl.replace(/\/+$/, ''),
model,
@@ -163,6 +187,7 @@ const readConfig = (): HttpConfig | null => {
300_000,
),
minIntervalMs: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 0, 300_000),
requestDimensions,
};
};
@@ -283,9 +308,9 @@ const isEmbeddingItem = (item: unknown): item is EmbeddingItem =>
* the `dimensions` field in the request body. Endpoints that implement
* Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3,
* Voyage) return a truncated vector at that size; endpoints that do not
* recognise the field may ignore it or return 400. Leave
* `GITNEXUS_EMBEDDING_DIMS` unset for strict backends that reject
* unknown fields.
* recognise the field may ignore it or return 400. Set
* `GITNEXUS_EMBEDDING_REQUEST_DIMS=omit` for strict backends while keeping
* `GITNEXUS_EMBEDDING_DIMS` set to the returned vector size.
*/
const httpEmbedBatch = async (
url: string,
@@ -434,7 +459,7 @@ export const httpEmbed = async (
config.model,
config.apiKey,
batchIndex,
config.dimensions,
config.requestDimensions,
requestOptions,
config.maxAttempts,
config.retryCapMs,
@@ -491,7 +516,7 @@ export const httpEmbedQuery = async (
config.model,
config.apiKey,
0,
config.dimensions,
config.requestDimensions,
requestOptions,
config.maxAttempts,
config.retryCapMs,
@@ -3,8 +3,10 @@
*
* `module.registerHooks` — the synchronous ESM/CJS resolution-hook API the
* embedding-stack resolvers rely on — was added in Node 22.15.0 (and 23.5.0 on
* the 23.x line). The gitnexus engines floor is `>=22.0.0`, which admits Node
* 22.0–22.14 AND 23.0–23.4, where the export is absent.
* the 23.x line). The gitnexus engines floor is `^22.18.0 || >=24.11.0`, so
* every supported runtime exposes it — but `engines` is advisory (not
* engine-strict), so a below-floor Node (22.0–22.14, or the unsupported
* 23.0–23.4 line) can still run, where the export is absent.
*
* In this `"type": "module"` package, a *static named* import of a missing
* builtin export (`import { registerHooks } from 'node:module'`) is a
@@ -52,8 +52,8 @@
* per-resolution cost is a single string comparison.
*
* `module.registerHooks` is marked `@experimental` and requires Node >= 22.15
* (the gitnexus engines floor is >= 22.0.0). On older runtimes it is absent and
* this is a graceful no-op: embeddings then resolve onnxruntime-common exactly
* (below the gitnexus engines floor of `^22.18.0 || >=24.11.0`). On below-floor
* runtimes it is absent and this is a graceful no-op: embeddings then resolve onnxruntime-common exactly
* as before — fine on hoisted layouts. Any failure during installation is
* swallowed.
*/
@@ -100,9 +100,9 @@ export const ensureOnnxRuntimeCommonResolvable = (): void => {
attempted = true;
try {
// Node < 22.15 / < 23.5 (the gitnexus engines floor is >= 22.0.0): no
// synchronous hooks API. Degrade gracefully — the import still works on
// hoisted layouts.
// Node < 22.15 / < 23.5 (below the gitnexus engines floor of
// ^22.18.0 || >=24.11.0): no synchronous hooks API. Degrade gracefully —
// the import still works on hoisted layouts.
const registerHooks = getRegisterHooks();
if (typeof registerHooks !== 'function') return;
@@ -36,8 +36,8 @@
* So CUDA-12 hosts, Windows (DirectML), macOS, and CPU-only hosts are
* untouched. Idempotent; any failure is swallowed and leaves the default
* resolution exactly as before. `module.registerHooks` requires Node >= 22.15
* (the gitnexus engines floor is >= 22.0.0); on older runtimes the redirect is
* a no-op, but the default copy's CUDA major is still probed so an
* (below the gitnexus engines floor of `^22.18.0 || >=24.11.0`); on below-floor
* runtimes the redirect is a no-op, but the default copy's CUDA major is still probed so an
* already-matching host (e.g. CUDA 12 + transformers' CUDA-12 build) keeps
* auto-selecting the GPU.
* `npm link` / symlinked local-dev checkouts are a known caveat: `resolveOurOrtNodeDir`/
@@ -191,7 +191,7 @@ export const ensureEmbeddingStackResolvable = (): void => {
hookAttempted = true;
try {
// Node < 22.15 / < 23.5 (engines floor is >= 22.0.0): no synchronous hooks
// Node < 22.15 / < 23.5 (below the engines floor of ^22.18.0 || >=24.11.0): no synchronous hooks
// API. Degrade gracefully — normally-installed stacks still resolve; only
// the runtime-prefix fallback is unavailable. Reachable now that the import
// is a namespace access (see node-module-compat.ts) rather than a static
+5
View File
@@ -162,6 +162,11 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
forEachRelationship(fn: (rel: GraphRelationship) => void) {
relationshipMap.forEach(fn);
},
forEachRelationshipFields(
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
) {
relationshipMap.forEach((rel) => fn(rel.sourceId, rel.targetId, rel.type, rel.confidence));
},
getNode: (id: string) => nodeMap.get(id),
// O(1) count getters - avoid creating arrays just for length
+20
View File
@@ -27,6 +27,19 @@ export interface KnowledgeGraph {
iterRelationshipsByType: (type: RelationshipType) => IterableIterator<GraphRelationship>;
forEachNode: (fn: (node: GraphNode) => void) => void;
forEachRelationship: (fn: (rel: GraphRelationship) => void) => void;
/**
* Zero-allocation relationship scan: fields, not objects (#2680).
*
* The whole-graph scans (the local-symbol pruner, community detection,
* process extraction) read only these four fields, and materializing a
* `GraphRelationship` per edge just to read them dominates iteration cost once
* relationships are held columnar — measured at ~90 ms per analyze on a
* million-edge graph. Prefer this over `forEachRelationship` in any pass that
* walks every edge and needs no other field.
*/
forEachRelationshipFields: (
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
) => void;
getNode: (id: string) => GraphNode | undefined;
nodeCount: number;
relationshipCount: number;
@@ -34,5 +47,12 @@ export interface KnowledgeGraph {
addRelationship: (relationship: GraphRelationship) => void;
removeNode: (nodeId: string) => boolean;
removeNodesByFile: (filePath: string) => number;
/**
* Removes the relationship with this id, returning whether it existed.
*
* Implementations that offload relationships out of memory cannot always tell
* "absent" from "already written out" — `GraphEmitSink` deliberately throws
* rather than answering `false` for an edge it can no longer recall (#2680).
*/
removeRelationship: (relationshipId: string) => boolean;
}
+2
View File
@@ -1031,6 +1031,8 @@ const LBUG_OPEN_RETRY_PATTERNS = [
'lock held by another process',
];
// Cross-repo bridge RO open retry. Catalogued as entry 5 of the lbug-config
// retry-budget registry; caps back-off so total wait ~3s.
const LBUG_OPEN_RETRY_ATTEMPTS = 10;
const LBUG_OPEN_RETRY_BASE_MS = 100;
/** Cap individual back-off delays so the total wait is bounded (~3s). */
@@ -2,7 +2,7 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { ClassExtractionConfig } from '../../class-types.js';
import { synthesizeJavaAnonymousClassName } from '../../utils/ast-helpers.js';
import { synthesizeJavaTypeIdentity } from '../../utils/ast-helpers.js';
// ---------------------------------------------------------------------------
// Java
@@ -33,10 +33,10 @@ export const javaClassConfig: ClassExtractionConfig = {
'record_declaration',
],
extractName(node) {
if (node.type === 'object_creation_expression' || node.type === 'enum_constant') {
return synthesizeJavaAnonymousClassName(node);
}
return undefined;
return synthesizeJavaTypeIdentity(node)?.name;
},
extractType(node) {
return synthesizeJavaTypeIdentity(node)?.label;
},
// An anonymous body whose name CANNOT be synthesized must not become a
// Class node at all. Without this skip, `extract()`'s
@@ -50,7 +50,7 @@ export const javaClassConfig: ClassExtractionConfig = {
definitionNode !== undefined &&
(definitionNode.type === 'object_creation_expression' ||
definitionNode.type === 'enum_constant') &&
synthesizeJavaAnonymousClassName(definitionNode) === undefined
synthesizeJavaTypeIdentity(definitionNode) === undefined
);
},
};
@@ -290,14 +290,16 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
const connectedNodes = new Set<string>();
const nodeDegree = new Map<string, number>();
knowledgeGraph.forEachRelationship((rel) => {
if (!isClusteringRelationship(rel.type) || rel.sourceId === rel.targetId) return;
if (isLarge && rel.confidence < MIN_CONFIDENCE_LARGE) return;
// Field-wise scan (#2680): this walks every edge and reads only these four,
// so taking objects would allocate one per edge for nothing.
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
if (!isClusteringRelationship(type) || sourceId === targetId) return;
if (isLarge && confidence < MIN_CONFIDENCE_LARGE) return;
connectedNodes.add(rel.sourceId);
connectedNodes.add(rel.targetId);
nodeDegree.set(rel.sourceId, (nodeDegree.get(rel.sourceId) || 0) + 1);
nodeDegree.set(rel.targetId, (nodeDegree.get(rel.targetId) || 0) + 1);
connectedNodes.add(sourceId);
connectedNodes.add(targetId);
nodeDegree.set(sourceId, (nodeDegree.get(sourceId) || 0) + 1);
nodeDegree.set(targetId, (nodeDegree.get(targetId) || 0) + 1);
});
const nodes: CommunityProjectionNode[] = [];
@@ -328,12 +330,12 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
const seenEdges = new Set<string>();
const edges: Array<readonly [number, number]> = [];
knowledgeGraph.forEachRelationship((rel) => {
if (!isClusteringRelationship(rel.type) || rel.sourceId === rel.targetId) return;
if (isLarge && rel.confidence < MIN_CONFIDENCE_LARGE) return;
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
if (!isClusteringRelationship(type) || sourceId === targetId) return;
if (isLarge && confidence < MIN_CONFIDENCE_LARGE) return;
const sourceIndex = nodeIndexById.get(rel.sourceId);
const targetIndex = nodeIndexById.get(rel.targetId);
const sourceIndex = nodeIndexById.get(sourceId);
const targetIndex = nodeIndexById.get(targetId);
if (sourceIndex === undefined || targetIndex === undefined || sourceIndex === targetIndex)
return;
@@ -1,61 +1,77 @@
/**
* Per-language DI field-matcher registry — the lookup the generic `di`
* pipeline phase uses to decide whether a `Property` node is a
* dependency-injection fan-out candidate.
* Per-language DI resolver registry — the lookup the generic `di` pipeline
* phase uses to discover injection sites and provider metadata on graph nodes.
*
* Mirrors `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`): a
* single-valued `ReadonlyMap<SupportedLanguages, DiFieldMatcher>` consumed by
* single-valued `ReadonlyMap<SupportedLanguages, DiResolver>` consumed by
* a framework-neutral phase, so no language or framework names leak into
* shared pipeline code. Adding a framework is two lines: implement a
* `DiFieldMatcher` in `di-extractors/<framework>.ts` and register it here.
* shared pipeline code. Adding a framework means implementing a `DiResolver`
* in `di-extractors/<framework>.ts` and registering it here.
*
* Scope honesty: matchers are per-language *field-injection* matchers.
* Constructor injection (the dominant modern Spring idiom) lives on
* Method/parameter nodes and would require widening the phase's routing —
* deliberately out of scope (see the plan's Deferred work). The registry is
* single-valued per language, matching the `SCOPE_RESOLVERS` shape; widen the
* value type to arrays only when a second same-language framework actually
* lands (a one-line type change then).
* The registry is single-valued per language, matching the `SCOPE_RESOLVERS`
* shape; widen the value type to arrays only when a second same-language
* framework actually lands. Java and Kotlin share Spring's attached metadata
* contract while retaining language-specific syntax capture.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { GraphNode } from 'gitnexus-shared';
import { springDiFieldMatcher } from './spring.js';
import { springDiResolver } from './spring.js';
/** A successful DI field match, produced by a per-language matcher. */
export interface DiFieldMatch {
/** The element type name `T` — the injected bean interface. */
elementTypeName: string;
/** A successful injection-site match, produced by a per-language resolver. */
export interface DiInjectionMatch {
/** The requested dependency type name. */
targetTypeName: string;
/** A collection receives every matching provider; a single site may need
* framework-specific named/preferred-provider disambiguation. */
cardinality: 'single' | 'collection';
/** Statically known provider name requested at the injection site. The
* resolver owns the human-readable explanation of that selection. */
namedSelection?: {
name: string;
reason: string;
};
/** Human-readable edge reason. Framework specifics (names, idioms,
* collection wrapper, gating annotation) live in this payload so the
* shared `di` phase stays framework-neutral. */
reason: string;
}
/**
* A per-language field-injection matcher: given a `Property` node, return the
* parsed DI match or `null` when the field is not container-injected. The
* matcher receives the whole node (not pre-plucked fields) so the shared
* phase stays ignorant of which properties matter.
*/
export type DiFieldMatcher = (node: GraphNode) => DiFieldMatch | null;
/** Provider metadata used by the shared resolver without naming a framework. */
export interface DiProviderMatch {
/** Provider names and aliases that can satisfy a named injection. */
names: readonly string[];
/** Present when the framework marks this as its preferred candidate. The
* value is appended to the emitted edge reason when it disambiguates. */
preferenceReason?: string;
}
/** Per-language DI behavior. Matchers receive whole nodes so the shared phase
* remains ignorant of language/framework-specific property shapes. */
export interface DiResolver {
matchInjectionSites(node: GraphNode): readonly DiInjectionMatch[];
matchProvider(node: GraphNode): DiProviderMatch | null;
}
/** All `SupportedLanguages` string values, for narrowing raw graph strings. */
const SUPPORTED_LANGUAGE_VALUES: ReadonlySet<string> = new Set(Object.values(SupportedLanguages));
/**
* Type guard narrowing an arbitrary graph `language` string to
* `SupportedLanguages`, so `DI_MATCHERS.get()` needs no cast.
* `SupportedLanguages`, so `DI_RESOLVERS.get()` needs no cast.
*/
export function isSupportedLanguage(value: string): value is SupportedLanguages {
return SUPPORTED_LANGUAGE_VALUES.has(value);
}
/** Map of `SupportedLanguages` → `DiFieldMatcher`. The `di` phase routes each
* `Property` node here by `node.properties.language`; no entry ⇒ the node is
/** Map of `SupportedLanguages` → `DiResolver`. The `di` phase routes each
* graph node here by `node.properties.language`; no entry ⇒ the node is
* skipped. This is the single source of truth for which languages (and,
* transitively, frameworks) produce INJECTS edges. */
export const DI_MATCHERS: ReadonlyMap<SupportedLanguages, DiFieldMatcher> = new Map<
export const DI_RESOLVERS: ReadonlyMap<SupportedLanguages, DiResolver> = new Map<
SupportedLanguages,
DiFieldMatcher
>([[SupportedLanguages.Java, springDiFieldMatcher]]);
DiResolver
>([
[SupportedLanguages.Java, springDiResolver],
[SupportedLanguages.Kotlin, springDiResolver],
]);
@@ -51,13 +51,15 @@
* between `<` and the element) are NOT stripped and fail closed —
* acceptable.
*
* Registered under `SupportedLanguages.Java` in `./index.ts` (`DI_MATCHERS`);
* language routing is the registry's job, so the matcher itself never reads
* `node.properties.language`.
* Registered for Java and Kotlin in `./index.ts` (`DI_RESOLVERS`); language
* routing is the registry's job, so the matcher itself never reads
* `node.properties.language`. Kotlin's AST-backed class metadata is the
* primary path because Kotlin Property extraction intentionally exposes less
* annotation/type syntax than Java's legacy field contract.
*/
import type { GraphNode } from 'gitnexus-shared';
import type { DiFieldMatch, DiFieldMatcher } from './index.js';
import type { DiInjectionMatch, DiProviderMatch, DiResolver } from './index.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
@@ -84,6 +86,17 @@ const WILDCARD_SUPER_PREFIX = '? super ';
* punctuation) fails closed. */
const JAVA_TYPE_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
/** Ephemeral Class-node property populated by Java's post-resolution Spring
* metadata hook. It is consumed in the same pipeline run before persistence. */
export const SPRING_DI_INJECTION_SITES_PROPERTY = 'springDiInjectionSites';
/** Ephemeral Class-node property carrying Spring bean names / @Primary. */
export const SPRING_DI_PROVIDER_PROPERTY = 'springDiProvider';
/** Marker placed on Property nodes whose richer AST-backed field fact was
* attached to the owning Class, suppressing the legacy collection fallback. */
export const SPRING_DI_CAPTURED_FIELD_PROPERTY = 'springDiCapturedField';
/**
* Split a generic-argument list on TOP-LEVEL commas only, tracking `<`/`>`
* bracket depth so nested generics (e.g. the `Pair<A,B>` key in
@@ -181,13 +194,33 @@ export function parseSpringCollectionType(
return { collectionType: wrapper, elementTypeName };
}
/** Parse either a supported collect-all type or a standard single bean type. */
export function parseSpringInjectionType(
rawDeclaredType: string,
): { targetTypeName: string; cardinality: 'single' | 'collection'; displayType: string } | null {
const collection = parseSpringCollectionType(rawDeclaredType);
if (collection !== null) {
return {
targetTypeName: collection.elementTypeName,
cardinality: 'collection',
displayType: `${collection.collectionType}<${collection.elementTypeName}>`,
};
}
const normalized = rawDeclaredType.replace(/\s+/g, '').trim();
if (!JAVA_TYPE_NAME_PATTERN.test(normalized)) return null;
return { targetTypeName: normalized, cardinality: 'single', displayType: normalized };
}
/**
* Match a `Property` node against Spring's collection-injection shape.
*
* Returns the parsed match (with a Spring-specific human-readable `reason`
* payload) or `null` when the field is not container-injected.
*/
export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMatch | null => {
export const springDiFieldMatcher = (
node: GraphNode,
): { elementTypeName: string; reason: string } | null => {
// Injection-annotation gate: only fields the container actually
// injects (@Autowired / @Inject) are candidates. Plain collection
// fields are never injected; @Resource is deliberately excluded
@@ -220,3 +253,62 @@ export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMa
reason: `Spring DI: ${matchedAnnotation} ${parsed.collectionType}<${parsed.elementTypeName}>`,
};
};
function isInjectionMatch(value: unknown): value is DiInjectionMatch {
if (value === null || typeof value !== 'object') return false;
const match = value as Partial<DiInjectionMatch>;
const namedSelection = match.namedSelection;
return (
typeof match.targetTypeName === 'string' &&
(match.cardinality === 'single' || match.cardinality === 'collection') &&
typeof match.reason === 'string' &&
(namedSelection === undefined ||
(typeof namedSelection === 'object' &&
namedSelection !== null &&
typeof namedSelection.name === 'string' &&
typeof namedSelection.reason === 'string'))
);
}
function isProviderMatch(value: unknown): value is DiProviderMatch {
if (value === null || typeof value !== 'object') return false;
const provider = value as Partial<DiProviderMatch>;
return (
Array.isArray(provider.names) &&
provider.names.every((name) => typeof name === 'string') &&
(provider.preferenceReason === undefined || typeof provider.preferenceReason === 'string')
);
}
/** JVM/Spring resolver registered behind the framework-neutral DI seam. */
export const springDiResolver: DiResolver = {
matchInjectionSites(node): readonly DiInjectionMatch[] {
const matches: DiInjectionMatch[] = [];
// Preserve the existing Property-node collection contract for hand-built
// graphs and for compatibility with pre-#2414 extraction fixtures.
if (node.label === 'Property' && node.properties[SPRING_DI_CAPTURED_FIELD_PROPERTY] !== true) {
const field = springDiFieldMatcher(node);
if (field !== null) {
matches.push({
targetTypeName: field.elementTypeName,
cardinality: 'collection',
reason: field.reason,
});
}
}
const attached = node.properties[SPRING_DI_INJECTION_SITES_PROPERTY];
if (Array.isArray(attached)) {
for (const candidate of attached) {
if (isInjectionMatch(candidate)) matches.push(candidate);
}
}
return matches;
},
matchProvider(node): DiProviderMatch | null {
const attached = node.properties[SPRING_DI_PROVIDER_PROPERTY];
return isProviderMatch(attached) ? attached : null;
},
};
@@ -59,6 +59,7 @@ export interface SpringBeanCandidateAdapter {
}
type OwnedTypeNamesByOwner = ReadonlyMap<string, ReadonlySet<string>>;
type RecognizedAnnotationNames = { readonly has: (value: string) => boolean };
function simpleNameOf(def: SymbolDefinition): string | undefined {
const qualifiedName = def.qualifiedName;
@@ -152,7 +153,11 @@ function hasVisibleTypeBinding(
return false;
}
function wildcardImportTarget(parsed: ParsedFile, simpleName: string): string | undefined {
function wildcardImportTarget(
parsed: ParsedFile,
simpleName: string,
recognizedAnnotations: RecognizedAnnotationNames,
): string | undefined {
const wildcardPackages = new Set(
parsed.parsedImports
.filter((entry) => entry.kind === 'wildcard')
@@ -161,37 +166,40 @@ function wildcardImportTarget(parsed: ParsedFile, simpleName: string): string |
if (wildcardPackages.size !== 1) return undefined;
const [packageName] = wildcardPackages;
const target = `${packageName}.${simpleName}`;
return SPRING_BEAN_STEREOTYPES.has(target) ? target : undefined;
return recognizedAnnotations.has(target) ? target : undefined;
}
function resolveSpringAnnotation(
rawName: string,
parsed: ParsedFile,
enclosingScope: ScopeId | null,
indexes: ScopeResolutionIndexes,
ownedTypeNamesByOwner: OwnedTypeNamesByOwner,
isPackageVisibilityIncomplete: boolean,
): string | undefined {
if (rawName.includes('.')) {
return SPRING_BEAN_STEREOTYPES.has(rawName) ? rawName : undefined;
}
/** Build a scope-aware Spring annotation resolver shared by framework hooks. */
export function createSpringAnnotationNameResolver(indexes: ScopeResolutionIndexes) {
const ownedTypeNamesByOwner = buildOwnedTypeNamesByOwner(indexes);
return (
rawName: string,
parsed: ParsedFile,
enclosingScope: ScopeId | null,
recognizedAnnotations: RecognizedAnnotationNames,
isPackageVisibilityIncomplete: boolean,
): string | undefined => {
if (rawName.includes('.')) {
return recognizedAnnotations.has(rawName) ? rawName : undefined;
}
if (hasLexicalTypeDeclaration(enclosingScope, rawName, indexes)) return undefined;
if (hasInheritedTypeDeclaration(enclosingScope, rawName, indexes, ownedTypeNamesByOwner)) {
return undefined;
}
if (hasLexicalTypeDeclaration(enclosingScope, rawName, indexes)) return undefined;
if (hasInheritedTypeDeclaration(enclosingScope, rawName, indexes, ownedTypeNamesByOwner)) {
return undefined;
}
const explicitImports = explicitImportTargets(parsed, rawName);
if (explicitImports.size > 0) {
if (explicitImports.size !== 1) return undefined;
const [imported] = explicitImports;
return SPRING_BEAN_STEREOTYPES.has(imported) ? imported : undefined;
}
const explicitImports = explicitImportTargets(parsed, rawName);
if (explicitImports.size > 0) {
if (explicitImports.size !== 1) return undefined;
const [imported] = explicitImports;
return recognizedAnnotations.has(imported) ? imported : undefined;
}
const wildcardTarget = wildcardImportTarget(parsed, rawName);
if (wildcardTarget === undefined || isPackageVisibilityIncomplete) return undefined;
const wildcardTarget = wildcardImportTarget(parsed, rawName, recognizedAnnotations);
if (wildcardTarget === undefined || isPackageVisibilityIncomplete) return undefined;
return hasVisibleTypeBinding(enclosingScope, rawName, indexes) ? undefined : wildcardTarget;
return hasVisibleTypeBinding(enclosingScope, rawName, indexes) ? undefined : wildcardTarget;
};
}
/** Build a language hook that enriches Class nodes after scope resolution. */
@@ -202,7 +210,7 @@ export function createSpringBeanCandidateAttacher(adapter: SpringBeanCandidateAd
nodeLookup: GraphNodeLookup,
indexes: ScopeResolutionIndexes,
): void => {
const ownedTypeNamesByOwner = buildOwnedTypeNamesByOwner(indexes);
const resolveSpringAnnotation = createSpringAnnotationNameResolver(indexes);
for (const parsed of parsedFiles) {
for (const fact of adapter.getClassAnnotationFacts(parsed.filePath)) {
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
@@ -221,8 +229,7 @@ export function createSpringBeanCandidateAttacher(adapter: SpringBeanCandidateAd
rawName,
parsed,
classScope.parent,
indexes,
ownedTypeNamesByOwner,
SPRING_BEAN_STEREOTYPES,
adapter.isPackageVisibilityIncomplete(parsed.filePath),
);
if (annotation !== undefined) recognized.add(annotation);
@@ -0,0 +1,166 @@
import type { GraphNode } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
export const SPRING_CONFIG_DESCRIPTION = 'Spring configuration property';
export interface SpringValueConsumer {
readonly kind: 'value';
readonly fieldName: string;
readonly line: number;
readonly keys: readonly string[];
}
export interface SpringConfigurationPropertiesConsumer {
readonly kind: 'configuration-properties';
readonly className: string;
readonly line: number;
readonly prefix: string;
}
export type SpringConfigConsumer = SpringValueConsumer | SpringConfigurationPropertiesConsumer;
export interface SpringConfigConsumerBatch {
readonly filePath: string;
readonly consumers: readonly SpringConfigConsumer[];
}
function closestNode(
candidates: readonly GraphNode[],
filePath: string,
name: string,
line: number,
): GraphNode | undefined {
return candidates
.filter((node) => node.properties.filePath === filePath && node.properties.name === name)
.sort(
(left, right) =>
Math.abs(Number(left.properties.startLine ?? 0) - line) -
Math.abs(Number(right.properties.startLine ?? 0) - line),
)[0];
}
function markUnresolved(node: GraphNode, key: string): void {
const marker = `Spring config unresolved: ${key}`;
const existing =
typeof node.properties.description === 'string' ? node.properties.description : '';
if (existing.includes(marker)) return;
node.properties.description = existing.length > 0 ? `${existing}; ${marker}` : marker;
}
function relaxedName(value: string): string {
return value.toLowerCase().replace(/[-_.]/g, '');
}
function isSpringConfigNode(node: GraphNode): boolean {
return (
node.label === 'Property' &&
typeof node.properties.description === 'string' &&
node.properties.description.startsWith(SPRING_CONFIG_DESCRIPTION)
);
}
/**
* Attach normalized, language-provider-produced Spring consumers to config
* keys already present in the shared graph.
*/
export function bindSpringConfigConsumers(
graph: KnowledgeGraph,
batches: readonly SpringConfigConsumerBatch[],
): void {
if (batches.length === 0) return;
const configNodes: GraphNode[] = [];
const propertyNodes: GraphNode[] = [];
const classNodes: GraphNode[] = [];
for (const node of graph.iterNodes()) {
if (isSpringConfigNode(node)) configNodes.push(node);
else if (node.label === 'Property') propertyNodes.push(node);
else if (node.label === 'Class' || node.label === 'Record') classNodes.push(node);
}
const keyNodes = new Map<string, GraphNode[]>();
for (const node of configNodes) {
const key = String(node.properties.name);
const bucket = keyNodes.get(key) ?? [];
bucket.push(node);
keyNodes.set(key, bucket);
}
const propertiesByOwner = new Map<string, GraphNode[]>();
for (const rel of graph.iterRelationshipsByType('HAS_PROPERTY')) {
const property = graph.getNode(rel.targetId);
if (property?.label !== 'Property' || isSpringConfigNode(property)) continue;
const members = propertiesByOwner.get(rel.sourceId) ?? [];
members.push(property);
propertiesByOwner.set(rel.sourceId, members);
}
const addBinding = (
source: GraphNode,
target: GraphNode,
reason: string,
confidence: number,
): void => {
const edgeId = generateId('USES', `${source.id}->${target.id}:${reason}`);
graph.addRelationship({
id: edgeId,
sourceId: source.id,
targetId: target.id,
type: 'USES',
confidence,
reason,
});
};
for (const { filePath, consumers } of batches) {
for (const consumer of consumers) {
if (consumer.kind === 'value') {
const field = closestNode(propertyNodes, filePath, consumer.fieldName, consumer.line);
if (field === undefined) continue;
for (const key of consumer.keys) {
const matches = keyNodes.get(key) ?? [];
if (matches.length === 0) {
markUnresolved(field, key);
continue;
}
for (const match of matches) {
addBinding(field, match, `spring-config:@Value ${key}`, 1);
}
}
continue;
}
const owner = closestNode(classNodes, filePath, consumer.className, consumer.line);
if (owner === undefined) continue;
const prefix = `${consumer.prefix}.`;
const matches = configNodes.filter((node) => {
const key = String(node.properties.name);
return key === consumer.prefix || key.startsWith(prefix);
});
if (matches.length === 0) {
markUnresolved(owner, consumer.prefix);
continue;
}
for (const match of matches) {
addBinding(owner, match, `spring-config:@ConfigurationProperties ${consumer.prefix}`, 0.95);
}
for (const field of propertiesByOwner.get(owner.id) ?? []) {
const fieldName = relaxedName(String(field.properties.name));
for (const match of matches) {
const key = String(match.properties.name);
const suffix = key === consumer.prefix ? '' : key.slice(prefix.length);
const firstSegment = suffix.split(/[.\[]/, 1)[0];
if (firstSegment.length === 0 || relaxedName(firstSegment) !== fieldName) continue;
addBinding(
field,
match,
`spring-config:@ConfigurationProperties field ${consumer.prefix}`,
0.95,
);
}
}
}
}
}
@@ -0,0 +1,317 @@
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { DiInjectionMatch, DiProviderMatch } from '../../di-extractors/index.js';
import {
parseSpringInjectionType,
SPRING_DI_CAPTURED_FIELD_PROPERTY,
SPRING_DI_INJECTION_SITES_PROPERTY,
SPRING_DI_PROVIDER_PROPERTY,
} from '../../di-extractors/spring.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import { createSpringAnnotationNameResolver } from './bean-candidates.js';
import { SPRING_BEAN_STEREOTYPES } from './bean-catalog.js';
export interface SpringDiAnnotationFact {
readonly name: string;
readonly text: string;
}
export interface SpringDiDependencyFact<Annotation extends SpringDiAnnotationFact> {
readonly name: string;
readonly rawType: string;
readonly annotations: readonly Annotation[];
}
export interface SpringDiInjectionSiteFact<
Annotation extends SpringDiAnnotationFact,
SiteKind extends string,
> {
readonly kind: SiteKind;
readonly memberName: string;
readonly implicitConstructor: boolean;
readonly annotations: readonly Annotation[];
readonly dependencies: readonly SpringDiDependencyFact<Annotation>[];
}
export interface SpringDiClassFact<
Annotation extends SpringDiAnnotationFact,
SiteKind extends string,
> {
readonly classScopeId: ScopeId;
readonly classAnnotations: readonly Annotation[];
readonly injectionSites: readonly SpringDiInjectionSiteFact<Annotation, SiteKind>[];
}
const INJECTION_ANNOTATIONS = new Set([
'org.springframework.beans.factory.annotation.Autowired',
'jakarta.inject.Inject',
'javax.inject.Inject',
]);
const QUALIFIER_ANNOTATIONS = new Set([
'org.springframework.beans.factory.annotation.Qualifier',
'jakarta.inject.Named',
'javax.inject.Named',
]);
const PRIMARY_ANNOTATIONS = new Set(['org.springframework.context.annotation.Primary']);
const RESOLVABLE_DI_ANNOTATIONS = new Set([
...SPRING_BEAN_STEREOTYPES.keys(),
...INJECTION_ANNOTATIONS,
...QUALIFIER_ANNOTATIONS,
...PRIMARY_ANNOTATIONS,
]);
const CAPTURE_RELEVANT_ANNOTATIONS = new Set([
'Autowired',
'Inject',
'Qualifier',
'Named',
'Primary',
'Component',
'Service',
'Repository',
'Controller',
'RestController',
'Configuration',
]);
const STEREOTYPE_SIMPLE_NAMES = new Set(
[...SPRING_BEAN_STEREOTYPES.keys()].map((name) => springAnnotationSimpleName(name)),
);
export function springAnnotationSimpleName(name: string): string {
const separator = name.lastIndexOf('.');
return separator === -1 ? name : name.slice(separator + 1);
}
export function hasSpringDiRelevantAnnotation(
annotations: readonly SpringDiAnnotationFact[],
): boolean {
return annotations.some((annotation) =>
CAPTURE_RELEVANT_ANNOTATIONS.has(springAnnotationSimpleName(annotation.name)),
);
}
export function hasSpringStereotypeSyntax(annotations: readonly SpringDiAnnotationFact[]): boolean {
return annotations.some((annotation) =>
STEREOTYPE_SIMPLE_NAMES.has(springAnnotationSimpleName(annotation.name)),
);
}
function staticStringArgument(annotationText: string): string | undefined {
const args = annotationText.match(/\((.*)\)$/s)?.[1]?.trim();
if (args === undefined) return undefined;
const value = args.replace(/^value\s*=\s*/, '').trim();
const literal = value.match(/^"((?:\\.|[^"\\])*)"$/s);
if (literal === null) return undefined;
try {
return JSON.parse(`"${literal[1]}"`) as string;
} catch {
return undefined;
}
}
function defaultBeanName(className: string): string {
if (className.length === 0) return className;
if (
className.length > 1 &&
className[0] !== className[0].toLowerCase() &&
className[1] !== className[1].toLowerCase()
) {
return className;
}
return className[0].toLowerCase() + className.slice(1);
}
type ParsedSpringInjectionType = NonNullable<ReturnType<typeof parseSpringInjectionType>>;
export interface SpringDiMetadataAdapter<
Annotation extends SpringDiAnnotationFact,
SiteKind extends string,
> {
getFacts(filePath: string): readonly SpringDiClassFact<Annotation, SiteKind>[];
isPackageVisibilityIncomplete(filePath: string): boolean;
parseInjectionType(rawType: string): ParsedSpringInjectionType | null;
capturedMemberKind: SiteKind;
isInjectionAnnotationApplicable?(
annotation: Annotation,
site: SpringDiInjectionSiteFact<Annotation, SiteKind>,
): boolean;
isQualifierAnnotationApplicable?(
annotation: Annotation,
site: SpringDiInjectionSiteFact<Annotation, SiteKind>,
): boolean;
}
/**
* Build the post-resolution Spring DI metadata hook shared by language adapters.
* Language adapters retain syntax capture, type normalization, use-site rules,
* and side-channel ownership; this function owns framework semantics only.
*/
export function createSpringDiMetadataAttacher<
Annotation extends SpringDiAnnotationFact,
SiteKind extends string,
>(adapter: SpringDiMetadataAdapter<Annotation, SiteKind>) {
return (
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
indexes: ScopeResolutionIndexes,
): void => {
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
for (const parsed of parsedFiles) {
const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath);
for (const fact of adapter.getFacts(parsed.filePath)) {
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
if (classScope === undefined || classScope.kind !== 'Class') continue;
const classDef = classScope.ownedDefs.find((definition) => definition.type === 'Class');
if (classDef === undefined) continue;
const graphId = resolveDefGraphId(parsed.filePath, classDef, nodeLookup);
if (graphId === undefined) continue;
const classNode = graph.getNode(graphId);
if (classNode === undefined || classNode.label !== 'Class') continue;
const resolvedAnnotations = new Map<string, string | undefined>();
const resolveFact = (
annotation: Annotation,
enclosingScope: ScopeId | null = classScope.parent,
): string | undefined => {
const cacheKey = `${enclosingScope ?? '<root>'}\0${annotation.name}`;
if (resolvedAnnotations.has(cacheKey)) return resolvedAnnotations.get(cacheKey);
const resolved = resolveAnnotation(
annotation.name,
parsed,
enclosingScope,
RESOLVABLE_DI_ANNOTATIONS,
incomplete,
);
resolvedAnnotations.set(cacheKey, resolved);
return resolved;
};
const frameworkAnnotations = Array.isArray(classNode.properties.frameworkAnnotations)
? classNode.properties.frameworkAnnotations.filter(
(annotation): annotation is string => typeof annotation === 'string',
)
: [];
if (frameworkAnnotations.length > 0) {
const names = new Set<string>();
let explicitBeanName: string | undefined;
let hasDynamicBeanName = false;
let primary = false;
for (const annotation of fact.classAnnotations) {
const resolved = resolveFact(annotation);
if (resolved === undefined) continue;
if (SPRING_BEAN_STEREOTYPES.has(resolved)) {
const argumentText = annotation.text.match(/\((.*)\)$/s)?.[1]?.trim();
if (argumentText !== undefined && argumentText.length > 0) {
const staticName = staticStringArgument(annotation.text);
if (staticName === undefined) hasDynamicBeanName = true;
else if (staticName.length > 0) explicitBeanName = staticName;
}
}
if (QUALIFIER_ANNOTATIONS.has(resolved)) {
const qualifier = staticStringArgument(annotation.text);
if (qualifier !== undefined) names.add(qualifier);
}
if (PRIMARY_ANNOTATIONS.has(resolved)) primary = true;
}
if (explicitBeanName !== undefined) names.add(explicitBeanName);
else if (!hasDynamicBeanName) names.add(defaultBeanName(classNode.properties.name));
const provider: DiProviderMatch = {
names: [...names],
...(primary ? { preferenceReason: 'selected @Primary' } : {}),
};
classNode.properties[SPRING_DI_PROVIDER_PROPERTY] = provider;
}
const matches: DiInjectionMatch[] = [];
const semanticallyOwnedMemberNames = new Set<string>();
for (const site of fact.injectionSites) {
let injectionAnnotation: Annotation | undefined;
for (const annotation of site.annotations) {
if (adapter.isInjectionAnnotationApplicable?.(annotation, site) === false) continue;
const resolved = resolveFact(annotation, classScope.id);
if (resolved !== undefined && INJECTION_ANNOTATIONS.has(resolved)) {
injectionAnnotation = annotation;
break;
}
}
if (injectionAnnotation === undefined) {
if (!site.implicitConstructor || frameworkAnnotations.length === 0) continue;
} else if (site.kind === adapter.capturedMemberKind) {
// Claim the member only after its injection annotation resolves to
// a recognized FQN. Ambiguous wildcard imports stay unclaimed so
// the legacy collection matcher can fall back. A dynamic qualifier
// later fails closed, but this path still owns the member and must
// suppress that legacy fallback.
semanticallyOwnedMemberNames.add(site.memberName);
}
for (const dependency of site.dependencies) {
const parsedType = adapter.parseInjectionType(dependency.rawType);
if (parsedType === null) continue;
let qualifierAnnotation: Annotation | undefined;
for (const annotation of dependency.annotations) {
if (adapter.isQualifierAnnotationApplicable?.(annotation, site) === false) continue;
const resolved = resolveFact(annotation, classScope.id);
if (resolved !== undefined && QUALIFIER_ANNOTATIONS.has(resolved)) {
qualifierAnnotation = annotation;
break;
}
}
const qualifier =
qualifierAnnotation === undefined
? undefined
: staticStringArgument(qualifierAnnotation.text);
// A present-but-dynamic qualifier is not the same as no qualifier.
// Without its value we cannot choose a provider honestly, so fail
// closed instead of emitting the unqualified candidate set.
if (qualifierAnnotation !== undefined && qualifier === undefined) continue;
const trigger =
injectionAnnotation === undefined
? 'constructor'
: `@${springAnnotationSimpleName(injectionAnnotation.name)} ${site.kind}`;
const location =
site.kind === adapter.capturedMemberKind
? site.memberName
: `${site.memberName} parameter ${dependency.name}`;
matches.push({
targetTypeName: parsedType.targetTypeName,
cardinality: parsedType.cardinality,
...(qualifier === undefined
? {}
: {
namedSelection: {
name: qualifier,
reason: `qualifier "${qualifier}"`,
},
}),
reason: `Spring DI: ${trigger} ${location}: ${parsedType.displayType}`,
});
}
}
if (matches.length > 0) {
classNode.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = matches;
}
for (const memberName of semanticallyOwnedMemberNames) {
for (const { def } of classScope.bindings.get(memberName) ?? []) {
if (def.ownerId !== classDef.nodeId) continue;
const propertyId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (propertyId === undefined) continue;
const property = graph.getNode(propertyId);
if (property?.label === 'Property') {
property.properties[SPRING_DI_CAPTURED_FIELD_PROPERTY] = true;
}
}
}
}
}
};
}
@@ -615,7 +615,11 @@ export function populateCsharpNamespaceSiblings(
}
if (seen.has(memberDef.nodeId)) continue;
seen.add(memberDef.nodeId);
bucketArr.push({ def: memberDef, origin: 'import' });
bucketArr.push({
def: memberDef,
origin: 'import',
visibility: 'static-member-import',
});
}
}
}
@@ -93,6 +93,7 @@ const csharpScopeResolver: ScopeResolver = {
// `(caller, target)` — multiple `g.Greet(...)` sites from Main
// yield ONE edge, not one per site.
collapseMemberCallsByCallerTarget: true,
freeCallsRequireInstanceOwnership: true,
// C# hoists method return-type bindings to the enclosing Module
// scope so `propagateImportedReturnTypes` can mirror them across
@@ -0,0 +1,19 @@
import type { AnalysisFeatureDescriptor } from '../../../analysis-features.js';
function isSpringApplicationConfig(filePath: string): boolean {
const base = filePath.replaceAll('\\', '/').split('/').pop() ?? '';
return /^application(?:-[^.]+)?\.(?:properties|ya?ml)$/i.test(base);
}
/** Durable completeness contract for Java Spring configuration bindings. */
export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = {
id: 'spring.config-bindings',
version: 1,
// Java sources need consumer extraction even without config files (missing
// placeholders still get unresolved markers). Config-only repositories also
// need a one-time rebuild to backfill language-agnostic Property nodes.
appliesTo: (filePaths) =>
filePaths.some(
(filePath) => filePath.toLowerCase().endsWith('.java') || isSpringApplicationConfig(filePath),
),
};
@@ -9,6 +9,8 @@ import {
type JvmPackageFact,
} from '../jvm/package-facts.js';
import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js';
import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js';
import type { JavaSpringDiClassFact } from './spring-di.js';
export type JavaClassAnnotationFact = ClassAnnotationFact;
@@ -16,13 +18,19 @@ export interface JavaCaptureSideChannel {
readonly kind: 'java';
readonly packageFact: JvmPackageFact;
readonly classAnnotations: readonly JavaClassAnnotationFact[];
readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[];
readonly springDiFacts?: readonly JavaSpringDiClassFact[];
}
const classAnnotations = createClassAnnotationFactStore();
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
/** Clear facts retained by a prior workspace pass in a long-lived process. */
export function clearJavaClassAnnotationFacts(): void {
classAnnotations.clear();
springConfigConsumers.clear();
springDiFacts.clear();
}
/** Store the annotation syntax collected by Java's existing scope-query traversal. */
@@ -33,17 +41,54 @@ export function setJavaClassAnnotationFacts(
classAnnotations.set(filePath, facts);
}
export function setJavaSpringConfigConsumerFacts(
filePath: string,
facts: readonly JavaSpringConfigConsumerFact[],
): void {
if (facts.length === 0) springConfigConsumers.delete(filePath);
else springConfigConsumers.set(filePath, facts);
}
export function getJavaSpringConfigConsumerFacts(
filePath: string,
): readonly JavaSpringConfigConsumerFact[] {
return springConfigConsumers.get(filePath) ?? [];
}
export function setJavaSpringDiFacts(
filePath: string,
facts: readonly JavaSpringDiClassFact[],
): void {
if (facts.length === 0) springDiFacts.delete(filePath);
else springDiFacts.set(filePath, facts);
}
export function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiClassFact[] {
return springDiFacts.get(filePath) ?? [];
}
/** Snapshot worker-local Java annotation facts for ParsedFile serialization. */
export function collectJavaCaptureSideChannel(
filePath: string,
): JavaCaptureSideChannel | undefined {
const facts = classAnnotations.get(filePath);
const configConsumers = springConfigConsumers.get(filePath) ?? [];
const diFacts = springDiFacts.get(filePath) ?? [];
const packageFact = getJavaPackageFact(filePath);
if (facts.length === 0 && packageFact === undefined) return undefined;
if (
facts.length === 0 &&
configConsumers.length === 0 &&
diFacts.length === 0 &&
packageFact === undefined
) {
return undefined;
}
return {
kind: 'java',
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
classAnnotations: facts,
...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}),
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
};
}
@@ -62,10 +107,20 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
!Array.isArray(data.classAnnotations)
) {
setJavaClassAnnotationFacts(parsed.filePath, []);
setJavaSpringConfigConsumerFacts(parsed.filePath, []);
setJavaSpringDiFacts(parsed.filePath, []);
setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
return;
}
setJavaClassAnnotationFacts(parsed.filePath, data.classAnnotations);
setJavaSpringConfigConsumerFacts(
parsed.filePath,
Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [],
);
setJavaSpringDiFacts(
parsed.filePath,
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
);
setJavaPackageFact(
parsed.filePath,
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
@@ -20,9 +20,10 @@ import {
recordClassAnnotationCapture,
} from '../../frameworks/spring/bean-candidates.js';
import {
javaLocalTypeDeclarationContainer,
nodeIfType,
nodeToCapture,
synthesizeJavaAnonymousClassName,
synthesizeJavaTypeIdentity,
syntheticCapture,
} from '../../utils/ast-helpers.js';
import { splitImportDeclaration } from './import-decomposer.js';
@@ -32,15 +33,25 @@ import { getJavaParser, getJavaScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { setJavaClassAnnotationFacts } from './capture-side-channel.js';
import {
setJavaClassAnnotationFacts,
setJavaSpringConfigConsumerFacts,
setJavaSpringDiFacts,
} from './capture-side-channel.js';
import { captureJavaPackageFact } from './package-facts.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js';
import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
/** tree-sitter-java node types that the method extractor accepts. */
const FUNCTION_NODE_TYPES = ['method_declaration', 'constructor_declaration'] as const;
const FUNCTION_NODE_TYPES = [
'method_declaration',
'constructor_declaration',
'compact_constructor_declaration',
] as const;
const JAVA_CALLABLE_CAPTURE_OPTIONS = {
functionNodeTypes: new Set([...FUNCTION_NODE_TYPES, 'lambda_expression']),
@@ -61,6 +72,26 @@ const JAVA_CALLABLE_CAPTURE_OPTIONS = {
normalizeQualifiedName: (raw: string) => raw.replaceAll('::', '.'),
} as const;
/** Visibility of a local type begins at its declaration and ends with its
* immediately enclosing block (JLS 6.3). A Java-only synthetic Block scope
* models that range without changing shared resolver selection semantics. */
function javaLocalTypeVisibilityScope(node: SyntaxNode): CaptureMatch | undefined {
const container = javaLocalTypeDeclarationContainer(node);
if (container === null) return undefined;
return {
'@scope.block': {
name: '@scope.block',
range: {
startLine: node.startPosition.row + 1,
startCol: node.startPosition.column,
endLine: container.endPosition.row + 1,
endCol: container.endPosition.column,
},
text: node.text,
},
};
}
/** Suppress read.member emissions when the field_access is already
* covered by a method_invocation (object of a call) or an
* assignment_expression (write target). */
@@ -95,6 +126,8 @@ export function emitJavaScopeCaptures(
const rawMatches = getJavaScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
const classAnnotations = new Map<ScopeId, Set<string>>();
const springDiFacts: JavaSpringDiClassFact[] = [];
const springDiClassNodeIds = new Set<number>();
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
@@ -114,6 +147,13 @@ export function emitJavaScopeCaptures(
}
if (Object.keys(grouped).length === 0) continue;
const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration');
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
springDiClassNodeIds.add(springDiClassNode.id);
const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath);
if (fact !== null) springDiFacts.push(fact);
}
const annotatedClass = grouped['@class-annotation.class'];
const annotationName = grouped['@class-annotation.name'];
if (annotatedClass !== undefined && annotationName !== undefined) {
@@ -121,6 +161,29 @@ export function emitJavaScopeCaptures(
continue;
}
const typeDeclaration = [
nodeMap['@declaration.class'],
nodeMap['@declaration.enum'],
nodeMap['@declaration.record'],
nodeMap['@declaration.interface'],
].find((node): node is SyntaxNode => node !== undefined);
const localTypeIdentity =
typeDeclaration === undefined ? undefined : synthesizeJavaTypeIdentity(typeDeclaration);
if (
localTypeIdentity?.bindingName !== undefined &&
grouped['@declaration.name'] !== undefined &&
typeDeclaration !== undefined
) {
grouped['@declaration.binding-name'] = grouped['@declaration.name'];
grouped['@declaration.name'] = syntheticCapture(
'@declaration.name',
typeDeclaration,
localTypeIdentity.name,
);
const visibilityScope = javaLocalTypeVisibilityScope(typeDeclaration);
if (visibilityScope !== undefined) out.push(visibilityScope);
}
// Decompose each `import_declaration`. `@import.statement` is captured
// directly on the `import_declaration` node.
if (grouped['@import.statement'] !== undefined) {
@@ -150,6 +213,29 @@ export function emitJavaScopeCaptures(
continue;
}
// Normalize a `new`-expression receiver to its constructed type's simple
// name: `new Local().inner()` binds the WHOLE `object_creation_expression`
// as `@reference.receiver`, so its raw text is `"new Local()"` — a string
// that can never match a scope binding, so the compound-receiver resolver
// silently falls through to name-only fallback resolution and picks the
// wrong same-named method on a collision (#2564). Rewriting the text to
// just `Local` lets Case 2 (class-name / static receiver) in
// receiver-bound-calls.ts resolve it via its normal MRO walk. Mirrors the
// established `normalizePhpReceiver` precedent (php/captures.ts) — a
// language-local capture rewrite, no shared-pipeline change.
if (grouped['@reference.receiver'] !== undefined) {
const receiverNode = nodeIfType(nodeMap['@reference.receiver'], 'object_creation_expression');
const typeNode = receiverNode?.childForFieldName('type');
const simpleName = typeNode ? javaBaseSimpleNameOf(typeNode) : undefined;
if (simpleName !== undefined) {
grouped['@reference.receiver'] = syntheticCapture(
'@reference.receiver',
receiverNode!,
simpleName,
);
}
}
// Filter read.member when it's a child of method_invocation or assignment.
// `@reference.read.member` is captured directly on the `field_access` node.
if (grouped['@reference.read.member'] !== undefined) {
@@ -257,6 +343,11 @@ export function emitJavaScopeCaptures(
}
setJavaClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations));
setJavaSpringConfigConsumerFacts(
filePath,
captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath),
);
setJavaSpringDiFacts(filePath, springDiFacts);
return [
...resolveVarTypeBindings(out),
@@ -269,8 +360,8 @@ export function emitJavaScopeCaptures(
/**
* Synthesize `@declaration.class` matches for anonymous class bodies
* (`new Runnable() { ... }`), named by the same javac-style authority
* (`synthesizeJavaAnonymousClassName` → `Worker$N`) the structure phase
* (`new Runnable() { ... }`), named by the same javac-compatible authority
* (`synthesizeJavaTypeIdentity` → `Worker$N`) the structure phase
* uses — the two layers agree by construction (#2550).
*
* The anchor is the `class_body` node: it shares its range with the
@@ -283,13 +374,13 @@ export function emitJavaScopeCaptures(
function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): CaptureMatch[] {
const out: CaptureMatch[] = [];
for (const oce of rootNode.descendantsOfType('object_creation_expression')) {
const name = synthesizeJavaAnonymousClassName(oce);
if (name === undefined) continue;
const identity = synthesizeJavaTypeIdentity(oce);
if (identity === undefined) continue;
const body = oce.namedChildren.find((c) => c.type === 'class_body');
if (body === undefined) continue;
out.push({
'@declaration.class': nodeToCapture('@declaration.class', body),
'@declaration.name': syntheticCapture('@declaration.name', body, name),
'@declaration.name': syntheticCapture('@declaration.name', body, identity.name),
});
// Inheritance: the anonymous class extends/implements its constructed
@@ -330,7 +421,7 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture
out.push({
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', declNode),
'@type-binding.name': nodeToCapture('@type-binding.name', varName),
'@type-binding.type': syntheticCapture('@type-binding.type', oce, name),
'@type-binding.type': syntheticCapture('@type-binding.type', oce, identity.name),
});
}
}
@@ -341,23 +432,49 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture
// constant's class extends its HOST ENUM (javac semantics), so the
// inherits reference names the enum — giving `mroFor(E$N) ∋ E` and
// keeping bare calls from the body to the enum's own helpers alive
// through the ownership gate's MRO arm. No receiver typeBinding piece:
// constants are not variable initializers; `E.A.hook()` dispatch rides
// the existing enum receiver machinery.
// through the ownership gate's MRO arm.
for (const constant of rootNode.descendantsOfType('enum_constant')) {
const name = synthesizeJavaAnonymousClassName(constant);
if (name === undefined) continue;
const body = constant.childForFieldName?.('body');
if (body === null || body === undefined || body.type !== 'class_body') continue;
out.push({
'@declaration.class': nodeToCapture('@declaration.class', body),
'@declaration.name': syntheticCapture('@declaration.name', body, name),
});
const hostEnum = javaEnclosingEnumNameOf(constant);
if (hostEnum !== undefined) {
const bodyNode = constant.childForFieldName?.('body');
const isBodied = bodyNode !== null && bodyNode !== undefined && bodyNode.type === 'class_body';
const bodiedIdentity = synthesizeJavaTypeIdentity(constant);
if (bodiedIdentity !== undefined && isBodied) {
out.push({
'@reference.inherits': nodeToCapture('@reference.inherits', body),
'@reference.name': syntheticCapture('@reference.name', body, hostEnum),
'@declaration.class': nodeToCapture('@declaration.class', bodyNode),
'@declaration.name': syntheticCapture('@declaration.name', bodyNode, bodiedIdentity.name),
});
if (hostEnum !== undefined) {
out.push({
'@reference.inherits': nodeToCapture('@reference.inherits', bodyNode),
'@reference.name': syntheticCapture('@reference.name', bodyNode, hostEnum),
});
}
}
// Receiver dispatch (#2561): `E.CONST.method()` resolves through the
// generic compound-receiver chain walk, which looks up each dotted
// segment via the owning class scope's `typeBindings` map — the same
// mechanism a field declaration uses (`private User user;` binds
// `user` on the class scope). Binding the constant's own simple name
// there — to its synthesized `E$N` class when bodied (MRO includes E,
// so members inherited from the enum still resolve), or to the host
// enum itself when body-less — makes `E.CONST.method()` resolve with
// no changes to the shared receiver-binding machinery.
//
// A bodied constant binds ONLY to its `E$N` class, never the host enum:
// if name synthesis fails on a malformed/error-recovery tree (`bodiedName`
// undefined despite a real body), emit nothing rather than silently
// misattributing an OVERRIDING constant's receiver to the enum's own
// (non-overridden) method — a wrong edge is worse than no edge. Mirrors
// the `object_creation_expression` branch, which skips on synthesis
// failure. `hostEnum` is used only for genuinely body-less constants.
const constantNameNode = constant.childForFieldName?.('name');
const constantType = isBodied ? bodiedIdentity?.name : hostEnum;
if (constantNameNode !== null && constantNameNode !== undefined && constantType !== undefined) {
out.push({
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', constant),
'@type-binding.name': nodeToCapture('@type-binding.name', constantNameNode),
'@type-binding.type': syntheticCapture('@type-binding.type', constant, constantType),
});
}
}
@@ -55,6 +55,7 @@ const JAVA_SCOPE_QUERY = `
(method_declaration) @scope.function
(constructor_declaration) @scope.function
(compact_constructor_declaration) @scope.function
;; Declarations — types
(class_declaration
@@ -30,6 +30,8 @@ import {
} from './index.js';
import { populateJavaPackageSiblings } from './package-siblings.js';
import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
import { attachJavaSpringConfigBindings } from './spring-config-bindings.js';
import { attachJavaSpringDiMetadata } from './spring-di.js';
import {
applyJavaCaptureSideChannel,
clearJavaClassAnnotationFacts,
@@ -83,7 +85,11 @@ const javaScopeResolver: ScopeResolver = {
populateNamespaceSiblings: populateJavaPackageSiblings,
populateRangeBindings: populateJavaCrossFileReturnTypes,
emitPostResolutionEdges: attachSpringBeanCandidateMetadata,
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => {
attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx);
},
};
export { javaScopeResolver };
@@ -0,0 +1,267 @@
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { makeScopeId, type ParsedFile, type ScopeId } from 'gitnexus-shared';
import {
bindSpringConfigConsumers,
type SpringConfigConsumer,
} from '../../frameworks/spring/config-bindings.js';
import { createSpringAnnotationNameResolver } from '../../frameworks/spring/bean-candidates.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { getJavaParser } from './query.js';
import { getJavaSpringConfigConsumerFacts } from './capture-side-channel.js';
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
const VALUE_ANNOTATION = 'org.springframework.beans.factory.annotation.Value';
const CONFIGURATION_PROPERTIES_ANNOTATION =
'org.springframework.boot.context.properties.ConfigurationProperties';
interface JavaAnnotation {
readonly name: string;
readonly node: SyntaxNode;
}
interface JavaImports {
readonly exact: ReadonlySet<string>;
readonly wildcard: ReadonlySet<string>;
readonly localTypes: ReadonlySet<string>;
}
export interface JavaSpringConfigConsumerFact {
readonly consumer: SpringConfigConsumer;
readonly annotationName: string;
readonly classScopeId: ScopeId;
}
function collectJavaImports(root: SyntaxNode): JavaImports {
const exact = new Set<string>();
const wildcard = new Set<string>();
const localTypes = new Set<string>();
for (const node of root.descendantsOfType('import_declaration')) {
const imported = node.text
.replace(/^\s*import\s+(?:static\s+)?/, '')
.replace(/;\s*$/, '')
.trim();
if (imported.endsWith('.*')) wildcard.add(imported.slice(0, -2));
else exact.add(imported);
}
for (const type of [
'class_declaration',
'interface_declaration',
'enum_declaration',
'record_declaration',
'annotation_type_declaration',
]) {
for (const node of root.descendantsOfType(type)) {
const name = node.childForFieldName('name')?.text;
if (name) localTypes.add(name);
}
}
return { exact, wildcard, localTypes };
}
function annotationsOn(node: SyntaxNode): JavaAnnotation[] {
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
if (modifiers === undefined) return [];
const annotations: JavaAnnotation[] = [];
for (const child of modifiers.namedChildren) {
if (child.type !== 'annotation' && child.type !== 'marker_annotation') continue;
const name = child.childForFieldName('name')?.text ?? child.firstNamedChild?.text;
if (name) annotations.push({ name, node: child });
}
return annotations;
}
function resolvesToAnnotation(
rawName: string,
canonicalName: string,
imports: JavaImports,
): boolean {
if (rawName.includes('.')) return rawName === canonicalName;
if (imports.localTypes.has(rawName)) return false;
if (imports.exact.has(canonicalName)) return true;
const packageName = canonicalName.slice(0, canonicalName.lastIndexOf('.'));
return imports.wildcard.has(packageName);
}
function decodeJavaStringLiteral(literal: string): string {
const delimiterLength = literal.startsWith('"""') && literal.endsWith('"""') ? 3 : 1;
return literal
.slice(delimiterLength, -delimiterLength)
.replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
)
.replace(/\\(["'\\btnfr])/g, (_match, escaped: string) => {
const controls: Record<string, string> = {
b: '\b',
t: '\t',
n: '\n',
f: '\f',
r: '\r',
};
return controls[escaped] ?? escaped;
});
}
function javaStringLiterals(annotation: SyntaxNode): string[] {
return annotation
.descendantsOfType('string_literal')
.map((literal) => decodeJavaStringLiteral(literal.text));
}
/** Extract statically readable Spring placeholder keys from a Java annotation. */
export function parseValuePlaceholderKeys(annotation: SyntaxNode): string[] {
const keys = new Set<string>();
for (const literal of javaStringLiterals(annotation)) {
for (const match of literal.matchAll(/\$\{([^{}]+)\}/g)) {
const key = match[1].split(':', 1)[0].trim();
if (/^[A-Za-z0-9_.-]+$/.test(key)) keys.add(key);
}
}
return [...keys];
}
/** Extract `prefix`/`value` (or the positional value) from the annotation. */
export function parseConfigurationPropertiesPrefix(annotation: SyntaxNode): string | null {
const named = annotation.descendantsOfType('element_value_pair').find((pair) => {
const key = pair.childForFieldName('key')?.text;
return key === 'prefix' || key === 'value';
});
const namedValue = named?.childForFieldName('value');
const argumentsNode = annotation.childForFieldName('arguments');
const literalNode =
(namedValue?.type === 'string_literal'
? namedValue
: namedValue?.descendantsOfType('string_literal')[0]) ??
(named === undefined
? argumentsNode?.namedChildren.find((child) => child.type === 'string_literal')
: undefined);
if (literalNode === undefined) return null;
const prefix = decodeJavaStringLiteral(literalNode.text)
.trim()
.replace(/^\.+|\.+$/g, '');
return /^[A-Za-z0-9_.-]+$/.test(prefix) ? prefix : null;
}
function classScopeId(filePath: string, declaration: SyntaxNode): ScopeId {
return makeScopeId({
filePath,
range: nodeToCapture('@scope.class', declaration).range,
kind: 'Class',
});
}
function enclosingClass(node: SyntaxNode): SyntaxNode | undefined {
let current = node.parent;
while (current !== null) {
if (current.type === 'class_declaration' || current.type === 'record_declaration') {
return current;
}
current = current.parent;
}
return undefined;
}
/** Collect config facts from the Java parser's existing AST (no reparse). */
export function captureJavaSpringConfigConsumerFacts(
root: SyntaxNode,
filePath: string,
): JavaSpringConfigConsumerFact[] {
const imports = collectJavaImports(root);
const facts: JavaSpringConfigConsumerFact[] = [];
for (const field of root.descendantsOfType('field_declaration')) {
const annotations = annotationsOn(field).filter((annotation) =>
resolvesToAnnotation(annotation.name, VALUE_ANNOTATION, imports),
);
if (annotations.length === 0) continue;
const owner = enclosingClass(field);
if (owner === undefined) continue;
for (const declarator of field.namedChildren.filter(
(child) => child.type === 'variable_declarator',
)) {
const fieldName = declarator.childForFieldName('name')?.text;
if (!fieldName) continue;
for (const annotation of annotations) {
const keys = parseValuePlaceholderKeys(annotation.node);
if (keys.length > 0) {
facts.push({
consumer: { kind: 'value', fieldName, line: field.startPosition.row + 1, keys },
annotationName: annotation.name,
classScopeId: classScopeId(filePath, owner),
});
}
}
}
}
for (const type of ['class_declaration', 'record_declaration']) {
for (const declaration of root.descendantsOfType(type)) {
const className = declaration.childForFieldName('name')?.text;
if (!className) continue;
for (const annotation of annotationsOn(declaration)) {
if (!resolvesToAnnotation(annotation.name, CONFIGURATION_PROPERTIES_ANNOTATION, imports)) {
continue;
}
const prefix = parseConfigurationPropertiesPrefix(annotation.node);
if (prefix !== null) {
facts.push({
consumer: {
kind: 'configuration-properties',
className,
line: declaration.startPosition.row + 1,
prefix,
},
annotationName: annotation.name,
classScopeId: classScopeId(filePath, declaration),
});
}
}
}
}
return facts;
}
/** Parse Java consumers for focused unit tests; production reuses the worker AST. */
export function extractJavaSpringConfigConsumers(source: string): SpringConfigConsumer[] {
const tree = parseSourceSafe(getJavaParser(), source);
return captureJavaSpringConfigConsumerFacts(tree.rootNode, '<memory>').map(
(fact) => fact.consumer,
);
}
/** Java ScopeResolver post-resolution hook for Spring configuration consumers. */
export function attachJavaSpringConfigBindings(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
_nodeLookup: GraphNodeLookup,
indexes: ScopeResolutionIndexes,
_ctx: { readonly fileContents: ReadonlyMap<string, string> },
): void {
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
const recognizedAnnotations = new Set([VALUE_ANNOTATION, CONFIGURATION_PROPERTIES_ANNOTATION]);
const batches: Array<{ filePath: string; consumers: SpringConfigConsumer[] }> = [];
for (const parsed of parsedFiles) {
const consumers: SpringConfigConsumer[] = [];
for (const fact of getJavaSpringConfigConsumerFacts(parsed.filePath)) {
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
if (classScope === undefined || classScope.kind !== 'Class') continue;
const expectedAnnotation =
fact.consumer.kind === 'value' ? VALUE_ANNOTATION : CONFIGURATION_PROPERTIES_ANNOTATION;
const enclosingScope = fact.consumer.kind === 'value' ? classScope.id : classScope.parent;
const resolved = resolveAnnotation(
fact.annotationName,
parsed,
enclosingScope,
recognizedAnnotations,
isJavaPackageSiblingVisibilityIncomplete(parsed.filePath),
);
if (resolved === expectedAnnotation) consumers.push(fact.consumer);
}
if (consumers.length > 0) batches.push({ filePath: parsed.filePath, consumers });
}
bindSpringConfigConsumers(graph, batches);
}
@@ -0,0 +1,153 @@
import { makeScopeId } from 'gitnexus-shared';
import {
createSpringDiMetadataAttacher,
hasSpringDiRelevantAnnotation,
hasSpringStereotypeSyntax,
type SpringDiAnnotationFact,
type SpringDiClassFact,
type SpringDiDependencyFact,
type SpringDiInjectionSiteFact,
} from '../../frameworks/spring/di-metadata.js';
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
import { getJavaSpringDiFacts } from './capture-side-channel.js';
export type JavaAnnotationSyntaxFact = SpringDiAnnotationFact;
export type JavaSpringDependencyFact = SpringDiDependencyFact<JavaAnnotationSyntaxFact>;
type JavaSpringInjectionSiteKind = 'field' | 'constructor' | 'method';
export type JavaSpringInjectionSiteFact = SpringDiInjectionSiteFact<
JavaAnnotationSyntaxFact,
JavaSpringInjectionSiteKind
>;
export type JavaSpringDiClassFact = SpringDiClassFact<
JavaAnnotationSyntaxFact,
JavaSpringInjectionSiteKind
>;
function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
const facts: JavaAnnotationSyntaxFact[] = [];
for (const child of node.namedChildren) {
if (child.type !== 'modifiers') continue;
for (const modifier of child.namedChildren) {
if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue;
const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild;
if (nameNode === null) continue;
facts.push({ name: nameNode.text.trim(), text: modifier.text.trim() });
}
}
return facts;
}
function dependenciesOf(callable: SyntaxNode): JavaSpringDependencyFact[] {
const parameters = callable.childForFieldName('parameters');
if (parameters === null) return [];
const dependencies: JavaSpringDependencyFact[] = [];
for (const parameter of parameters.namedChildren) {
if (parameter.type !== 'formal_parameter' && parameter.type !== 'spread_parameter') continue;
const nameNode = parameter.childForFieldName('name');
const typeNode = parameter.childForFieldName('type');
if (nameNode === null || typeNode === null) continue;
dependencies.push({
name: nameNode.text.trim(),
rawType: typeNode.text.trim(),
annotations: annotationFacts(parameter),
});
}
return dependencies;
}
/**
* Capture one class already surfaced by Java's scope query.
*
* `captures.ts` calls this from its existing query-match traversal, so Spring
* DI does not perform a second recursive walk from the AST root.
*/
export function captureJavaSpringDiClassFact(
classNode: SyntaxNode,
filePath: string,
): JavaSpringDiClassFact | null {
const body = classNode.childForFieldName('body');
if (body === null) return null;
const classAnnotations = annotationFacts(classNode);
const injectionSites: JavaSpringInjectionSiteFact[] = [];
const constructors = body.namedChildren.filter(
(child) => child.type === 'constructor_declaration',
);
for (const constructor of constructors) {
const annotations = annotationFacts(constructor);
const implicitConstructor =
constructors.length === 1 &&
hasSpringStereotypeSyntax(classAnnotations) &&
!hasSpringDiRelevantAnnotation(annotations);
if (!implicitConstructor && !hasSpringDiRelevantAnnotation(annotations)) continue;
injectionSites.push({
kind: 'constructor',
memberName: constructor.childForFieldName('name')?.text.trim() ?? '<constructor>',
implicitConstructor,
annotations,
dependencies: dependenciesOf(constructor),
});
}
for (const member of body.namedChildren) {
if (member.type === 'field_declaration') {
const annotations = annotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
const typeNode = member.childForFieldName('type');
if (typeNode === null) continue;
for (const declarator of member.namedChildren) {
if (declarator.type !== 'variable_declarator') continue;
const nameNode = declarator.childForFieldName('name');
if (nameNode === null) continue;
injectionSites.push({
kind: 'field',
memberName: nameNode.text.trim(),
implicitConstructor: false,
annotations,
dependencies: [
{
name: nameNode.text.trim(),
rawType: typeNode.text.trim(),
annotations,
},
],
});
}
} else if (member.type === 'method_declaration') {
const annotations = annotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
injectionSites.push({
kind: 'method',
memberName: member.childForFieldName('name')?.text.trim() ?? '<method>',
implicitConstructor: false,
annotations,
dependencies: dependenciesOf(member),
});
}
}
if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null;
const classCapture = nodeToCapture('@spring-di.class', classNode);
return {
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
classAnnotations,
injectionSites,
};
}
/** Attach resolved, framework-private DI metadata to Class nodes. */
export const attachJavaSpringDiMetadata = createSpringDiMetadataAttacher<
JavaAnnotationSyntaxFact,
JavaSpringInjectionSiteKind
>({
getFacts: getJavaSpringDiFacts,
isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete,
parseInjectionType: parseSpringInjectionType,
capturedMemberKind: 'field',
});
@@ -185,10 +185,10 @@ export const kotlinProvider = defineLanguage({
emitScopeCaptures: emitKotlinScopeCaptures,
// ── #2195 PDG layer: Kotlin CFG visitor (vendored grammar) ──
cfgVisitor: createKotlinCfgVisitor(),
// Worker-side: snapshot companion-scope marks, package visibility, and
// class-annotation facts `emitKotlinScopeCaptures` just populated into plain
// data on `ParsedFile.captureSideChannel`, so the main thread can restore all
// three via `applyCaptureSideChannel` WITHOUT a re-parse (#1983). See
// Worker-side: snapshot companion-scope marks, package visibility, class
// annotations, and Spring DI facts `emitKotlinScopeCaptures` just populated
// into plain data on `ParsedFile.captureSideChannel`, so the main thread can
// restore them via `applyCaptureSideChannel` WITHOUT a re-parse (#1983). See
// `kotlin/capture-side-channel.ts`.
// `assertCloneable` is a runtime identity; it makes a future non-serializable
// value in the side-channel payload a compile error here, at the source, rather
@@ -9,6 +9,8 @@
* from the `@scope.companion` marker capture.
* - Spring Bean class-annotation facts collected during the same scope-query
* traversal, consumed only after imports and package visibility finalize.
* - Spring DI class facts (constructor/property/method injection syntax),
* resolved and attached only after imports finalize.
* - A JVM package fact read from the already-parsed root, so package-sibling
* visibility never re-parses Kotlin source on the main thread.
*
@@ -29,7 +31,8 @@
* The single generic `ParsedFile.captureSideChannel` field is shared with C++,
* which is safe because each file is one language (a `.kt` file uses the kotlin
* provider, a `.cpp` file the cpp provider). The payload is self-describing
* (`{ kind: 'kotlin', companionScopes, packageFact, classAnnotations }`) so
* (`{ kind: 'kotlin', companionScopes, packageFact, classAnnotations,
* springDiFacts }`) so
* `applyKotlinCaptureSideChannel` only restores kotlin state and ignores a
* foreign-shaped snapshot.
*/
@@ -46,8 +49,10 @@ import {
} from '../jvm/package-facts.js';
import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js';
import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js';
import type { KotlinSpringDiClassFact } from './spring-di.js';
const classAnnotations = createClassAnnotationFactStore();
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
/**
* Plain JSON-serializable snapshot of the per-file Kotlin capture-time
@@ -63,10 +68,13 @@ export interface KotlinCaptureSideChannel {
readonly packageFact: JvmPackageFact;
/** Class annotation syntax collected by the existing scope traversal. */
readonly classAnnotations: readonly ClassAnnotationFact[];
/** Constructor, property, and method injection syntax captured per class. */
readonly springDiFacts?: readonly KotlinSpringDiClassFact[];
}
export function clearKotlinClassAnnotationFacts(): void {
classAnnotations.clear();
springDiFacts.clear();
}
export function setKotlinClassAnnotationFacts(
@@ -80,6 +88,18 @@ export function getKotlinClassAnnotationFacts(filePath: string): readonly ClassA
return classAnnotations.get(filePath);
}
export function setKotlinSpringDiFacts(
filePath: string,
facts: readonly KotlinSpringDiClassFact[],
): void {
if (facts.length === 0) springDiFacts.delete(filePath);
else springDiFacts.set(filePath, facts);
}
export function getKotlinSpringDiFacts(filePath: string): readonly KotlinSpringDiClassFact[] {
return springDiFacts.get(filePath) ?? [];
}
/**
* `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
* Returns `undefined` when this file recorded no side-channel state at all, so
@@ -90,8 +110,14 @@ export function collectKotlinCaptureSideChannel(
): KotlinCaptureSideChannel | undefined {
const companionScopes = getCompanionScopesForFile(filePath);
const annotationFacts = classAnnotations.get(filePath);
const diFacts = springDiFacts.get(filePath) ?? [];
const packageFact = getKotlinPackageFact(filePath);
if (companionScopes.length === 0 && annotationFacts.length === 0 && packageFact === undefined) {
if (
companionScopes.length === 0 &&
annotationFacts.length === 0 &&
diFacts.length === 0 &&
packageFact === undefined
) {
return undefined;
}
return {
@@ -99,6 +125,7 @@ export function collectKotlinCaptureSideChannel(
companionScopes,
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
classAnnotations: annotationFacts,
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
};
}
@@ -121,6 +148,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
!Array.isArray(data.classAnnotations)
) {
classAnnotations.set(parsed.filePath, []);
setKotlinSpringDiFacts(parsed.filePath, []);
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
return;
}
@@ -128,6 +156,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
markCompanionScope(parsed.filePath, scopeId);
}
classAnnotations.set(parsed.filePath, data.classAnnotations);
setKotlinSpringDiFacts(
parsed.filePath,
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
);
setKotlinPackageFact(
parsed.filePath,
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
@@ -18,9 +18,10 @@ import { normalizeKotlinType } from './interpret.js';
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
import { markCompanionScope } from './companion-scopes.js';
import { setKotlinClassAnnotationFacts } from './capture-side-channel.js';
import { setKotlinClassAnnotationFacts, setKotlinSpringDiFacts } from './capture-side-channel.js';
import { captureKotlinPackageFact } from './package-facts.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js';
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
@@ -83,6 +84,8 @@ export function emitKotlinScopeCaptures(
const out: CaptureMatch[] = [];
const classAnnotations = new Map<ScopeId, Set<string>>();
const springDiFacts: KotlinSpringDiClassFact[] = [];
const springDiClassNodeIds = new Set<number>();
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
@@ -106,6 +109,13 @@ export function emitKotlinScopeCaptures(
}
if (Object.keys(grouped).length === 0) continue;
const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration');
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
springDiClassNodeIds.add(springDiClassNode.id);
const fact = captureKotlinSpringDiClassFact(springDiClassNode, filePath);
if (fact !== null) springDiFacts.push(fact);
}
const annotatedClass = grouped['@class-annotation.class'];
const annotationName = grouped['@class-annotation.name'];
if (annotatedClass !== undefined && annotationName !== undefined) {
@@ -288,6 +298,7 @@ export function emitKotlinScopeCaptures(
}
setKotlinClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations));
setKotlinSpringDiFacts(filePath, springDiFacts);
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
return out;
}
@@ -22,6 +22,7 @@ import { isKotlinStaticOnly } from './owners.js';
import { populateKotlinPackageSiblings } from './package-siblings.js';
import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
import { clearKotlinPackageFacts } from './package-facts.js';
import { attachKotlinSpringDiMetadata } from './spring-di.js';
/**
* Kotlin scope resolver for RFC #909 Ring 3.
@@ -120,9 +121,13 @@ export const kotlinScopeResolver: ScopeResolver = {
propagatesReturnTypesAcrossImports: true,
collapseMemberCallsByCallerTarget: false,
hoistTypeBindingsToModule: true,
freeCallsRequireInstanceOwnership: true,
postExtractSourceTextPolicy: 'uncached-files',
populateNamespaceSiblings: populateKotlinPackageSiblings,
emitPostResolutionEdges: attachKotlinSpringBeanCandidateMetadata,
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => {
attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
},
};
/**
@@ -0,0 +1,299 @@
import { makeScopeId } from 'gitnexus-shared';
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
import {
createSpringDiMetadataAttacher,
hasSpringDiRelevantAnnotation,
hasSpringStereotypeSyntax,
type SpringDiAnnotationFact,
type SpringDiClassFact,
type SpringDiDependencyFact,
type SpringDiInjectionSiteFact,
} from '../../frameworks/spring/di-metadata.js';
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { getKotlinSpringDiFacts } from './capture-side-channel.js';
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact {
readonly useSiteTarget?: string;
}
export type KotlinSpringDependencyFact = SpringDiDependencyFact<KotlinAnnotationSyntaxFact>;
type KotlinSpringInjectionSiteKind = 'property' | 'constructor' | 'method';
export type KotlinSpringInjectionSiteFact = SpringDiInjectionSiteFact<
KotlinAnnotationSyntaxFact,
KotlinSpringInjectionSiteKind
>;
export type KotlinSpringDiClassFact = SpringDiClassFact<
KotlinAnnotationSyntaxFact,
KotlinSpringInjectionSiteKind
>;
const KOTLIN_TYPE_NODES = new Set(['user_type', 'nullable_type', 'function_type']);
function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | undefined {
const stack = [...node.namedChildren].reverse();
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) continue;
if (current.type === type) return current;
for (let index = current.namedChildren.length - 1; index >= 0; index--) {
const child = current.namedChildren[index];
if (child !== undefined) stack.push(child);
}
}
return undefined;
}
function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | null {
const nameNode = firstDescendantOfType(annotation, 'user_type');
if (nameNode === undefined) return null;
const useSiteTarget = annotation.namedChildren
.find((child) => child.type === 'use_site_target')
?.text.replace(/:\s*$/, '')
.trim();
return {
name: nameNode.text.trim(),
text: annotation.text.trim(),
...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
};
}
function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
const facts: KotlinAnnotationSyntaxFact[] = [];
for (const child of node.namedChildren) {
if (child.type !== 'annotation') continue;
const fact = annotationFact(child);
if (fact !== null) facts.push(fact);
}
return facts;
}
function annotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
const facts: KotlinAnnotationSyntaxFact[] = [];
for (const child of node.namedChildren) {
if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue;
facts.push(...annotationsFromModifierContainer(child));
}
return facts;
}
function directTypeNode(node: SyntaxNode): SyntaxNode | undefined {
return node.namedChildren.find((child) => KOTLIN_TYPE_NODES.has(child.type));
}
function parameterDependency(
parameter: SyntaxNode,
precedingAnnotations: readonly KotlinAnnotationSyntaxFact[] = [],
): KotlinSpringDependencyFact | null {
const nameNode = parameter.namedChildren.find((child) => child.type === 'simple_identifier');
const typeNode = directTypeNode(parameter);
if (nameNode === undefined || typeNode === undefined) return null;
return {
name: nameNode.text.trim(),
rawType: typeNode.text.trim(),
annotations: [...precedingAnnotations, ...annotationFacts(parameter)],
};
}
function functionDependencies(callable: SyntaxNode): KotlinSpringDependencyFact[] {
const parameters = callable.namedChildren.find(
(child) => child.type === 'function_value_parameters',
);
if (parameters === undefined) return [];
const dependencies: KotlinSpringDependencyFact[] = [];
let pendingAnnotations: KotlinAnnotationSyntaxFact[] = [];
for (const child of parameters.namedChildren) {
if (child.type === 'parameter_modifiers') {
pendingAnnotations = annotationsFromModifierContainer(child);
continue;
}
if (child.type !== 'parameter') continue;
const dependency = parameterDependency(child, pendingAnnotations);
pendingAnnotations = [];
if (dependency !== null) dependencies.push(dependency);
}
return dependencies;
}
function primaryConstructorDependencies(constructor: SyntaxNode): KotlinSpringDependencyFact[] {
const dependencies: KotlinSpringDependencyFact[] = [];
for (const parameter of constructor.namedChildren) {
if (parameter.type !== 'class_parameter') continue;
const dependency = parameterDependency(parameter);
if (dependency !== null) dependencies.push(dependency);
}
return dependencies;
}
function propertyDependency(property: SyntaxNode): KotlinSpringDependencyFact | null {
const variable = property.namedChildren.find((child) => child.type === 'variable_declaration');
if (variable === undefined) return null;
const nameNode = variable.namedChildren.find((child) => child.type === 'simple_identifier');
const typeNode = directTypeNode(variable);
if (nameNode === undefined || typeNode === undefined) return null;
const annotations = annotationFacts(property);
return {
name: nameNode.text.trim(),
rawType: typeNode.text.trim(),
annotations,
};
}
function isKotlinBeanCandidateClass(classNode: SyntaxNode): boolean {
if (classNode.children.some((child) => child.type === 'interface' || child.type === 'enum')) {
return false;
}
const modifiers = classNode.namedChildren.find((child) => child.type === 'modifiers');
return !modifiers?.namedChildren.some(
(child) => child.type === 'class_modifier' && child.text.trim() === 'annotation',
);
}
/**
* Capture one class already surfaced by Kotlin's scope query. Kotlin-specific
* syntax is normalized here while import/FQN semantics remain deferred until
* post-resolution.
*/
export function captureKotlinSpringDiClassFact(
classNode: SyntaxNode,
filePath: string,
): KotlinSpringDiClassFact | null {
if (!isKotlinBeanCandidateClass(classNode)) return null;
const classAnnotations = annotationFacts(classNode);
const injectionSites: KotlinSpringInjectionSiteFact[] = [];
const body = classNode.namedChildren.find((child) => child.type === 'class_body');
const primaryConstructor = classNode.namedChildren.find(
(child) => child.type === 'primary_constructor',
);
const secondaryConstructors =
body?.namedChildren.filter((child) => child.type === 'secondary_constructor') ?? [];
const constructorCount =
(primaryConstructor === undefined ? 0 : 1) + secondaryConstructors.length;
if (primaryConstructor !== undefined) {
const annotations = annotationFacts(primaryConstructor);
const implicitConstructor =
constructorCount === 1 &&
hasSpringStereotypeSyntax(classAnnotations) &&
!hasSpringDiRelevantAnnotation(annotations);
if (implicitConstructor || hasSpringDiRelevantAnnotation(annotations)) {
injectionSites.push({
kind: 'constructor',
memberName: '<primary-constructor>',
implicitConstructor,
annotations,
dependencies: primaryConstructorDependencies(primaryConstructor),
});
}
}
for (const constructor of secondaryConstructors) {
const annotations = annotationFacts(constructor);
const implicitConstructor =
constructorCount === 1 &&
hasSpringStereotypeSyntax(classAnnotations) &&
!hasSpringDiRelevantAnnotation(annotations);
if (!implicitConstructor && !hasSpringDiRelevantAnnotation(annotations)) continue;
injectionSites.push({
kind: 'constructor',
memberName: '<secondary-constructor>',
implicitConstructor,
annotations,
dependencies: functionDependencies(constructor),
});
}
if (body !== undefined) {
for (const member of body.namedChildren) {
if (member.type === 'property_declaration') {
const annotations = annotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
const dependency = propertyDependency(member);
if (dependency === null) continue;
injectionSites.push({
kind: 'property',
memberName: dependency.name,
implicitConstructor: false,
annotations,
dependencies: [dependency],
});
} else if (member.type === 'function_declaration') {
const annotations = annotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
const name =
member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ??
'<method>';
injectionSites.push({
kind: 'method',
memberName: name,
implicitConstructor: false,
annotations,
dependencies: functionDependencies(member),
});
}
}
}
if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null;
const classCapture = nodeToCapture('@spring-di.class', classNode);
return {
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
classAnnotations,
injectionSites,
};
}
function isApplicableInjectionAnnotation(
annotation: KotlinAnnotationSyntaxFact,
site: KotlinSpringInjectionSiteFact,
): boolean {
if (annotation.useSiteTarget === undefined) return true;
if (site.kind === 'constructor') return annotation.useSiteTarget === 'constructor';
if (site.kind === 'property') {
return annotation.useSiteTarget === 'field' || annotation.useSiteTarget === 'set';
}
return false;
}
function isApplicableQualifierAnnotation(
annotation: KotlinAnnotationSyntaxFact,
site: KotlinSpringInjectionSiteFact,
): boolean {
if (annotation.useSiteTarget === undefined) return true;
if (site.kind === 'property') {
return (
annotation.useSiteTarget === 'field' ||
annotation.useSiteTarget === 'param' ||
annotation.useSiteTarget === 'setparam'
);
}
return annotation.useSiteTarget === 'param';
}
function parseKotlinSpringInjectionType(rawType: string) {
// Kotlin nullable suffixes, type projections, and mutable collection aliases
// do not change the JVM bean type selected by Spring. Normalize only those
// surface forms; stars, function types, arrays, and nested generic elements
// still fail closed in the shared parser.
const normalized = rawType
.replace(/\bMutable(List|Set|Collection|Map)(?=\s*<)/g, '$1')
.replace(/([<,])\s*(?:out|in)\s+/g, '$1')
.replace(/\?(?=\s*(?:[>,]|$))/g, '');
return parseSpringInjectionType(normalized);
}
/** Attach resolved, framework-private DI metadata to Kotlin Class nodes. */
export const attachKotlinSpringDiMetadata = createSpringDiMetadataAttacher<
KotlinAnnotationSyntaxFact,
KotlinSpringInjectionSiteKind
>({
getFacts: getKotlinSpringDiFacts,
isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete,
parseInjectionType: parseKotlinSpringInjectionType,
capturedMemberKind: 'property',
isInjectionAnnotationApplicable: isApplicableInjectionAnnotation,
isQualifierAnnotationApplicable: isApplicableQualifierAnnotation,
});
@@ -8,10 +8,9 @@
* 1. **Per-name import statements** — `import a, b` and
* `from m import x, y` decompose to one match per imported name
* (see `import-decomposer.ts`).
* 2. **Receiver type bindings** — each `function_definition` inside a
* class body emits a `@type-binding.self` (or `@type-binding.cls`
* for `@classmethod`) capture so Pass-4 attaches the implicit
* receiver (see `receiver-binding.ts`).
* 2. **Receiver type bindings** — methods emit an implicit `self` / `cls`
* binding, and `__init__` assignments from annotated parameters emit
* class-scoped instance-field bindings (see `receiver-binding.ts`).
*
* Pure given the input source text. No I/O, no globals consulted.
*/
@@ -25,7 +24,10 @@ import {
} from '../../utils/ast-helpers.js';
import { splitImportStatement } from './import-decomposer.js';
import { getPythonParser, getPythonScopeQuery } from './query.js';
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
import {
synthesizeConstructorFieldTypeBindings,
synthesizeReceiverTypeBinding,
} from './receiver-binding.js';
import { synthesizeDependsReferences } from './depends-references.js';
import { computePythonArityMetadata } from './arity-metadata.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
@@ -133,6 +135,7 @@ export function emitPythonScopeCaptures(
if (fnNode !== null) {
const synth = synthesizeReceiverTypeBinding(fnNode);
if (synth !== null) out.push(synth);
out.push(...synthesizeConstructorFieldTypeBindings(fnNode));
for (const depRef of synthesizeDependsReferences(fnNode)) out.push(depRef);
}
continue;
@@ -119,7 +119,10 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi
// `cls` is a self-like receiver; share the source label so downstream
// `Registry.lookup` Step 2 treats them identically.
else if (captures['@type-binding.cls'] !== undefined) source = 'self';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.instance-field'] !== undefined) {
source =
captures['@type-binding.parameter'] !== undefined ? 'parameter-annotation' : 'annotation';
} else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
@@ -1,6 +1,6 @@
/**
* Synthesize `@type-binding.self` / `@type-binding.cls` captures for
* methods.
* Synthesize implicit receiver and constructor-assigned field type bindings
* for methods.
*
* Tree-sitter can't easily express "the first parameter of a function
* defined directly inside a class body" via a single static query.
@@ -113,3 +113,114 @@ export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch
'@type-binding.type': syntheticCapture('@type-binding.type', first, className),
};
}
/**
* Synthesize class-scope field bindings for the common Python constructor
* injection pattern:
*
* def __init__(self, service: Service):
* self.service = service
*
* An explicit field annotation (`self.service: Service = ...`) is also
* accepted and takes precedence over a parameter annotation. Deliberately do
* not infer from arbitrary unannotated RHS expressions: the receiver resolver
* needs a declared type, not a name-only guess.
*/
export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): CaptureMatch[] {
if (fnNode.childForFieldName('name')?.text !== '__init__') return [];
if (findEnclosingClassDefinition(fnNode) === null) return [];
if (hasDecorator(fnNode, 'staticmethod') || hasDecorator(fnNode, 'classmethod')) return [];
const receiver = synthesizeReceiverTypeBinding(fnNode);
const receiverName = receiver?.['@type-binding.self']?.text;
if (receiverName === undefined) return [];
const parameters = fnNode.childForFieldName('parameters');
const body = fnNode.childForFieldName('body');
if (parameters === null || body === null) return [];
const parameterTypes = new Map<string, string>();
for (let i = 0; i < parameters.namedChildCount; i++) {
const parameter = parameters.namedChild(i);
if (parameter === null) continue;
const name = firstParameterName(parameter);
const annotation = parameter.childForFieldName('type');
if (name !== null && annotation !== null) parameterTypes.set(name, annotation.text);
}
type Candidate = { readonly match: CaptureMatch; readonly explicit: boolean };
const candidates = new Map<string, Candidate>();
const stack: SyntaxNode[] = [body];
while (stack.length > 0) {
const node = stack.pop()!;
if (
node !== body &&
(node.type === 'function_definition' ||
node.type === 'lambda' ||
node.type === 'class_definition' ||
node.type === 'if_statement' ||
node.type === 'for_statement' ||
node.type === 'while_statement' ||
node.type === 'try_statement' ||
node.type === 'match_statement')
) {
continue;
}
if (node.type === 'assignment') {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (left?.type === 'attribute') {
const object = left.childForFieldName('object');
const field = left.childForFieldName('attribute');
if (object?.type === 'identifier' && object.text === receiverName && field !== null) {
const explicitType = node.childForFieldName('type');
const parameterType =
right?.type === 'identifier' ? parameterTypes.get(right.text) : undefined;
const typeName = explicitType?.text ?? parameterType;
if (typeName !== undefined) {
const explicit = explicitType !== null;
const existing = candidates.get(field.text);
if (existing === undefined || explicit || !existing.explicit) {
candidates.set(field.text, {
explicit,
match: {
'@type-binding.name': syntheticCapture('@type-binding.name', field, field.text),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
explicitType ?? right ?? field,
typeName,
),
...(explicit
? {}
: {
'@type-binding.parameter': syntheticCapture(
'@type-binding.parameter',
right ?? field,
'1',
),
}),
'@type-binding.instance-field': syntheticCapture(
'@type-binding.instance-field',
node,
'1',
),
},
});
}
}
}
}
}
// Push in reverse so the LIFO walk visits source order. That keeps Map
// insertion order (and therefore emitted capture order) deterministic.
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child !== null) stack.push(child);
}
}
return [...candidates.values()].map(({ match }) => match);
}
@@ -36,15 +36,23 @@ export function pythonFunctionDefinitionLabel(
// ─── bindingScopeFor ──────────────────────────────────────────────────────
/** Python has no block scope, so the central extractor's "innermost
* enclosing scope" default is already correct: `for x in …` creates
* `x` in the enclosing function/module scope (because we never emit a
* `@scope.block` for the for-loop body), comprehension variables stay
* in their expression context, etc. Returns `null` to delegate. */
* enclosing scope" default is already correct for ordinary bindings.
* Constructor-injected instance fields are the exception: their marker is
* anchored inside `__init__`, but compound receiver resolution needs the
* field type on the enclosing Class scope. */
export function pythonBindingScopeFor(
_decl: CaptureMatch,
_innermost: Scope,
_tree: ScopeTree,
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.instance-field'] !== undefined) {
let current: Scope | undefined = innermost;
while (current !== undefined) {
if (current.kind === 'Class') return current.id;
if (current.parent === null) break;
current = tree.getScope(current.parent);
}
}
return null;
}
@@ -2,8 +2,23 @@ import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'git
const REF_PREFIX_RE = /^&\s*(mut\s+)?/;
const PTR_PREFIX_RE = /^\*\s*(const|mut)?\s*/;
const DYN_PREFIX_RE = /^dyn\s+/;
const ENUM_VARIANT_NAMES = new Set(['Some', 'None', 'Ok', 'Err']);
// `dyn Trait`, `&dyn Trait`, `Box<dyn Trait>` all name a trait object whose
// receiver-dispatch target is the trait itself (#2604) — strip the `dyn`
// keyword and any auto-trait/lifetime bound list (`dyn Trait + Send`) down to
// the principal trait name. Reference/pointer sigils are stripped by the
// caller first; wrapper unwrapping (Box<T> etc.) runs before this so the
// unwrapped inner text still gets the same treatment.
function stripDynBound(t: string): string {
if (!DYN_PREFIX_RE.test(t)) return t;
t = t.replace(DYN_PREFIX_RE, '');
const plus = t.indexOf('+');
if (plus !== -1) t = t.slice(0, plus);
return t.trim();
}
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretRustImport(captures: CaptureMatch): ParsedImport | null {
@@ -98,6 +113,7 @@ export function normalizeRustTypeName(text: string): string {
const inner = extractFirstGenericArg(t);
if (inner !== null) t = inner;
}
t = stripDynBound(t);
const bracket = t.indexOf('<');
if (bracket !== -1) t = t.slice(0, bracket);
// Take last segment of qualified paths (crate::foo::Bar → Bar)
@@ -158,6 +174,7 @@ function normalizeRustReturnType(text: string): string {
}
}
}
t = stripDynBound(t);
const bracket = t.indexOf('<');
if (bracket !== -1) t = t.slice(0, bracket);
const lastColon = t.lastIndexOf('::');
@@ -10,6 +10,7 @@ const RUST_SCOPE_QUERY = `
(enum_item) @scope.class
(union_item) @scope.class
(function_item) @scope.function
(function_signature_item) @scope.function
(closure_expression) @scope.function
(block) @scope.block
(if_expression) @scope.block
@@ -55,6 +56,14 @@ const RUST_SCOPE_QUERY = `
(function_item
name: (identifier) @declaration.name) @declaration.function
;; Declarations — trait method signature (required method, no body,
;; e.g. fn foo(self) -> T; inside a trait body). Without this, an abstract
;; trait method is invisible to scope resolution — never owned by its
;; trait's Class scope, so a dyn Trait receiver can never dispatch to
;; it (#2604).
(function_signature_item
name: (identifier) @declaration.name) @declaration.function
;; Declarations — struct fields
(field_declaration
name: (field_identifier) @declaration.name
@@ -5,6 +5,7 @@ import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { logger } from '../../../logger.js';
import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js';
/**
* Populate type bindings for patterns and iterators that the tree-sitter
@@ -16,9 +17,54 @@ import { logger } from '../../../logger.js';
* Runs in Phase 2 (after propagateImportedReturnTypes) so all cross-file
* type bindings are available for lookup.
*/
type RustTree = ReturnType<ReturnType<typeof getRustParser>['parse']>;
/**
* Hold parsed trees for reuse across both prepass loops only when the whole
* Rust source fits this budget. Trees are much larger than their source, so a
* modest source cap keeps peak held-tree memory bounded; larger repos fall
* back to re-parsing per loop (unchanged RSS).
*/
const TREE_REUSE_SOURCE_BUDGET_BYTES = 16 * 1024 * 1024;
/**
* Parse `filePath`'s source once, honoring the caller's `treeCache` and, when
* provided, an in-function `store` so the two prepass loops share a single
* parse instead of re-parsing every file. Returns null when the source is
* missing or parsing times out.
*/
function getOrParseTree(
parser: ReturnType<typeof getRustParser>,
filePath: string,
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
},
store: Map<string, RustTree> | undefined,
): RustTree | null {
const cached = (ctx.treeCache?.get(filePath) ?? store?.get(filePath)) as RustTree | undefined;
if (cached !== undefined) return cached;
const sourceText = ctx.fileContents.get(filePath);
if (sourceText === undefined) return null;
let tree: RustTree;
try {
tree = parseSourceSafe(parser, sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
} catch (err) {
if (err instanceof ParseTimeoutError) {
logger.warn({ file: filePath }, 'rust range-binding: parse timed out, skipping file');
return null;
}
throw err;
}
store?.set(filePath, tree);
return tree;
}
export function populateRustRangeBindings(
parsedFiles: readonly ParsedFile[],
_indexes: ScopeResolutionIndexes,
indexes: ScopeResolutionIndexes,
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
@@ -26,45 +72,45 @@ export function populateRustRangeBindings(
): void {
const parser = getRustParser();
const allReturnTypes = new Map<string, string>();
const ambiguousReturnTypes = new Set<string>();
const allFieldTypes = new Map<string, Map<string, string>>();
const ambiguousFieldTypes = new Set<string>();
// Per-defining-file, un-collapsed, FULL-generic return/field types. When a
// bare name is ambiguous (#2514) but the call site's `use` import pins a
// single definition, we resolve that definition's file here and read its
// untruncated type so a generic `Vec<Repo>` element type survives (#2514
// follow-up: import-disambiguated duplicates resolve like the compiler).
const returnTypeByFile = new Map<string, Map<string, string>>();
const fieldTypeByFile = new Map<string, Map<string, Map<string, string>>>();
// Parse each file once and reuse across both loops when the workspace fits
// the byte budget; otherwise re-parse per loop to bound RSS (see helper).
let totalSourceBytes = 0;
for (const parsed of parsedFiles) {
totalSourceBytes += ctx.fileContents.get(parsed.filePath)?.length ?? 0;
}
const treeStore: Map<string, RustTree> | undefined =
totalSourceBytes <= TREE_REUSE_SOURCE_BUDGET_BYTES ? new Map() : undefined;
for (const parsed of parsedFiles) {
const sourceText = ctx.fileContents.get(parsed.filePath);
if (sourceText === undefined) continue;
const cachedTree = ctx.treeCache?.get(parsed.filePath) as
| ReturnType<typeof parser.parse>
| undefined;
let tree: ReturnType<typeof parser.parse>;
if (cachedTree !== undefined) {
tree = cachedTree;
} else {
try {
tree = parseSourceSafe(parser, sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
} catch (err) {
if (err instanceof ParseTimeoutError) {
logger.warn(
{ file: parsed.filePath },
'rust range-binding: parse timed out, skipping file',
);
continue;
}
throw err;
}
}
const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
if (tree === null) continue;
for (const fn of tree.rootNode.descendantsOfType('function_item')) {
const nameNode = fn.childForFieldName('name');
const retType = fn.childForFieldName('return_type');
if (nameNode !== null && retType !== null) {
const name = nameNode.text;
// Ambiguity is a latch, not a toggle: once a name has two or more
// workspace definitions it stays ambiguous for the rest of the
// prepass, regardless of duplicate count or file order (#2514).
if (allReturnTypes.has(name)) {
allReturnTypes.delete(name);
} else {
ambiguousReturnTypes.add(name);
} else if (!ambiguousReturnTypes.has(name)) {
allReturnTypes.set(name, retType.text);
}
// Full-generic record per defining file for import-disambiguated lookup.
recordByFile(returnTypeByFile, parsed.filePath, name, retType.text);
}
}
@@ -82,11 +128,16 @@ export function populateRustRangeBindings(
}
if (fields.size > 0) {
const name = nameNode.text;
// Same ambiguity latch as return types (#2514): a third same-named
// struct must not restore a resolvable global field map.
if (allFieldTypes.has(name)) {
allFieldTypes.delete(name);
} else {
ambiguousFieldTypes.add(name);
} else if (!ambiguousFieldTypes.has(name)) {
allFieldTypes.set(name, fields);
}
// Full-generic record per defining file for import-disambiguated lookup.
recordByFile(fieldTypeByFile, parsed.filePath, name, fields);
}
}
@@ -99,39 +150,32 @@ export function populateRustRangeBindings(
}
for (const parsed of parsedFiles) {
const sourceText = ctx.fileContents.get(parsed.filePath);
if (sourceText === undefined) continue;
const cachedTree = ctx.treeCache?.get(parsed.filePath) as
| ReturnType<typeof parser.parse>
| undefined;
let tree: ReturnType<typeof parser.parse>;
if (cachedTree !== undefined) {
tree = cachedTree;
} else {
try {
tree = parseSourceSafe(parser, sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
} catch (err) {
if (err instanceof ParseTimeoutError) {
logger.warn(
{ file: parsed.filePath },
'rust range-binding: parse timed out, skipping file',
);
continue;
}
throw err;
}
}
const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
if (tree === null) continue;
const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes);
processForLoops(
tree.rootNode,
parsed,
scopeMap,
moduleScope,
allReturnTypes,
indexes,
returnTypeByFile,
);
processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope);
processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes);
processStructDestructuring(
tree.rootNode,
parsed,
scopeMap,
moduleScope,
allFieldTypes,
indexes,
fieldTypeByFile,
);
processPendingAssignments(
tree.rootNode,
parsed,
@@ -196,12 +240,88 @@ function normalizeFieldType(text: string): string {
return t.trim();
}
/** Get-or-create the inner map for `file` and record `name -> value`. */
function recordByFile<V>(
byFile: Map<string, Map<string, V>>,
file: string,
name: string,
value: V,
): void {
let inner = byFile.get(file);
if (inner === undefined) {
inner = new Map<string, V>();
byFile.set(file, inner);
}
inner.set(name, value);
}
/** Final segment of a dot-joined qualified name (`a.make` -> `make`), or the
* bare name when the def carries no qualifier. */
function simpleName(qualifiedName: string | undefined, bareName: string): string {
if (qualifiedName === undefined) return bareName;
const dot = qualifiedName.lastIndexOf('.');
return dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1);
}
/** Distinct `(file, name)` definitions, in first-seen order. */
function uniqueDefs(
defs: readonly { file: string; name: string }[],
): { file: string; name: string }[] {
const seen = new Set<string>();
const out: { file: string; name: string }[] = [];
for (const d of defs) {
const key = `${d.file} ${d.name}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(d);
}
return out;
}
/**
* Resolve `name` at `moduleScope` to the value recorded in `byFile` for the one
* definition visible here, or null when zero or several are visible (which
* keeps the #2514 ambiguity latch). Mirrors Rust name resolution: explicit
* `use`/re-export imports and local defs shadow `use x::*` globs, so a glob is
* consulted only when no explicit binding names `name`, and even then only when
* exactly one glob-target file actually defines it.
*/
function resolveImportedDef<V>(
name: string,
moduleScope: Scope,
indexes: ScopeResolutionIndexes,
byFile: ReadonlyMap<string, ReadonlyMap<string, V>>,
): V | null {
const explicit = uniqueDefs(
lookupBindingsAt(moduleScope.id, name, indexes)
.filter((r) => r.origin === 'import' || r.origin === 'reexport' || r.origin === 'local')
.map((r) => ({ file: r.def.filePath, name: simpleName(r.def.qualifiedName, name) })),
);
const defs =
explicit.length > 0
? explicit
: uniqueDefs(
(indexes.imports.get(moduleScope.id) ?? [])
.filter(
(e) =>
e.kind === 'wildcard-expanded' &&
e.targetFile !== null &&
byFile.get(e.targetFile)?.has(name) === true,
)
.map((e) => ({ file: e.targetFile as string, name })),
);
if (defs.length !== 1) return null;
return byFile.get(defs[0].file)?.get(defs[0].name) ?? null;
}
function processForLoops(
root: SyntaxNode,
parsed: ParsedFile,
scopeMap: ReadonlyMap<string, Scope>,
moduleScope: Scope,
allReturnTypes: ReadonlyMap<string, string>,
indexes: ScopeResolutionIndexes,
returnTypeByFile: ReadonlyMap<string, Map<string, string>>,
): void {
for (const forNode of root.descendantsOfType('for_expression')) {
const patternNode = forNode.childForFieldName('pattern');
@@ -217,6 +337,8 @@ function processForLoops(
scopeMap,
moduleScope,
allReturnTypes,
indexes,
returnTypeByFile,
);
if (elementType === null) continue;
@@ -331,7 +453,9 @@ function processStructDestructuring(
parsed: ParsedFile,
scopeMap: ReadonlyMap<string, Scope>,
moduleScope: Scope,
allFieldTypes?: ReadonlyMap<string, Map<string, string>>,
allFieldTypes: ReadonlyMap<string, Map<string, string>>,
indexes: ScopeResolutionIndexes,
fieldTypeByFile: ReadonlyMap<string, ReadonlyMap<string, Map<string, string>>>,
): void {
for (const letNode of root.descendantsOfType('let_declaration')) {
const patternNode = letNode.childForFieldName('pattern');
@@ -356,7 +480,13 @@ function processStructDestructuring(
let fieldType = lookupFieldType(typeName, fieldName, parsed, scopeMap, moduleScope);
if (fieldType === null) {
fieldType = allFieldTypes?.get(typeName)?.get(fieldName) ?? null;
fieldType = allFieldTypes.get(typeName)?.get(fieldName) ?? null;
}
if (fieldType === null) {
// Import-disambiguated duplicate struct (#2514 follow-up): the global
// field map is ambiguous, but a `use` import pins one definition.
const fields = resolveImportedDef(typeName, moduleScope, indexes, fieldTypeByFile);
fieldType = fields?.get(fieldName) ?? null;
}
if (fieldType !== null) {
injectTypeBinding(targetScope, fieldName, fieldType);
@@ -481,7 +611,9 @@ function resolveIterableElementType(
parsed: ParsedFile,
scopeMap: ReadonlyMap<string, Scope>,
moduleScope: Scope,
allReturnTypes?: ReadonlyMap<string, string>,
allReturnTypes: ReadonlyMap<string, string>,
indexes: ScopeResolutionIndexes,
returnTypeByFile: ReadonlyMap<string, ReadonlyMap<string, string>>,
): string | null {
let iterableNode = valueNode;
if (iterableNode.type === 'reference_expression') {
@@ -506,10 +638,16 @@ function resolveIterableElementType(
}
if (func.type === 'identifier') {
const crossFileReturn = allReturnTypes?.get(func.text);
const crossFileReturn = allReturnTypes.get(func.text);
if (crossFileReturn !== undefined) return unwrapGeneric(crossFileReturn);
const rawReturn = lookupRawFunctionReturnType(func.text, valueNode);
if (rawReturn !== null) return unwrapGeneric(rawReturn);
// Import-disambiguated duplicate: the bare-name map is ambiguous (#2514)
// but a `use` import pins one definition. Read its FULL return type
// here, BEFORE the scope-binding lookup below, because that binding is
// generic-truncated (`Vec<Repo>` becomes `Vec`), losing the element.
const importedReturn = resolveImportedDef(func.text, moduleScope, indexes, returnTypeByFile);
if (importedReturn !== null) return unwrapGeneric(importedReturn);
const returnType = lookupReturnTypeInScopes(func.text, parsed, scopeMap, moduleScope);
if (returnType !== null) return unwrapGeneric(returnType);
}
@@ -1,4 +1,4 @@
import type { GraphNode, GraphRelationship, NodeLabel } from 'gitnexus-shared';
import type { GraphNode, NodeLabel, RelationshipType } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../graph/types.js';
import { parseTruthyEnv } from './utils/env.js';
@@ -30,9 +30,13 @@ const isLocalValueCandidate = (node: GraphNode): boolean => {
// True when `rel` is the structural `File -> DEFINES -> candidate` edge. Callers
// guard on the candidate already being the edge target, so only the source label
// needs checking here.
const isFileDefinesEdge = (graph: KnowledgeGraph, rel: GraphRelationship): boolean => {
if (rel.type !== 'DEFINES') return false;
return graph.getNode(rel.sourceId)?.label === 'File';
const isFileDefinesEdge = (
graph: KnowledgeGraph,
type: RelationshipType,
sourceId: string,
): boolean => {
if (type !== 'DEFINES') return false;
return graph.getNode(sourceId)?.label === 'File';
};
export const pruneLocalValueSymbols = (
@@ -51,21 +55,21 @@ export const pruneLocalValueSymbols = (
if (candidateIds.size === 0) return emptyStats(false);
const candidatesWithSemanticEdges = new Set<string>();
for (const rel of graph.iterRelationships()) {
// Field-wise scan (#2680): a whole-graph walk that reads only these three, so
// materializing a relationship object per edge would be pure overhead.
graph.forEachRelationshipFields((sourceId, targetId, type) => {
// Any outgoing edge from a candidate is a semantic edge: the only structural
// edge a block-local value symbol carries is the incoming File -> DEFINES, on
// which the candidate is the target, never the source.
if (candidateIds.has(rel.sourceId)) {
candidatesWithSemanticEdges.add(rel.sourceId);
if (candidateIds.has(sourceId)) {
candidatesWithSemanticEdges.add(sourceId);
}
// An incoming edge is semantic unless it is the structural File -> DEFINES.
if (candidateIds.has(rel.targetId)) {
if (!isFileDefinesEdge(graph, rel)) {
candidatesWithSemanticEdges.add(rel.targetId);
}
if (candidateIds.has(targetId) && !isFileDefinesEdge(graph, type, sourceId)) {
candidatesWithSemanticEdges.add(targetId);
}
}
});
let prunedNodes = 0;
for (const candidateId of candidateIds) {
+207 -188
View File
@@ -1,91 +1,91 @@
/**
* Phase: di
*
* Framework-neutral dependency-injection resolution. Routes `Property` nodes
* by `properties.language` to the per-language field matchers registered in
* `di-extractors/` (`DI_MATCHERS` — same registry seam shape as
* `SCOPE_RESOLVERS`), then fans each match out to `INJECTS` edges from the
* consumer Class node to every Class implementing the matched element
* interface.
*
* This file names NO language or framework: which fields count as
* container-injected — and why — is entirely the registered matcher's
* business (see `di-extractors/` for the matchers and their semantics,
* including deliberate annotation exclusions). The matcher also supplies the
* human-readable edge `reason`, so framework specifics stay in the payload,
* never in this phase.
*
* The resolution uses ONLY graph data — Property nodes, `HAS_PROPERTY` edges,
* `IMPLEMENTS` edges, and Interface nodes. No filesystem access is performed:
* the structural information was already extracted by earlier parse /
* structure phases.
*
* Interface resolution is scoped to the CANDIDATE'S OWN language and prefers
* qualified names: a dotted element type resolves via the language's
* `qualifiedName` index; a bare simple name resolves only while unique within
* that language. Ambiguous names — simple OR qualified (a qualifiedName has
* no file-path component, so the same package+name duplicated across monorepo
* modules collides too) — fail CLOSED — no edge, never
* last-writer-wins — but observably: skips are counted in the phase output's
* `ambiguousSkipped` and named in an isDev debug log, so "no DI fields" is
* distinguishable from "all candidates ambiguous". Same-package/import-aware
* disambiguation is a documented follow-up (see the plan's Deferred work).
* Framework-neutral dependency-injection resolution. Per-language resolvers
* identify injection sites and provider metadata; this phase performs only
* graph-level type/heritage resolution and emits Class -> Class INJECTS edges.
*
* @deps mro
* @reads graph (Property nodes, HAS_PROPERTY edges, IMPLEMENTS edges, Interface nodes)
* @reads graph (Class/Interface/member nodes and heritage/ownership edges)
* @writes graph (INJECTS edges)
*/
import type { SupportedLanguages } from 'gitnexus-shared';
import type { GraphNode, SupportedLanguages } from 'gitnexus-shared';
import type { PipelinePhase, PipelineContext } from './types.js';
import { DI_MATCHERS, isSupportedLanguage } from '../di-extractors/index.js';
import {
DI_RESOLVERS,
isSupportedLanguage,
type DiInjectionMatch,
type DiProviderMatch,
} from '../di-extractors/index.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
export interface DIOutput {
injectsEdges: number;
/** Kept for output compatibility; now counts every matched injection site. */
fieldsScanned: number;
/** Candidates skipped because their element type name — bare simple name
* or dotted qualified name — matched more than one Interface within the
* candidate's language (fail-closed). */
/** Sites skipped because the requested type name itself was ambiguous. */
ambiguousSkipped: number;
/** Single-valued sites represented by multiple low-confidence candidates. */
ambiguousInjections: number;
}
/** Sentinel marking an interface name (simple or qualified) claimed by more
* than one Interface node within a language — resolution must fail closed. */
const AMBIGUOUS: unique symbol = Symbol('ambiguous');
/** Per-language interface lookup: qualified names resolve exactly; bare
* simple names resolve only while unique within the language. Both indexes
* fail closed on their own duplicates. */
interface InterfaceIndex {
/** `properties.qualifiedName` → Interface node id (when extracted — e.g.
* package-qualified for languages with a file-scope package declaration),
* or {@link AMBIGUOUS} once a second Interface claims the same qualified
* name in the same language — realistic in monorepos, where the same
* package+name is duplicated across modules or main/test source roots
* (a qualifiedName carries no file-path component). */
interface NameIndex {
byQualifiedName: Map<string, string | typeof AMBIGUOUS>;
/** `properties.name` → Interface node id, or {@link AMBIGUOUS} once a
* second same-name Interface appears in the same language. */
bySimpleName: Map<string, string | typeof AMBIGUOUS>;
}
/** A Property node a registered matcher accepted as a DI fan-out candidate. */
interface CandidateField {
propertyId: string;
/** The candidate's language — interface resolution (Pass 3) looks up ONLY
* this language's interface index. */
interface CandidateSite extends DiInjectionMatch {
siteNodeId: string;
language: SupportedLanguages;
elementTypeName: string;
/** Matcher-supplied edge reason (carries the framework specifics). */
}
interface PendingEdge {
sourceId: string;
targetId: string;
confidence: number;
reason: string;
}
function emptyNameIndex(): NameIndex {
return { byQualifiedName: new Map(), bySimpleName: new Map() };
}
function addIndexedName(index: NameIndex, node: GraphNode): void {
const qualifiedName = node.properties.qualifiedName;
if (typeof qualifiedName === 'string') {
index.byQualifiedName.set(
qualifiedName,
index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id,
);
}
const simpleName = node.properties.name;
index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id);
}
function resolveIndexedName(index: NameIndex | undefined, name: string) {
if (index === undefined) return undefined;
return name.includes('.') ? index.byQualifiedName.get(name) : index.bySimpleName.get(name);
}
function providerCandidates(
ids: ReadonlySet<string>,
providers: ReadonlyMap<string, DiProviderMatch>,
): string[] {
const all = [...ids];
const recognized = all.filter((id) => providers.has(id));
// Recall-first fallback: provider metadata can be incomplete (custom
// registration mechanisms and legacy indexes can omit it). Prefer
// framework-recognized providers when present, but keep structurally valid
// candidates when none are known instead of dropping the injection entirely.
return recognized.length > 0 ? recognized : all;
}
export const diPhase: PipelinePhase<DIOutput> = {
name: 'di',
// Depends on `mro` for ordering: heritage edges (IMPLEMENTS/EXTENDS) must be
// fully populated before we resolve interface→implementer fan-out.
deps: ['mro'],
async execute(ctx: PipelineContext): Promise<DIOutput> {
@@ -96,174 +96,193 @@ export const diPhase: PipelinePhase<DIOutput> = {
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount },
});
// ── Pass 1: route Property nodes to registered per-language matchers ───
// Early-exit optimization: if no registered matcher accepts any Property
// node, skip all index construction. This makes the phase a no-op on
// repos with no DI-matched fields (no IMPLEMENTS / HAS_PROPERTY scans).
const candidates: CandidateField[] = [];
const candidates: CandidateSite[] = [];
const providers = new Map<string, DiProviderMatch>();
ctx.graph.forEachNode((node) => {
if (node.label !== 'Property') return;
const language = node.properties.language;
if (language === undefined || !isSupportedLanguage(language)) return;
const matcher = DI_MATCHERS.get(language);
if (matcher === undefined) return;
const match = matcher(node);
if (match === null) return;
candidates.push({
propertyId: node.id,
language,
elementTypeName: match.elementTypeName,
reason: match.reason,
});
const resolver = DI_RESOLVERS.get(language);
if (resolver === undefined) return;
const provider = resolver.matchProvider(node);
if (provider !== null) providers.set(node.id, provider);
for (const match of resolver.matchInjectionSites(node)) {
candidates.push({ ...match, siteNodeId: node.id, language });
}
});
if (candidates.length === 0) {
return { injectsEdges: 0, fieldsScanned: 0, ambiguousSkipped: 0 };
return {
injectsEdges: 0,
fieldsScanned: 0,
ambiguousSkipped: 0,
ambiguousInjections: 0,
};
}
// ── Pass 2: build single-pass reverse indexes ─────────────────────────
// interfaceNodeId → Set<implementerClassId> (reverse of IMPLEMENTS edge)
// IMPLEMENTS edges go Class→Interface, so target is the interface.
// Keyed by node id — globally unique — so this index needs no language
// scoping; only NAME-based lookups (below) do.
const interfaceToImplementers = new Map<string, Set<string>>();
for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) {
const implementerId = rel.sourceId; // Class
const interfaceId = rel.targetId; // Interface
let set = interfaceToImplementers.get(interfaceId);
if (set === undefined) {
set = new Set();
interfaceToImplementers.set(interfaceId, set);
const set = interfaceToImplementers.get(rel.targetId) ?? new Set<string>();
set.add(rel.sourceId);
interfaceToImplementers.set(rel.targetId, set);
}
const memberToClass = new Map<string, string>();
for (const relationType of ['HAS_PROPERTY', 'HAS_METHOD'] as const) {
for (const rel of ctx.graph.iterRelationshipsByType(relationType)) {
memberToClass.set(rel.targetId, rel.sourceId);
}
set.add(implementerId);
}
// propertyNodeId → consumerClassId (reverse of HAS_PROPERTY edge)
// HAS_PROPERTY edges go Class→Property, so target is the property.
const propertyToClass = new Map<string, string>();
for (const rel of ctx.graph.iterRelationshipsByType('HAS_PROPERTY')) {
propertyToClass.set(rel.targetId, rel.sourceId);
}
// language → InterfaceIndex (from Interface-labeled nodes). Scoped per
// language so an Interface in one language can never satisfy a candidate
// from another. Within a language, a name resolves only while unique —
// a second Interface claiming the same simple OR qualified name flips
// that entry to AMBIGUOUS and resolution fails closed (never
// last-writer-wins).
// Index only languages that can resolve: an Interface in a language with
// no candidate can never be looked up in Pass 3.
const candidateLanguages = new Set<string>(candidates.map((c) => c.language));
const interfacesByLanguage = new Map<string, InterfaceIndex>();
const candidateLanguages = new Set<string>(candidates.map((candidate) => candidate.language));
const interfacesByLanguage = new Map<string, NameIndex>();
const classesByLanguage = new Map<string, NameIndex>();
const classNodes = new Map<string, GraphNode>();
ctx.graph.forEachNode((node) => {
if (node.label !== 'Interface') return;
if (node.label !== 'Class' && node.label !== 'Interface') return;
const language = node.properties.language;
if (typeof language !== 'string') return; // no language ⇒ unindexable
if (!candidateLanguages.has(language)) return;
let index = interfacesByLanguage.get(language);
if (index === undefined) {
index = { byQualifiedName: new Map(), bySimpleName: new Map() };
interfacesByLanguage.set(language, index);
}
// `qualifiedName` reaches NodeProperties through the extensible index
// signature, so narrow it explicitly (no `any`).
const qualifiedName = node.properties.qualifiedName;
if (typeof qualifiedName === 'string') {
index.byQualifiedName.set(
qualifiedName,
index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id,
);
}
const simpleName = node.properties.name;
index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id);
if (typeof language !== 'string' || !candidateLanguages.has(language)) return;
const indexes = node.label === 'Class' ? classesByLanguage : interfacesByLanguage;
const index = indexes.get(language) ?? emptyNameIndex();
addIndexedName(index, node);
indexes.set(language, index);
if (node.label === 'Class') classNodes.set(node.id, node);
});
// ── Pass 3: emit INJECTS edges ────────────────────────────────────────
let injectsEdges = 0;
let ambiguousSkipped = 0;
const ambiguousElementTypes = new Set<string>();
const seenEdges = new Set<string>();
let ambiguousInjections = 0;
const ambiguousTypeNames = new Set<string>();
const pending = new Map<string, PendingEdge>();
const queueEdge = (edge: PendingEdge): void => {
if (edge.sourceId === edge.targetId) return;
const id = `INJECTS:${edge.sourceId}->${edge.targetId}`;
const existing = pending.get(id);
if (existing === undefined || edge.confidence > existing.confidence) pending.set(id, edge);
};
for (const candidate of candidates) {
// Resolve the consumer Class that owns this Property.
const consumerClassId = propertyToClass.get(candidate.propertyId);
if (!consumerClassId) continue;
const siteNode = ctx.graph.getNode(candidate.siteNodeId);
const consumerClassId =
siteNode?.label === 'Class' ? siteNode.id : memberToClass.get(candidate.siteNodeId);
if (consumerClassId === undefined) continue;
// Resolve the element type name via the CANDIDATE'S OWN language index
// only — a same-named Interface in another language never participates.
const index = interfacesByLanguage.get(candidate.language);
if (index === undefined) continue;
// A dotted element type is a qualified name (e.g. `com.a.Shape`) —
// exact qualifiedName lookup, unaffected by simple-name ambiguity.
// A bare name uses the simple-name index. BOTH lookups fail CLOSED
// on their own ambiguity (a qualified name too can be claimed twice —
// same package+name across monorepo modules): no edge (never
// last-writer-wins), but counted and logged so the skip is
// observable. Same-package/import-aware disambiguation is a
// deliberate follow-up (plan: Deferred work).
let interfaceId: string | undefined;
if (candidate.elementTypeName.includes('.')) {
const entry = index.byQualifiedName.get(candidate.elementTypeName);
if (entry === AMBIGUOUS) {
ambiguousSkipped++;
ambiguousElementTypes.add(candidate.elementTypeName);
continue;
}
interfaceId = entry;
} else {
const entry = index.bySimpleName.get(candidate.elementTypeName);
if (entry === AMBIGUOUS) {
ambiguousSkipped++;
ambiguousElementTypes.add(candidate.elementTypeName);
continue;
}
interfaceId = entry;
const classEntry = resolveIndexedName(
classesByLanguage.get(candidate.language),
candidate.targetTypeName,
);
const interfaceEntry = resolveIndexedName(
interfacesByLanguage.get(candidate.language),
candidate.targetTypeName,
);
if (
classEntry === AMBIGUOUS ||
interfaceEntry === AMBIGUOUS ||
(classEntry !== undefined && interfaceEntry !== undefined)
) {
// A simple/qualified name claimed by both a Class and an Interface is
// type-ambiguous too. Fail closed rather than guessing which Java type
// the injection site meant; import-aware disambiguation is not
// available in this graph-only phase. This intentionally applies to
// legacy collection sites too: a Class/Interface collision no longer
// fans out through the interface on a simple-name guess.
ambiguousSkipped++;
ambiguousTypeNames.add(candidate.targetTypeName);
continue;
}
if (interfaceId === undefined) continue;
// Fan out to every class implementing that interface.
const implementers = interfaceToImplementers.get(interfaceId);
if (!implementers) continue;
const structural = new Set<string>();
if (typeof classEntry === 'string') structural.add(classEntry);
if (typeof interfaceEntry === 'string') {
for (const id of interfaceToImplementers.get(interfaceEntry) ?? []) structural.add(id);
}
structural.delete(consumerClassId);
if (structural.size === 0) continue;
for (const implId of implementers) {
// Skip self-edges: a class never injects its own bean into itself.
if (implId === consumerClassId) continue;
let viable = providerCandidates(structural, providers);
const namedSelection = candidate.namedSelection;
if (namedSelection !== undefined) {
viable = viable.filter(
(id) => providers.get(id)?.names.includes(namedSelection.name) === true,
);
if (viable.length === 0) continue;
}
// Dedup-safe edge ID: deterministic from (consumer, implementer).
const edgeId = `INJECTS:${consumerClassId}->${implId}`;
if (seenEdges.has(edgeId)) continue;
seenEdges.add(edgeId);
if (candidate.cardinality === 'collection') {
const confidence = namedSelection === undefined ? 0.8 : 0.9;
const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`;
for (const targetId of viable) {
queueEdge({
sourceId: consumerClassId,
targetId,
confidence,
reason: candidate.reason + suffix,
});
}
continue;
}
ctx.graph.addRelationship({
id: edgeId,
if (viable.length === 1) {
const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`;
queueEdge({
sourceId: consumerClassId,
targetId: implId,
type: 'INJECTS',
confidence: 0.8,
// Matcher-supplied reason — names the framework and the annotation
// actually found on the field (see di-extractors/).
reason: candidate.reason,
targetId: viable[0],
confidence: namedSelection === undefined ? 0.9 : 0.95,
reason: candidate.reason + suffix,
});
injectsEdges++;
continue;
}
const preferred = viable.flatMap((id) => {
const reason = providers.get(id)?.preferenceReason;
return reason === undefined ? [] : [{ id, reason }];
});
if (namedSelection === undefined && preferred.length === 1) {
const selected = preferred[0];
queueEdge({
sourceId: consumerClassId,
targetId: selected.id,
confidence: 0.95,
reason: `${candidate.reason}; ${selected.reason}`,
});
continue;
}
ambiguousInjections++;
const candidateNames = viable
.map((id) => classNodes.get(id)?.properties.name ?? id)
.sort()
.join(', ');
for (const targetId of viable) {
queueEdge({
sourceId: consumerClassId,
targetId,
confidence: 0.5,
reason: `${candidate.reason}; ambiguous candidates: ${candidateNames}`,
});
}
}
for (const [id, edge] of pending) {
ctx.graph.addRelationship({ id, type: 'INJECTS', ...edge });
}
if (isDev && ambiguousSkipped > 0) {
// One aggregated debug line (not per-candidate spam): duplicate simple
// names are NORMAL in large repos, but the skip must stay observable.
logger.debug(
`🧩 DI: ${ambiguousSkipped} candidate(s) skipped — ambiguous element interface name(s): ${[...ambiguousElementTypes].sort().join(', ')}`,
`DI: ${ambiguousSkipped} site(s) skipped because requested type names were ambiguous: ${[...ambiguousTypeNames].sort().join(', ')}`,
);
}
if (isDev && (injectsEdges > 0 || ambiguousSkipped > 0)) {
if (isDev && (pending.size > 0 || ambiguousInjections > 0)) {
logger.info(
`🧩 DI: ${injectsEdges} INJECTS edges from ${candidates.length} injection-annotated collection fields (${ambiguousSkipped} ambiguous skipped)`,
`DI: ${pending.size} INJECTS edges from ${candidates.length} injection sites (${ambiguousInjections} ambiguous single-site resolutions)`,
);
}
return { injectsEdges, fieldsScanned: candidates.length, ambiguousSkipped };
return {
injectsEdges: pending.size,
fieldsScanned: candidates.length,
ambiguousSkipped,
ambiguousInjections,
};
},
};
@@ -20,6 +20,7 @@ export {
scopeResolutionPhase,
type ScopeResolutionOutput,
} from '../scope-resolution/pipeline/phase.js';
export { springConfigPhase, type SpringConfigOutput } from './spring-config.js';
export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js';
export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js';
export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js';
@@ -95,7 +95,9 @@ import {
import type { KnowledgeGraph } from '../../graph/types.js';
import type { PipelineOptions } from '../pipeline.js';
import fs from 'node:fs';
import { effectiveRamBytes, memoryAutopilotDisabled } from '../utils/effective-ram.js';
import path from 'node:path';
import v8 from 'node:v8';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { isDev } from '../utils/env.js';
@@ -111,6 +113,81 @@ import { isDebugHeapEnabled, logHeapProbe } from '../utils/heap-probe.js';
import { logger } from '../../logger.js';
// ── Constants ──────────────────────────────────────────────────────────────
/**
* Heap-scale guardrail constants (#2649). Measured on a Linux-kernel analyze:
* ~75 graph nodes per PARSEABLE file (~5M nodes / ~65k parseable files;
* validated against heap probes at chunks 25/50/75 of 113 — the first
* calibration divided by total scanned files and under-projected by ~30%),
* main-thread heap per node. C-heavy corpus; other language mixes vary — these
* feed a WARNING and an emergency abort, never a hard admission gate, so
* estimate error only shifts when the operator hears about the problem, not
* whether analyze runs.
*
* RECALIBRATED for streamed structural emit (#2680), which is on by default for
* full rebuilds and holds relationships out of the JS heap. The original 2250
* was measured against the object-based graph; an A/B at 400k nodes / 1.08M
* edges put streaming at 1.40x smaller (819 MB -> 584 MB), so the corpus-
* calibrated figure is divided by that ratio: 2250 / 1.40 ~= 1600. Scaling the
* measured constant rather than substituting a synthetic one keeps #2649's
* kernel calibration intact and changes only the one thing that actually moved.
*
* If streaming is disabled (GITNEXUS_STREAM_GRAPH_EMIT=0, or any non-force run)
* this UNDER-projects by ~40%, so the preflight warning may stay quiet on a repo
* that then struggles. That is the safe direction to be wrong in: the abort
* below reads LIVE heap use, not this projection, so it still catches the real
* condition — only the early warning is affected.
*/
const PROJECTED_NODES_PER_FILE = 75;
const PROJECTED_HEAP_BYTES_PER_NODE = 1600;
/** Warn at scan end when the projection crosses this share of the heap limit. */
const PREFLIGHT_WARN_FRACTION = 0.85;
/**
* Abort the chunk loop when live heap use crosses this share of the limit.
* Above ~0.95 V8 enters the ineffective-mark-compact death spiral (2s+ GC
* pauses that also falsely idle-timeout healthy workers, #2649); 0.92 leaves
* one chunk's worth of headroom to fail with an actionable message instead.
* `GITNEXUS_MEMORY=off` declines the abort (proceed-at-own-risk).
*/
const HEAP_ABORT_FRACTION = 0.92;
/** Projected main-thread heap need for the parse phase (#2649). */
export function projectParseHeapNeedBytes(parseableFileCount: number): number {
return parseableFileCount * PROJECTED_NODES_PER_FILE * PROJECTED_HEAP_BYTES_PER_NODE;
}
/** True when the mid-loop heap guard should abort the parse (#2649). */
export function shouldAbortForHeapPressure(heapUsedBytes: number, heapLimitBytes: number): boolean {
if (memoryAutopilotDisabled()) return false;
return heapUsedBytes > heapLimitBytes * HEAP_ABORT_FRACTION;
}
/**
* The ONE action a user should take when this repository doesn't fit the
* current heap (#2649). Users hitting memory limits are already frustrated —
* a menu of env knobs at that moment is noise. Branch on whether the machine
* itself has more memory to give: if this process's limit sits well below
* what the RAM-aware auto-sizer would grant (an inherited NODE_OPTIONS pin or
* explicit flag), the fix is to drop the pin — gitnexus sizes itself.
* Otherwise the machine is the ceiling and only scope or hardware helps.
* Escape hatches (GITNEXUS_MEMORY etc.) stay in the README env table.
*/
export function heapPressureRemedy(heapLimitBytes: number): string {
// Effective RAM honors a real cgroup limit — raw os.totalmem() told users
// inside an 8GB-limited container on a 64GB host that "this machine has
// more memory available", an advice loop with no exit (#2649 review).
const autoCapBytes = effectiveRamBytes() * 0.75;
if (heapLimitBytes < autoCapBytes * 0.9) {
return (
`This machine has more memory available: re-run without the --max-old-space-size ` +
`pin (NODE_OPTIONS or node flag) — gitnexus sizes its heap to the machine automatically.`
);
}
return (
`This machine is at its memory ceiling: exclude generated or vendored directories ` +
`via .gitnexusignore, or analyze on a machine with more memory.`
);
}
/** Max bytes of source content to load per parse chunk.
*
* Memory bound for the worker pool dispatch + a granularity knob for
@@ -516,6 +593,22 @@ export async function runChunkedParseAndResolve(
MIN_SUB_BATCH_BYTES,
Math.ceil(chunkByteBudget / (effectivePoolSize * TARGET_JOBS_PER_WORKER)),
);
// Heap-scale guardrails (#2649), measured on a Linux-kernel analyze
// (94,773 files): ~55 graph nodes per parseable file and ~2.2KB of
// main-thread heap per node, linear across 113 chunks (see
// docs/plans/2026-07-23-gitnexus-plan-large-repo-analyze-oom.md §2).
// Estimates, not contracts — used only to warn early (preflight) and to
// convert a certain multi-minute GC death spiral into an immediate
// actionable error (mid-loop guard).
const projectedHeapNeedBytes = projectParseHeapNeedBytes(parseableScanned.length);
const heapLimitBytes = v8.getHeapStatistics().heap_size_limit;
if (projectedHeapNeedBytes > heapLimitBytes * PREFLIGHT_WARN_FRACTION) {
logger.warn(
`Large repository: analyzing ${parseableScanned.length} files needs roughly ${Math.round(projectedHeapNeedBytes / 1024 / 1024 / 1024)}GB of memory, ` +
`but Node is limited to ${Math.round(heapLimitBytes / 1024 / 1024 / 1024)}GB — analyze may stop early. ${heapPressureRemedy(heapLimitBytes)}`,
);
}
const chunks: string[][] = [];
let currentChunk: string[] = [];
let currentBytes = 0;
@@ -869,6 +962,18 @@ export async function runChunkedParseAndResolve(
`nodes=${graph.nodeCount} parsedFiles=${allParsedFiles.length}`,
);
}
// #2649 mid-loop heap guard: fail actionably BEFORE V8 enters the
// ineffective-mark-compact death spiral (which also falsely times out
// healthy workers). The pool is torn down by this function's finally.
const heapUsedNow = process.memoryUsage().heapUsed;
const heapLimitNow = v8.getHeapStatistics().heap_size_limit;
if (shouldAbortForHeapPressure(heapUsedNow, heapLimitNow)) {
throw new Error(
`Analyze stopped before running out of memory: ${Math.round(heapUsedNow / 1024 / 1024)}MB of the ` +
`${Math.round(heapLimitNow / 1024 / 1024)}MB Node heap in use at parse chunk ${chunkIdx + 1}/${numChunks} (#2649). ` +
heapPressureRemedy(heapLimitNow),
);
}
const chunkPaths = chunks[chunkIdx];
// Start wall-clock for the per-chunk throughput log emitted at end
// of this iteration. The gate is computed once above; here we just

Some files were not shown because too many files have changed in this diff Show More