Compare commits

...
Author SHA1 Message Date
gitnexus-release-bot[bot] 7a1ed9b53e release: v1.6.8-rc.26 2026-06-13 09:16:08 +00:00
bluerose cab63b508e feat(mcp): add gitnexus mcp --http server with Streamable HTTP and legacy SSE transports (#2141) 2026-06-13 09:58:49 +01:00
60752de3e9 fix(ip): Scope write-route origin guard to server's own bound host (#2172)
* Initial plan

* Allow RFC1918 LAN origins in requireLocalhostOrigin

* Harden LAN origin parsing in middleware tests

* Refactor private IPv4 checks into shared server helper

* fix: scope origin guard to server's bound host, fix [::1], guard all write routes

- P1: Replace blanket RFC1918 trust with same-host check — only the server's
  own bound host is allowed (via `createLocalhostOriginGuard(host)`), not
  every device on the LAN.
- P2: Fix dead `::1` branch — compare against `'[::1]'` (with brackets) as
  returned by WHATWG URL parser.
- P3: Update 403 message to "same-host origins" and doc comments.
- Out-of-scope: Add `requireLocalhostOrigin` to `DELETE /api/repo`,
  `POST /api/embed`, `DELETE /api/embed/:jobId`, `DELETE /api/analyze/:jobId`.
- Tests: Add [::1] regression, ftp://, null origin, direct private-ip.ts
  unit tests, and createLocalhostOriginGuard bound-host tests.

* fix: cast route params to string when middleware breaks type inference

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

* fix(test): update rate-limit test regex to match multi-line embed route registration

* fix(ip): normalize boundHost and keep wildcard binds loopback-only

The same-host write guard compared the raw `--host` string to the WHATWG
`URL.hostname` of the Origin, so it silently 403'd legitimate same-host
browser writes for several bind forms:
  - mixed-case hostnames (`MyHost.local` vs lowercased `myhost.local`)
  - non-loopback IPv6 (`fe80::1` vs bracketed `[fe80::1]`, and non-canonical
    forms like `fe80:0:0:0:0:0:0:1` / `::ffff:127.0.0.1`)
  - wildcard binds (`0.0.0.0` / `::`), the CLI-advertised remote-access config

Canonicalize boundHost once at guard construction through `new URL().hostname`
(provably the same form the Origin is parsed into), and treat wildcard binds as
having no single host identity → writes stay loopback-only. We deliberately do
NOT fall through to RFC1918 for wildcards (that would re-open whole-LAN reach).
`createServer` now warns when bound to a wildcard so a remote-access deployment
is not silently write-blocked.

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

* fix(ip): tag origin-block 403 with a machine-readable code and surface it in the web client

The write-route Origin guard returned a 403 with only a human-readable
`error` string, so clients could not distinguish an origin block from any
other 403. The hosted web client (gitnexus.vercel.app driving a local
backend) swallowed the resulting failure: the repo delete button caught the
error and only `console.error`'d it, so it silently no-op'd.

- Server: add a stable `code: 'origin_not_allowed'` discriminator to the 403 body.
- Web client: `assertOk` reads `body.code` and maps `origin_not_allowed` to a new
  `BackendError` code `origin_blocked`; `formatBackendError` renders an actionable
  i18n message (en + zh-CN) instead of the generic client message.
- Header: surface the delete failure inline instead of swallowing it to console.

Scope note: the embedding-status badge (EmbeddingStatus.tsx) hides in backend
mode (its `serverBaseUrl` guard), so it is not the surface where an origin-block
embed error appears; a dedicated backend-mode embedding-error surface is deferred
with the broader hosted-UI mode-awareness follow-up.

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

* refactor(ip): remove unused isValidIpv4Address export

`isValidIpv4Address` had no `src/` consumer — only its own test imported it.
It was a leftover from the reverted RFC1918-middleware approach (the same-host
guard now compares against a canonicalized bound host, not an IPv4 validity
check). Remove the export and its orphaned test block. `parseIpv4Octets` stays
(it feeds `isRfc1918PrivateIpv4`, which CORS `isAllowedOrigin` still uses).

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: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:24:03 +01:00
azizur100389andGergő Magyar 50cda61a43 feat(setup): select coding agent integrations (#2168)
* feat(setup): select coding agent integrations

* style: format setup agent selection

* fix(setup): validate explicit agent selection

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-13 08:06:50 +01:00
dependabot[bot] 6bd2d119fc chore(deps)(deps-dev): bump esbuild from 0.28.0 to 0.28.1 in /gitnexus (#2182)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.28.0 to 0.28.1.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-13 07:19:13 +01:00
Gergő Magyar 129bc84c0d feat(taint): interprocedural taint via function summaries over resolved CALLS (#2084) (#2179) 2026-06-13 07:04:14 +01:00
MinidoracatandGergő Magyar 0054496323 fix(hooks): wrap the augment CLI child in the orphan guard (#2163) (#2169)
* fix(hooks): wrap the augment CLI child in the orphan guard (#2163)

Follow-up invited by the maintainer on #2165: the augment child
(7s local / 12s npx) was the longest-lived unwrapped subprocess, exposed
to the same SIGKILL-orphan mechanism fixed for lsof/ps.

- Export resolveUnixGuardTimeout from the probe module (both copies,
  byte-identical); adapters share the same module instance, so the memo
  and lazy self-test still run at most once per hook process.
- Wrap every CLI-executing branch of runGitNexusCli in the three
  probe-equipped adapters with the guard: budget ceil(inner/1000)+1
  seconds with -k 1, strictly above each branch's inner spawnSync
  timeout, so the supervised path is unchanged and the wrapper only
  matters once the hook itself is SIGKILLed. Windows and no-guard hosts
  keep byte-identical argv. The plugin adapter's PATH-direct gitnexus
  branch (its most common production path) is wrapped too; the cheap
  which/where probe is not.
- Cursor integration: debug-gated 'augment skipped: hook slots
  saturated' on the slot-starved early return. Its augment child stays
  unwrapped for now — that integration does not install the probe
  sibling (the 'cursor probe' item on the #2163 follow-up list).
- Reaping tests get a guard-availability precheck with an explicit
  failure message (assertion, not skipIf, so a coreutils-less Linux
  host fails diagnosably instead of going silently green).
- Tests: orphaned-augment reaping (CJS + Plugin, red without the wrap,
  ~9.1s reap measured), disabled-sentinel degradation equivalence,
  source pinning for all three adapters (exact per-branch budget-formula
  counts) + probe export + cursor debug line.

Note: pre-commit typecheck skipped; remaining tsc errors are
pre-existing on main (none in files touched here).

* fix(hooks): group-SIGKILL the npx arm, prove guard exit propagation (#2169 review)

Addresses the tri-review findings on #2169:

- [P2] npx-arm containment: the CLI is the guard's grandchild there —
  at budget expiry coreutils timeout TERMs the group, npx (the obedient
  direct child) dies, timeout returns, and -k never fires, so a
  SIGTERM-immune grandchild escaped unbounded. The npx arm's wrapper now
  uses -s KILL: an unignorable group SIGKILL at budget that reaps the
  grandchild (kept -k 1 as a harmless belt; direct-exec arms keep
  TERM-first). CHANGELOG, adapter docblocks, and the test comment now
  state the per-arm semantics honestly. New behavioral test: a staged
  hook with a PATH-injected fake npx spawning a SIGTERM-immune
  grandchild is SIGKILLed; the grandchild must be reaped (red without
  -s KILL), with a route self-proof marker pinning the npx arm.
- [P3] guard self-test now proves exit-status propagation
  (sh -c 'exit 42' must yield status 42), so an always-exit-0 stub like
  /bin/true is rejected and resolution falls through to the built-in
  candidates instead of silently killing the augment feature. New test:
  stub guard rejected, augment still emits context.
- [P3] cleanup SIGKILLs in the reaping tests re-check the
  /proc/<pid>/cmdline identity immediately before firing (PID-reuse
  guard), applied consistently to the two pre-existing #2165 spots and
  both new tests.
- Review notes: source pins now constrain wrapper argv order and exact
  per-arm counts; adapters degrade to unwrapped on probe version skew
  (typeof check) instead of a swallowed TypeError; export JSDoc wording
  fixed for relative env paths; debug-gated diagnostic when no guard is
  available (e.g. macOS without coreutils), with the CHANGELOG entry
  qualified accordingly.

Note: pre-commit typecheck skipped; remaining tsc errors are
pre-existing on main (none in files touched here).

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-12 15:17:30 +01:00
Gergő Magyar 14397dd4aa feat(taint): intra-procedural taint analysis (#2083) (#2164)
* feat(taint): harvest occurrence-tagged call/member sites on StatementFacts (#2083 U1)

Worker-side site harvest in TsHarvester: call/new/member-read records with
dotted callee paths, receiver slots, per-argument occurrence tagging with
nested-site links, per-declarator resultDefs, spread/template/require-literal
markers. hasTaintSafeSites validation seam. The pdg parse-cache chunk-key
namespace is versioned (pdg:1 -> pdg:2) instead of a global SCHEMA_BUMP so
flag-off users keep warm caches; bench fingerprints re-baselined for the
three call-bearing scenarios (straight-line/dense-bindings byte-unchanged).

* feat(taint): built-in TS/JS source/sink/sanitizer model + site matcher (#2083 U2)

Typed spec (kind taxonomy; sanitizers carry neutralizes-kinds), the canonical
Express/Node model, and matchFunctionSites: ESM alias/namespace + require-
literal callee resolution, bare-name fallback restricted to true globals,
sanitizers module-or-global only (never user-shadowable by name), spread/
template arg-position rules, deterministic taintModelVersion.

* feat(taint): pure intra-procedural taint propagation engine (#2083 U3)

Two-rule model (statement-local + du-fact worklist) with per-taint
neutralized-kind exclusion sets: sanitizers exclude only the sink kinds
they neutralize (escape(req.body) suppresses res.send but still fires
db.query; exec(path.basename(t)) fires), intersection-over-paths so a
bypass occurrence keeps the taint live, kill locality on resultDefs,
propagate-through args+receiver with viaCall hops, one path per finding,
deterministic caps, coverage-gap statuses. Test-first: 38 scenarios on
real harvested CFGs.

* feat(taint): thread taint caps + model version through pdg config/meta (#2083 U5)

resolvePdgConfig gains maxTaintFindingsPerFunction (200), maxTaintHops (32),
and the taintModelVersion digest; RepoMeta.pdg + RunScopeResolutionInput
surfaces added. The key-union comparator trips full writeback on M2->M3
upgrade and on model-version change without --force (mode-flip tested).
No CLI flags or rc keys (programmatic parity with the other caps).

* feat(taint): in-phase taint emit with sparse TAINTED/SANITIZES edges (#2083 U4)

run.ts pdg window: match-first fast path (solver only when a function has
both a matched source and sink) -> computeReachingDefs with the shared RD
fact derivation -> computeTaintFlows -> per-finding TAINTED (versioned
hop-encoded reason via the shared path codec, statement-level occurrence
identity) + per-kill SANITIZES, dedup-before-budget, truncate-and-warn.
All emit counters surfaced (aggregate warn for gaps/drops, debug for
volume); PROF gains taint=. Flag-off golden untouched.

* feat(mcp): explain tool for persisted taint findings (#2083 U6)

Anchorless calls enumerate the sparse TAINTED table (bounded, deterministic,
limit-clamped); anchored calls (file or symbol via resolveSymbolCandidates)
return full decoded hop detail. sinkKind rides a version-1 codec header
(1;<kind>|hops — no other persisted channel exists; U4/U6 ship together).
RepoMeta.pdg probe yields a no-taint-layer note instead of an error.
TAINTED/SANITIZES pinned OUT of VALID_RELATION_TYPES (KTD9a negative-
membership tests); generators + canonical skill docs + mirrors updated.

* test(taint): acceptance fixture battery, snapshots, and bench gates (#2083 U7)

pdg-repo taint-cases fixtures complete the six plan shapes; committed
findings/kills snapshot via a shared pure-path harness that also feeds the
AE2 exact-equality assertion (stored TAINTED == pure-path findings, the
no-explosion gate). New taint-dense bench scenario with four --check gates:
per-function findings pinned AT the cap, absolute reason-byte + site-bytes
disk ceilings (the load-bearing R10 gate), zero-match pass < 0.5x match-
dense, N-linearity. Pre-existing scenario baselines untouched.

* refactor(taint): share one pointKey helper across propagate + emit (#2083 review)

Extract pointKey(ProgramPoint) to cfg/reaching-defs.ts (colon-separated,
matching the codebase block:stmt id convention) and import it in both
propagate.ts and emit.ts, replacing the two divergent locals (':' vs '.').
Edge-id material now uses the colon form; ids are in-memory only and no
test asserts the pointKey segment shape.

* fix(taint): discriminate taint state by source occurrence (#2083 review)

Two distinct sources flowing into one variable at one def point no longer
collapse to a single TAINTED edge: the taint-state key gains a root
source-occurrence discriminator ({point, siteIndex} — the same fields
recordFinding's identity uses, excluding kind). Def->use fact lookup keys
on the source-independent (binding, def-point) portion. Same-source
multi-path flows still share one state so their exclusion sets intersect
(the raw arm soundly wins); termination holds (finite keys, monotone
shrink, no cross-source ping-pong). Restores the KTD6 identity contract.

* fix(mcp): route dotted symbol names in explain to symbol resolution (#2083 review)

The fileish classifier matched any dotted name (UserController.create)
as a file via its extension-like suffix, so symbol resolution never ran
and the tool returned a silent empty file-anchored result. Tighten the
classifier to require a path separator or a real source extension (derived
from the resolver's EXTENSIONS list, multi-language), so dotted/bare names
route to resolveSymbolCandidates (found / ambiguous / not-found).

* fix(mcp): gate explain no-taint-layer note on taintModelVersion (#2083 review)

An M1/M2-era --pdg index has meta.pdg defined (BasicBlock/REACHING_DEF
recorded) but no taintModelVersion and zero TAINTED rows. The probe keyed
on generic meta.pdg presence, so explain returned the generic empty note
instead of the actionable 'no taint layer — run analyze' hint. Gate on
meta.pdg?.taintModelVersion (the field M3 stamps) so an M2-era index gets
the layer hint; a taint-stamped index with no findings still gets the
generic note.

* fix(taint): sequence-expression value flows only the final operand (#2083 review)

A comma expression in value position (exec((log(x), 'safe'))) default-
descended, fanning every operand's occurrences into the enclosing sink
argument — over-tainting exec's arg 0 with x. Add an explicit walkValue
case that records earlier operands' uses with occurrence fan-out suppressed
(new FactAccumulator.suppressOccurrences) and routes only the last operand
through the value path. Sites-layer only; defs/uses/mayDefs byte-identical
(cfg + reaching-defs snapshots unchanged).

* perf(taint): FIFO head-cursor worklist + dedup before chainHops (#2083 review)

Replace queue.shift() (O(N) dequeue) with a strict-FIFO head cursor plus
order-preserving prefix reclamation; FIFO is load-bearing because chainHops
reads the live taints map whose parent/source/viaCall are rewritten
order-sensitively on monotone shrink, so hop determinism is dequeue-order
contingent. Extract findingKey() and dedup-check before chainHops in the
justify branch — already-recorded identities discard their hop chain
(first write wins), so the ancestry walk was pure waste. The else kill
branch is untouched. Findings + hops byte-identical (snapshot unchanged).

* perf(taint): O(1) member-read dedup via composite-key set (#2083 review)

addMemberRead rescanned the whole per-statement sites array per call to
dedup by (object, property, parent) — O(n^2) on member-read-dense
statements. Track a composite-key Set alongside sites for O(1) dedup.
(The require-literal join is already O(sites) with a no-op body on
non-require sites, so no early-exit is needed there.) Behavior identical:
harvest + model-match + taint snapshots unchanged.

* refactor(taint): drop test-only export; source taint caps via emit.ts (#2083 review)

Remove the sanitizerNeutralizes export (its only consumers were two test
assertions — inlined to entry.neutralizes membership). Re-export the
DEFAULT_PDG_MAX_TAINT_* caps from emit.ts and point run.ts at emit.ts, so
the pipeline's taint dependency surface is the single orchestration module
rather than reaching into propagate.ts.

* test(taint): extract the shared TS CFG/taint test harness (#2083 review)

The parse/collectFunctions/cfgOf/cfgsOf/importsFor harness was copied
byte-for-byte across four suites (harvest, model-match, propagate,
taint-emit). Promote it to test/helpers/ts-cfg-harness.ts and import it.
site-safety/reaching-defs carry a structurally different inlined builder
and are left as-is. Pure extraction, no assertion changes.

* test(mcp): harden explain limit-rejection battery (#2083 review)

Add NaN, Infinity, -Infinity, and a numeric string to the out-of-bounds
limit cases — a regression fence over the interpolated LIMIT, confirming
the Number.isInteger guard rejects every non-integer/non-finite/string
input before it reaches the query.
2026-06-12 07:35:09 +01:00
Gergő Magyar 28f3b99822 chore(devcontainer): simplify Dockerfile and devcontainer.json by removing version args for AI CLIs (#2174) 2026-06-12 07:13:33 +01:00
azizur100389 bdb824cfe4 feat(cli): add circular import cycle check (#2166) 2026-06-12 04:53:17 +01:00
Minidoracat 10d1e47df3 fix(hooks): bound db-lock probe subprocesses and gate probe behind hook slot (#2163) (#2165)
* fix(hooks): bound db-lock probe subprocesses and gate probe behind hook slot (#2163)

The Claude PreToolUse db-lock probe leaks orphaned lsof processes when
the hook process is hard-killed mid-probe (e.g. Claude Code's 10s hook
timeout under load). Orphans accumulate, raise load, slow the next
probe, and snowball to sustained 100% CPU.

- Wrap the unix lsof/ps fallback in coreutils timeout (-k 1 2 / -k 1 1),
  resolved via a lazy self-test, so probe children self-destruct within
  ~3s even if the hook is SIGKILLed. GITNEXUS_HOOK_TIMEOUT_PATH
  overrides the guard binary; the sentinel value 'disabled' turns the
  guard off; hosts without a usable guard keep the previous behavior.
- Acquire the per-repo hook slot before probing (all three adapters),
  bounding concurrent probes to 3 per .gitnexus, with probe and augment
  inside try/finally so the slot is always released.
- Tests: source-order contract, slot-gating behavior, orphan reaping
  with a SIGTERM-immune fake lsof and a SIGKILLed parent (red on base),
  probe-copy byte parity, no-guard equivalence, broken-guard rejection.

Note: pre-commit typecheck skipped; the 62 tsc errors are pre-existing
on main (all in src/core/** and src/server/, none in files touched
here; base==head invariant verified).

* fix(hooks): address tri-review P3 findings (#2165)

- Map guard signal-death (status null + signal, no spawnSync error) to
  fail-closed at both the lsof and ps call sites, closing the freeze
  window (SIGSTOP / laptop sleep > 2s) that previously landed fail-open.
  Rewrite the exit-code comments: coreutils surfaces the -k kill as
  signal death, 124 is budget expiry (live arm), 137 covers only
  exit-code-propagating wrappers or an externally SIGKILLed child.
- Add a debug-gated 'augment skipped: hook slots saturated' stderr line
  on the slot-starved early return in all three adapters, restoring
  observability under GITNEXUS_DEBUG=1.
- GITNEXUS_HOOK_TIMEOUT_PATH now participates in candidate fall-through:
  the env candidate is tried first, then the built-ins, each behind the
  lazy self-test — an existing-but-unusable env path (directory,
  non-executable) can no longer silently disable orphan containment.
- Tests: +6 — guard exit 124 pins the live arm (CJS+Plugin), guard
  signal-death pins the new mapping (CJS+Plugin, red before the fix),
  antigravity behavioral slot-gate, env-dir fall-through still reaps a
  SIGTERM-immune orphan via a built-in guard.

Note: pre-commit typecheck skipped; the 62 tsc errors are pre-existing
on main (none in files touched here).
2026-06-11 15:38:13 +01:00
Gergő Magyar bde340a5b4 feat(cfg): intra-procedural REACHING_DEF data-dependence layer (#2082) (#2160)
* fix(cfg): route early exits through finally with target-relative threading (#2082 U2)

* feat(cfg): harvest per-statement def/use facts into the side channel (#2082 U1)

* feat(cfg): add reaching-definitions solver with GEN/KILL fixpoint + statement sweep (#2082 U3)

* feat(cfg): persist budgeted REACHING_DEF projection with RepoMeta coherence (#2082 U4)

* test(cfg): REACHING_DEF snapshot, pipeline both-sinks, and cache-seam coverage (#2082 U5)

* bench(cfg): reaching-defs scaling gates — dense-bindings + fact-fanout scenarios (#2082 U6)

* fix(mcp): exclude BasicBlock pseudo-symbols from detect_changes on pdg indexes (#2082 U7)

* style: prettier pass over M2 files

* fix(cfg): review-pass fixes — defKey overflow guard, catch-param block, class defs, intra-statement reads, graceful fact degradation (#2082)

- reaching-defs: STMT_STRIDE 2^16→2^21 + upfront aliasing bail-out; a use
  that shares its statement with a def now also sees the same-statement def
  (assign-and-test idiom was a taint false negative); drop dead posInOrder
- visitor: catch-param def gets its own once-executed block (prepending into
  a loop-header entry re-genned per iteration and killed loop-carried
  redefs); unresolved-label jumps now thread all active finallys; the
  finalizer-threading protocol moved to control-flow-context as shared
  helpers for future language visitors
- harvest: class declarations def their name (was a bogus use in JS, silent
  skip in TS); class-expression names stay internal
- emit: isEmitSafeCfg adds index==position contiguity; fact validation split
  into hasEmitSafeFacts so malformed facts degrade to CFG-only instead of
  dropping the function's whole CFG layer; facts-per-edge multiplier single
  source; lazy top-binding tally; dead solveMs removed
- run-analyze: pdgModeMismatch compares the key union structurally — new
  resolved knobs join the comparison automatically
- mcp: BasicBlock exclusion via id prefix (NULL-name rows of real symbols
  are no longer dropped) + same filter on the BM25 filePath fallback
- bench: rd ratio denominator clamped (gate no longer self-disables at fast
  small-N); PROF-gated pdg timing in run.ts

* test(run-analyze): model the M2 RepoMeta.pdg stamp in resolvePdgConfig defaults

The DEFAULTS constant lacked the maxReachingDefEdgesPerFunction field that
resolvePdgConfig resolves since the M2 stamp landed, failing two strict
toEqual expectations (the CI 'tests' job failures). Models M2 steady-state
equality; the M1-era-stamp upgrade path stays pinned in pdg-mode-flip.test.ts.

Finding P1-4 of review 4471987625 (#2160).

* test(cfg): reassign the shadowing fixture's bindings — fixes prefer-const CI errors

Both withShadowing let bindings now genuinely reassign (s = s + 1 per scope),
clearing the two prefer-const errors that failed quality/lint. Plain const
would change the binding kind the harvest test exercises; reassignment keeps
the let semantics and enriches the reaching-defs facts the snapshot pins
(snapshot + per-binding assertion updated accordingly).

Finding P2-6 of review 4471987625 (#2160).

* fix(cfg): validate entry/exit indices in the emit-safety guard

A corrupted side-channel element with an out-of-range entryIndex passed
isEmitSafeCfg and threw inside the reaching-defs RPO walk — caught by the
per-FILE try/catch, costing every sibling function's REACHING_DEF projection
instead of the one element (and logging a misleading message). entry/exit
join the guard's id-anchor checks.

Finding P3 (entryIndex) of review 4471987625 (#2160).

* fix(cfg): report the def-key stride bail-out as a distinct 'overflow' status

The STMT_STRIDE aliasing guard reused status 'truncated', so the emit warn
misnamed it as the fact-materialization limit (printing an unrelated maxFacts
value, including '(0)' when unlimited) and telemetry conflated the two. A
distinct 'overflow' status gets its own warn naming the actual cause; the
function's CFG layer is explicitly unaffected.

Finding P3 (stride-bail diagnosis) of review 4471987625 (#2160).

* perf(cfg): cache the nearest enclosing scope per node during the prescan

resolve() walked the AST parent chain per identifier — O(expression nesting
depth), quadratic on deeply-chained single-statement expressions in generated
code (not caught by any bench scenario, which scale blocks/bindings, not
expression depth). The prescan already visits every node once, so caching its
innermost scope makes phase-2 resolution O(scope-chain). Behavior-identical;
the parent-chain walk survives as fallback for prescan-unvisited nodes.

Finding P2 (resolve depth walk) of review 4471987625 (#2160).

* fix(cfg): stop harvesting initializer-less var declarators as defs

A bare `var x;` mid-function is hoisted and writes nothing at runtime, but
the harvester recorded a def — fabricating a kill of the live def in the
same block: `x = source(); var x; sink(x)` lost the source→sink fact (a
reaching-defs false negative). Defs now require an initializer for
variable_declaration declarators; let/const genuinely initialize and keep
their def.

Finding P2-5 of review 4471987625 (#2160).

* fix(cfg): unwrap parenthesized/non-null lvalue wrappers before def detection

`(x) += 1` and `(x)++` gated the def on the node type being exactly
'identifier', so the parenthesized form fell to the uses-only branch — the
def (and its kill) silently vanished. Wrappers that don't change the lvalue
(parenthesized_expression, TS non_null_expression) now unwrap at all three
lvalue sites.

Finding P3 (parenthesized lvalues) of review 4471987625 (#2160).

* fix(cfg): conditionally-evaluated defs are MAY-defs — gen without kill

A def inside a short-circuit right operand, ternary arm, logical assignment,
or switch case test was harvested as a must-def; the solver's total kill then
erased the prior def on the not-taken path — a taint false negative on core
idioms (`if (a && (x = clean())) {} sink(x)` lost source→sink;
`cached ?? (cached = load())` likewise). StatementFacts gains an optional
mayDefs field (conditional-context tracking in the harvester); the solver's
per-block GEN carries {set, kills} so a may-def UNIONS into the binding's set
instead of replacing it, in both the transfer and the statement sweep; the
emit fact-guard validates mayDefs indices; switch case tests harvest via the
conditional path.

Finding P1-1 of review 4471987625 (#2160).

* fix(cfg): model labeled statements generically — break keeps its real continuation

A break to a label the visitor didn't model (labeled non-loop block, the
OUTER label of a doubly-labeled construct) routed to EXIT, REMOVING the only
path that kept the pre-jump def live — a reaching-defs false kill the in-code
comment wrongly called sound. Loop/switch frames now carry their full label
LIST (`outer: inner: for` resolves both); a labeled non-loop statement gets
a break-target frame whose target is a synthesized join after the body; an
unlabeled break never matches a block frame; labels compose with finalizer
threading (a labeled break crossing a finally still threads it).

Finding P1-2 of review 4471987625 (#2160).

* fix(cfg): throw edges deliver ALL of a block's defs to the handler

The throw contribution was IN ∪ OUT — entry and final states only. The
intermediate defs of a multi-def coalesced block were invisible to the
handler, though they are exactly what the catch observes when a later
statement throws: `try { x = parse(a); x = normalize(x); } catch { sink(x) }`
lost the parse→sink fact (normalize throwing delivers parse's value). Throw
predecessors now contribute IN(from) ∪ allDefs(from) — a static per-block
all-def-sites map — which subsumes OUT; monotone and deterministic.

Finding P1-3 of review 4471987625 (#2160).
2026-06-11 05:49:39 +01:00
dependabot[bot]andGergő Magyar 1150eea98f chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 (#2152)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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>
2026-06-10 21:58:49 +01:00
dependabot[bot] ae1ec82f5f chore(deps): bump actions/attest-build-provenance from 2.4.0 to 4.1.0 (#2158)
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.4.0 to 4.1.0.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/v2.4.0...a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.1.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-06-10 21:55:17 +01:00
dependabot[bot] 5a590f052f chore(deps): bump docker/setup-qemu-action from 4.0.0 to 4.1.0 (#2159)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.0.0 to 4.1.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/ce360397dd3f832beb865e1373c09c0e9f86d70a...06116385d9baf250c9f4dcb4858b16962ea869c3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.1.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-06-10 21:55:03 +01:00
dependabot[bot] df56d34f33 chore(deps): bump release-drafter/release-drafter from 7.3.0 to 7.3.1 (#2157)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.3.0 to 7.3.1.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/c2e2804cc59f45f57076a99af580d0fedb697927...693d20e7c1ce1a81d3a41962f85914253b518449)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:52:55 +01:00
dependabot[bot] 04aeb1f4bf chore(deps)(deps-dev): bump @vercel/node in /gitnexus-web (#2156)
Bumps [@vercel/node](https://github.com/vercel/vercel/tree/HEAD/packages/node) from 5.8.8 to 5.8.12.
- [Release notes](https://github.com/vercel/vercel/releases)
- [Changelog](https://github.com/vercel/vercel/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/vercel/vercel/commits/@vercel/node@5.8.12/packages/node)

---
updated-dependencies:
- dependency-name: "@vercel/node"
  dependency-version: 5.8.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:52:41 +01:00
dependabot[bot] 792cd96d37 chore(deps): bump actions/setup-python from 5.6.0 to 6.2.0 (#2155)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.6.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5.6.0...a309ff8b426b58ec0e2a45f0f869d46889d02405)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.2.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-06-10 21:52:24 +01:00
dependabot[bot] 351edca0ca chore(deps)(deps-dev): bump @vitest/coverage-v8 in /gitnexus-web (#2153)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.5 to 4.1.8.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:51:57 +01:00
dependabot[bot] 15e141f21b chore(deps)(deps): bump sigma from 3.0.2 to 3.0.3 in /gitnexus-web (#2151)
Bumps [sigma](https://github.com/jacomyal/sigma.js) from 3.0.2 to 3.0.3.
- [Release notes](https://github.com/jacomyal/sigma.js/releases)
- [Changelog](https://github.com/jacomyal/sigma.js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jacomyal/sigma.js/compare/sigma@3.0.2...sigma@3.0.3)

---
updated-dependencies:
- dependency-name: sigma
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:50:52 +01:00
dependabot[bot] 0b2c88518e chore(deps)(deps): bump dompurify from 3.4.7 to 3.4.8 in /gitnexus-web (#2150)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.7 to 3.4.8.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.7...3.4.8)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:50:41 +01:00
dependabot[bot] 8bb2fe9128 chore(deps)(deps): bump langchain from 1.4.2 to 1.4.4 in /gitnexus-web (#2149)
Bumps [langchain](https://github.com/langchain-ai/langchainjs) from 1.4.2 to 1.4.4.
- [Release notes](https://github.com/langchain-ai/langchainjs/releases)
- [Commits](https://github.com/langchain-ai/langchainjs/compare/langchain@1.4.2...@langchain/openai@1.4.4)

---
updated-dependencies:
- dependency-name: langchain
  dependency-version: 1.4.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:50:30 +01:00
6424d8b09c fix(web): replace broken Browse-for-folder with upload directory picker (#1850)
* fix(web): replace broken Browse-for-folder with server-side directory picker

The "Browse for folder" button used `<input type="file" webkitdirectory>`
which only exposes relative paths via `webkitRelativePath`. The code
extracted just the folder name (e.g. `myproject`), causing the server to
reject it with "path must be an absolute path". No browser API can
expose absolute filesystem paths, so the approach was fundamentally
broken on all platforms.

- Add `GET /api/fs/list` endpoint that lists subdirectories at a given
  absolute server-side path (rate-limited, validated)
- Add `listDirectories()` client function in backend-client.ts
- Add `DirectoryPicker` modal component with breadcrumb navigation
- Replace broken `webkitdirectory` input in RepoAnalyzer with the new
  server-side directory picker
- Update i18n strings (en + zh-CN)
- Add unit tests for the new endpoint (9 tests)

Docker users can now browse `/workspace/` and other container paths
directly from the UI. Manual path entry continues to work unchanged.

Closes #1518

* test(e2e): add Playwright tests for server-side directory picker

13 Playwright e2e tests covering the full DirectoryPicker flow:
- Open/display: modal opens, shows root dirs, displays current path
- Navigation: click into dirs, breadcrumb back-nav, home button
- Selection: populates path input, returns absolute path, close without selecting
- Edge cases: empty dir, API error, manual typing still works

Also updates existing onboarding.spec.ts to match the renamed
"Browse server directories" button, and adds data-testid attributes
to DirectoryPicker and RepoAnalyzer for reliable e2e targeting.

* fix(a11y): add accessibility and UX polish to DirectoryPicker

- Add role="dialog", aria-modal, aria-label to the modal panel
- Add aria-label to close button, home button
- Add aria-hidden to decorative icons (chevrons, backdrop)
- Add role="status" to loading spinner with sr-only label
- Add role="alert" to error state
- Add aria-current="location" to active breadcrumb segment
- Wrap breadcrumb in nav landmark with aria-label
- Add Escape key handler to dismiss the modal
- Auto-focus the modal panel on open
- Add focus-visible ring styles to all interactive elements
  (matches existing focus-visible:ring-2 ring-accent/40 pattern)
- Increase breadcrumb button padding (px-1.5 py-1) for better
  touch targets
- Increase directory entry padding (py-2.5) for touch comfort
- Add active:bg-hover/70 pressed state on directory entries
- Add active:bg-accent/80 pressed state on select button

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

* fix: skip traversal guard for bare root paths in /api/fs/list (#2109)

* fix(web): replace server-side directory picker with secure folder upload

PR #1850 review found the new GET /api/fs/list directory-browsing endpoint
enumerated any absolute server path (CodeQL js/path-injection, plus a DoS and
cross-origin enumeration via the CORS/PNA allow-list). Browsers can't hand the
server an absolute path, so rather than harden the endpoint, remove it and
upload the folder instead — webkitdirectory exposes the file contents.

- Add POST /api/analyze/upload: busboy-streamed multipart ingest into an
  mkdtemp sandbox under UPLOAD_ROOT with resolve-then-contain write
  sanitization, hard size/count/dir caps, manifest-first ordering, and
  guaranteed cleanup; promote (atomic same-filesystem rename, no EXDEV) and
  analyze via the shared job/worker machinery, never returning a server path.
- Frontend: <input webkitdirectory> upload flow with client-side filtering
  (.git/node_modules/build), XHR progress, accessibility, en/zh-CN i18n.
- Remove /api/fs/list + handleFsListRequest, DirectoryPicker, listDirectories
  and their tests.
- Harden the adjacent /api/analyze {path} route: localhost-only CORS on write
  routes + realpath/exists/isDir validation replacing the inert
  normalize!==resolve guard.
- Extend DELETE /api/repo cleanup to upload dirs (by entry.path) and add a
  startup sweep for orphaned staging dirs.

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

* fix(review): resolve CodeQL path-injection + CSRF introduced by the upload change

The first push surfaced two new CodeQL alerts in the newly-added code (the
upload sandbox itself passed — its resolve-then-contain sanitizer is recognized):

- HIGH js/path-injection at the analyze route: the KTD11 in-route
  `fs.realpath(repoLocalPath)` / `fs.stat` was a user-controlled filesystem
  read with no security gain (the worker already reads the path; cross-origin
  reach is closed by requireLocalhostOrigin). Drop the in-route fs calls; keep
  only the absolute-path check + the localhost-origin guard.
- MEDIUM js/client-side-request-forgery: the new raw `xhr.open` was a fresh
  request sink. Route the upload through the shared, origin-validated
  fetchWithTimeout instead (the centralized sink all other calls use). Trades
  the upload-progress percentage for an indeterminate "Uploading…" state.

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

* fix(review): resolve tri-review findings on the upload flow

A multi-agent review of the upload implementation surfaced a P0 plus several
P2/P3s; all are addressed here.

- P0: the upload handler took the single analysis slot (createJob) before
  validating/promoting, so any failure in that window left a queued job that
  was never failed — wedging ALL analysis until restart (trivially triggered by
  a single-segment manifest). Now: validate the folder before taking the slot,
  release it via failJob on any pre-launch error, and reject single-segment /
  multi-top manifests during ingest (also fixes a silent file-drop).
- CI: rate-limit.test's source-regex broke when Prettier wrapped the
  /api/analyze registration; made it wrapping-tolerant.
- Resource: the startup sweep now also removes stale promoted upload dirs with
  no .gitnexus index (orphans from analyses that failed before registering).
- Frontend: guard against post-unmount SSE opening, reset upload state on
  cancel/mode-change, guard concurrent uploads, fall back to the folder name,
  add aria-busy, and fix the {{count}} plural ("1 files").
- Maintainability: extract launchAnalysisWorker into analyze-launch.ts (DI +
  typed WorkerMessage IPC), move requireLocalhostOrigin to middleware.ts, share
  REPO_NAME_PATTERN, tighten UploadJobRef, name the collision-retry constant.

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

* fix(web): reset isMountedRef on mount (StrictMode double-invoke)

The mount effect set isMountedRef=false on cleanup but never back to true on
re-mount, so under React StrictMode's mount->unmount->mount the ref stayed
false for the component's lifetime — trackJob then always early-returned and
the upload never advanced past 'starting' (caught by the folder-upload e2e).
Set it true at the start of the effect.

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

* fix(review): de-flake upload-ingest cleanup test via injectable staging root

ingestUpload gains an IngestOptions.root override (mirroring SweepOptions.root)
so the test asserts cleanup against a per-test mkdtemp root instead of counting
global ~/.gitnexus/uploads/.staging-* entries, which raced parallel forks.
Production default stays UPLOAD_ROOT (promote rename same-filesystem invariant).

* fix(web): make stale analyze/upload requests inert after mode switch, cancel, or unmount

A folder upload (or URL analyze) still in flight when the user switched modes
could resolve later, call trackJob(), and drive the old job's SSE stream under
the new mode's form. The only guard was isMountedRef — mode change and cancel
never unmount the component.

- requestControllerRef: per-request AbortController doubling as the staleness
  token (captured per closure, checked after the await; the abort error is
  matched via signal.aborted, never error identity, since it surfaces both as
  BackendError('Request aborted') and as a raw AbortError from response.json())
- uploadFolder() now takes an optional AbortSignal; fetchWithTimeout already
  merges caller signals via AbortSignal.any
- a stale-but-created job gets a fire-and-forget cancelAnalyze(jobId) (skipped
  when a live tracking session owns the id) so the single analyze slot is freed
- handleModeChange early-returns on same-tab clicks and resets phase to input
  so an aborted request can't strand the form at 'starting'
- fixed the stale breaker comment: resilientFetch records AbortError as
  breaker-neutral (recordNeutral), not as a retryable-network penalty

* refactor(web): consolidate stale-request guard plumbing

- single invalidateRequest() helper for the abort+null pattern (4 sites)
- drop isMountedRef checks subsumed by the aborted-controller token
  (unmount aborts the controller, and unlike isMountedRef the token stays
  correct across a StrictMode unmount/remount)
- dedup the component test's render/mock scaffolding
- countStaging filters on the exported STAGING_PREFIX, not a magic string

* fix(web): scope stale-job cancellation to the upload path

Code review caught a regression in the first cut: URL analyzes dedup-alias by
repo (createJob returns the existing active job's id), so a stale resolution's
fire-and-forget cancel could kill a job another session — or the user's own
fresh resubmit — is actively watching; the jobIdRef ownership guard was
order-dependent and instance-local. Uploads always own a fresh, never-deduped
job, so the cancel is kept (unconditionally) there and dropped on the URL path,
where a same-URL resubmit re-attaches via dedup and the server's job timeout /
TTL sweep bounds the slot occupancy.

Also: remove the isMountedRef machinery outright (zero readers remain — the
aborted-controller token subsumes it and stays correct across StrictMode
remounts), make the e2e abort check ERR_ABORTED-specific, and let a broken
test root fail loudly instead of passing vacuously.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Sparsh <73558748+prajapatisparsh@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:50:59 +01:00
Gergő MagyarandClaude Opus 4.8 5bf8a17cd5 feat(ingestion): add control-flow-graph layer for TS/JS (#2081) (#2099)
* feat(cfg): language-agnostic CFG construction core (#2081)

U1 of M1 (CFG layer). Plain JSON-serializable CFG data model (BasicBlockData/
CfgEdgeData/FunctionCfg — must survive the worker→main boundary + ParsedFile
store), a CfgBuilder accumulator (leaders→blocks→edges, synthetic ENTRY/EXIT,
idempotent edges), a ControlFlowContext (break/continue/switch + labeled-jump
target stacks), and a TraversalResult ({entry, dangling exits}). AST-agnostic
and unit-tested on the classic control-flow topologies (if/else, while back-edge,
mid-block return, labeled break/continue) the S2 spike validated; reachability
helper backs the R9 property test.

* feat(ingestion): U2 — TS/JS CFG visitor over tree-sitter AST (#2081)

Add the TS/JS CfgVisitor that walks a function's tree-sitter AST and drives
the U1 CfgBuilder to produce a serializable FunctionCfg. One visitor covers
both languages (shared grammar family).

Handles the classic CFG hazards explicitly (R2, R10):
- loops allocate a dedicated loop-exit block so `break` has a concrete target
  before the loop's successor is known; `continue`/back-edge close the loop
  (while, do-while, C-for with init-once + increment-as-continue-target,
  for-in, for-of)
- switch fallthrough falls out naturally: a non-breaking case yields exits we
  wire to the next case as `fallthrough`; a breaking case wires to the switch
  exit via ControlFlowContext
- try/catch/finally: normal completion AND exceptional flow both route through
  finally (post-domination); a conservative exceptional edge models that the
  protected region may raise to its handler (not just explicit `throw`)
- labeled break/continue resolve against the labeled loop's frame
- early return/throw wire to EXIT/handler and terminate their block

19 hazard tests (one per construct) + AC1 10-function fixture; all green.
No change to the committed U1 core or ControlFlowContext.

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

* feat(ingestion): U3 — worker CFG build + cfgSideChannel + cache coherence (#2081)

Run the CFG visitor in the parse worker (where the AST lives), serialize the
per-function CFG onto a new ParsedFile.cfgSideChannel, and keep it coherent
across the disk-backed store and the warm/durable parse cache (R3, R4).

- gitnexus-shared parsed-file.ts: add `cfgSideChannel?: unknown` as a DISTINCT
  field from captureSideChannel (different producer/consumer/lifecycle; plain
  JSON data — blocks/edges deliberately lack the `nodeId` the store's interning
  reviver keys on, so no mis-interning).
- cfg/types.ts + visitors/typescript.ts: add CfgVisitor.isFunction so the worker
  enumerates functions (and applies the line budget) by a cheap node-type test.
- cfg/collect.ts (new): collectFunctionCfgs walks the tree, builds one CFG per
  function (nested included), applies maxFunctionLines (over-cap = skipped).
- language-provider.ts: add `cfgVisitor?: CfgVisitor<SyntaxNode>` hook;
  typescript.ts attaches it to both the TS and JS providers (shared grammar).
- parse-worker.ts: read pdg + pdgMaxFunctionLines from workerData (read once at
  init — the worker never sees PipelineOptions), gate the build, attach
  cfgSideChannel alongside captureSideChannel.
- parse-cache.ts: bump SCHEMA_BUMP 4→5 (ParsedFile shape changed) and fold the
  pdg flag into computeChunkHash so a pdg-off cached chunk is NOT reused on a
  --pdg run (the #2038-class warm-cache trap). Default path keeps its keys.
- worker-pool.ts + parse-impl.ts + pipeline.ts: thread pdg/pdgMaxFunctionLines
  PipelineOptions → WorkerPoolOptions → workerData, and into the chunk-hash key.

9 boundary tests: collect contract, JSON round-trip identity (no AST leakage),
the pdg cache-key guard, the line-cap skip, and the no-visitor gate. Full CFG
suite (U1+U2+U3) green; build clean.

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

* feat(ingestion): U4 — emit BasicBlock + CFG within scope-resolution (#2081)

Emit persisted BasicBlock nodes + CFG edges from each ParsedFile's worker-built
cfgSideChannel, INSIDE scope-resolution's Phase-4 graph emission — the last
point where the worker-built CFGs are loaded (emitParsedFiles carries the
channel; the disk store is cleared right after the orchestrator returns). This
is the architecture the doc-review corrected to: a standalone post-`mro` phase
(the issue's literal subtask) provably reads empty data (KTD1).

- cfg/emit.ts (new): pure emitFileCfgs(graph, cfgs, maxEdgesPerFunction, onWarn).
  BasicBlock id = `BasicBlock:<filePath>:<functionStartLine>:<blockIndex>`
  (KTD3 — funcStart disambiguates blocks across functions in one file; no
  `name` column). CFG edge = CodeRelation type 'CFG' with the edge KIND
  (seq/cond-true/…) in `reason` (kinds can't be their own edge type). Per-
  function edge cap stops at the cap and warns with the dropped count — no
  silent truncation (R6/KTD6).
- run.ts: pdg-gated emit pass over emitParsedFiles after emitPostResolutionEdges
  (store still live); RunScopeResolutionInput gains pdg + pdgMaxEdgesPerFunction.
- phase.ts: thread ctx.options.pdg / pdgMaxEdgesPerFunction into the call.
- pipeline.ts: PipelineOptions.pdgMaxEdgesPerFunction.

6 tests: node/edge shape (KTD3 id, no name, type='CFG', kind in reason),
cross-function id uniqueness, AC2 reachability-from-ENTRY property, the edge
cap's no-silent-truncation contract, and empty-input no-op. Flag-off
byte-identity + full runPipelineFromRepo round-trip land in U7. Build clean.

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

* feat(cli): U5 — `--pdg` opt-in plumbing (CLI + .gitnexusrc → both sinks) (#2081)

Expose the CFG/PDG substrate as an opt-in and thread it from CLI/.gitnexusrc to
the single source of truth (PipelineOptions.pdg), which fans out to BOTH sinks
already wired in U3/U4: the worker build gate (workerData.pdg) and the
scope-resolution emit gate. Off by default (R7).

- cli/index.ts: `--pdg` commander flag.
- cli/analyze.ts: AnalyzeOptions.pdg + pass `pdg` into runFullAnalysis options.
- cli/analyze-config.ts: KEY_SPECS `pdg` (boolean) so `.gitnexusrc { "pdg": true }`
  normalizes and a non-boolean value fails closed with GitNexusRcError.
- core/run-analyze.ts: AnalyzeOptions.pdg → runPipelineFromRepo({ pdg }).

(The internal PipelineOptions/WorkerPoolOptions/workerData fields + the
parse-cache key fold landed in U3/U4; this unit adds the user-facing surface.
The budget knobs stay at internal defaults for M1.)

Tests: analyze-config pdg normalization + non-boolean rejection; opt-in.test.ts
covers the CLI/file merge precedence and that pdg perturbs the chunk-dispatch
key. The full worker-build + main-emit round-trip is the U7 integration test.

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

* test(ingestion): U7 — CFG acceptance fixtures, parity, end-to-end + docs (#2081)

Acceptance criteria for the M1 CFG layer:
- AC1: a 10-function TS fixture's CFG node/edge set matches a committed snapshot
  (cfg-snapshot.test.ts).
- AC2: every BasicBlock is reachable from its function ENTRY (property test over
  the emitted graph; the fixture has no dead code).
- AC3: hazard fixtures lock the classic-bug coverage — try/throw/finally
  post-domination + labeled break/continue resolution.
- AC4: the existing pipeline-graph-golden test stays byte-identical with --pdg
  off (verified; no UPDATE_GOLDEN), proving the opt-in adds zero default-run
  drift.
- End-to-end (pipeline-pdg.test.ts): runPipelineFromRepo({ pdg: true }) on a
  tiny repo emits BasicBlock nodes + CFG edges with both endpoints present —
  the true both-sinks proof (worker builds → store → scope-resolution emits);
  the default run emits zero.

Docs: CHANGELOG M1 entry, ARCHITECTURE "Optional CFG/PDG emission" subsection
(why emit is in-phase, not post-mro), README CFG language-support note.

Full CFG suite (U1–U7): 56 tests green.

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

* test(ingestion): drop unused helper in cfg-snapshot test (#2081)

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

* fix(review): apply ce-code-review autofix feedback (#2081)

Review (10 reviewers) confirmed OFF-path byte-identity (adversarial + golden)
and found defects all within the --pdg path. Fixes:

- P1 same-line BasicBlock id collision: add a start-column disambiguator to
  FunctionCfg + the id (`BasicBlock:<file>:<line>:<col>:<idx>`) so two functions
  sharing a start line no longer collide under first-writer-wins addNode.
- P1 worker crash-cascade: per-file try/catch around collectFunctionCfgs so a
  CFG-build throw cannot escape to the language-group catch and silently drop
  every remaining file in the group.
- P2 edge-cap drop now logs unconditionally (input.onWarn is validator-gated/
  silent in prod) — upholds the no-silent-truncation guarantee.
- P2 Array.isArray guard before the cfgSideChannel cast in run.ts.
- P2 maxFunctionLines default: worker applies DEFAULT_PDG_MAX_FUNCTION_LINES=2000
  when unset; caps forwarded through run-analyze AnalyzeOptions (closes the
  server-path drop).
- P3 README duplicate paragraph removed; `0`-vs-default docstrings corrected;
  CLI --pdg flag made language-neutral; reachableBlocks JSDoc corrected.
- Documented the break-through-finally + stacked-label CFG limitations.
- Tests: same-line id-collision regression, standalone throw→EXIT, dead-code-
  after-return, async/generator/method coverage, strengthened labeled-continue.

Refuted: the HTTP-500 getNodeQuery finding — M0 already shipped the BasicBlock
branch + name-floor (R12/web-safety handled).

CFG + analyze-config suites: 95 tests green; golden parity (AC4) byte-identical.

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

* perf(ingestion): benchmark CFG construction + O(n) block-text accumulation (#2081)

Closes the M1 review's requires_verification perf gap ("no benchmark for
collectFunctionCfgs; a wall-time + cfgSideChannel byte-size regression gate
would catch the extendBlock concatenation before kernel scale").

- bench/cfg/measure.mjs (new): build-free tsx harness timing collectFunctionCfgs
  (parse once, reuse the tree) across three scaling scenarios — straight-line
  (extendBlock path), many-functions (collect walk), branchy (block/edge growth)
  — at 500→2000. Reports a wall-time scaling ratio AND a cfgSideChannel
  byte-size ratio, plus an order-independent sha256 over the emitted blocks/edges
  as the behavior gate. `--check` compares both ratios + the fingerprint against
  bench/cfg/baselines.json; mirrors the scope-capture / python-scope harnesses.
- .github/workflows/ci-tests.yml: run the gate on every test job (build-free,
  alongside the existing scope-capture guards) so an O(n^2) re-regression fails CI.
- cfg-builder.ts: structural fix for the one real hotspot the bench surfaced —
  accumulate basic-block text as fragments joined once in finish(), instead of
  concatenating onto a growing string per coalesced statement (O(n^2) → O(n)).
  Behavior-identical (the CFG fingerprint + the AC1 snapshot are unchanged).

Measured (post-fix): time ratios straight-line ~1.3, many-functions ~1.0,
branchy ~1.1 (all sub-quadratic; a true O(n^2) would be ~4.0). cfgSideChannel
bytes scale linearly (~1.0-1.04). 60 CFG tests green; build clean.

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

* perf(ingestion): add memory + disk growth gates to the CFG benchmark (#2081)

Extend bench/cfg/measure.mjs beyond wall-time to the two other scalability
dimensions that matter at kernel scale:

- DISK growth: utf8 byte size of the serialized cfgSideChannel — exactly what a
  --pdg run writes onto every ParsedFile shard (durable store + parse cache).
- MEMORY growth: retained JS heap of the cfgSideChannel payload, measured by the
  release-delta method (heap held minus heap after dropping it) — robust to
  pre-existing garbage and dead-stable run-to-run. Needs `node --expose-gc`;
  without it the heap metric is null and its gate is skipped (local runs still
  work). ci-tests.yml now passes --expose-gc so the heap gate runs in CI.

Both gated on linear scaling in baselines.json (disk_bytes_budget / heap_budget
1.2-1.3). Measured: disk ~1.0-1.04, retained heap ~0.87-1.0 — both linear
(~1KB/function each; ~2MB heap / 1.6MB disk at 2000 functions, --pdg only).
Bumped REPS 7->15 to stabilize the noisier time signal and widened the coarse
time tripwire budgets (the disk/heap gates carry the tight regression detection).

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

* fix(ingestion): address tri-review + CFG-expert findings (#2081)

Corroborated findings from the tri-review (Codex + CE personas + GitNexus swarm
+ a CFG/program-analysis domain-expert lane). The OFF-path stays byte-identical;
all fixes are within the --pdg path or the benchmark.

- [Codex+CFG-expert] Exceptional `throw` edges now wire EVERY block in a try's
  protected region to the handler, not just the body ENTRY. A branched try body
  (`try { if (x) { use(t); } } catch`) previously left interior blocks with no
  path to `catch` — a taint false-negative into the handler for the M2 PDG pass.
- [Codex+CFG-expert] An unresolved labeled jump (a stacked outer label or a
  labeled non-loop block) now routes to the function EXIT instead of leaving a
  dangling sink — restores the single-exit invariant post-dominator/PDG
  computation needs.
- [Codex] computeChunkHash now folds pdgMaxFunctionLines/pdgMaxEdgesPerFunction
  into the chunk key (not just the pdg boolean), so a warm cache built under one
  cap is never served to a run with a different cap (#2038 class, extended to
  the budgets). Adds PdgCacheKey; boolean form kept for back-compat.
- [perf] visitTry resolves catch/finally in a single namedChild pass (the double
  `namedChildren.find` allocated two throwaway arrays).
- [adversarial] The bench `straight-line` scenario now runs at 2000->8000:
  output is a constant 4 blocks so disk/heap can't see the concat path, and at
  the old N a genuine O(n²) was masked by V8 cons-strings. Verified at the new N:
  the array-join impl ~1.0, a rope-optimized `+=` ~1.0 (correctly not flagged),
  a real O(n²) (re-join-every-append) ~3.8 — budget tightened 2.0->1.5.
- [adversarial+Codex] The bench `--check` now FAILS LOUDLY when run without
  `--expose-gc` instead of silently skipping the retained-heap gate.
- Doc: re-labeled the finally-bypass as a SOUNDNESS (false-negative) limitation
  tracked for M2, not mere "precision."

3 new regression tests (branched-try interior→handler, stacked-label→EXIT,
cap-fold key). 99 CFG tests pass; build clean; bench gate green.

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

* docs(parse-cache): clarify that SCHEMA_BUMP still invalidates caches once (#2099 F6)

The computeChunkHash comment claimed pdg-off warm caches "survive this
change untouched" — true for the key FORMAT, but misleading as an
upgrade-behavior promise: SCHEMA_BUMP 4→5 changes PARSE_CACHE_VERSION
and both stores hard-invalidate on it. Separate the two facts so the
next cache change isn't reasoned about from a false premise.

Review finding F6 (P3) of PR #2099 tri-review.

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

* fix(cfg): correct for-loop back-edge kinds when no increment clause (#2099 F5)

A for with a body but no increment emitted an unconditional
header→header 'loop-back' self-edge (a path that never executes the
body) while the real back-edge body→header was labeled 'seq'. Any
consumer identifying loops via reason='loop-back' picked the phantom
edge and excluded the body from the natural loop.

Gate the self-edge on the body being absent (the one case where the
header genuinely re-tests itself) and carry 'loop-back' on the body's
exits when they ARE the back-edge, matching visitWhile/visitForIn.

Review finding F5 (P3) of PR #2099 tri-review.

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

* fix(cfg): treat an empty catch clause as a real handler (#2099 F2)

visitTry keyed handler semantics off the traversal result — null for an
empty body, since visitSeq([]) returns null — instead of the syntactic
clause. An empty `catch {}` was therefore treated as NO catch: the
swallowed exception escaped to the outer handler/EXIT, the no-catch
re-propagation misfired past finally, and code after a try whose body
always throws became unreachable from ENTRY — a hard false-negative
source for the M2 taint pass, on an extremely common pattern.

Synthesize one empty block spanning the clause (entry == sole exit)
when the catch body traverses to null, before the protected region is
walked. Exception flow lands in it and rejoins the normal continuation;
all downstream wiring (handler selection, finally routing, the !catchRes
re-propagation gate) operates on the syntactically-correct shape.

Review finding F2 (P2, reproduced) of PR #2099 tri-review.

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

* fix(cfg): guard CFG emission per element, not just per outer array (#2099 F4)

The cfgSideChannel guard checked only Array.isArray before casting to
FunctionCfg[] — its own comment promised a wrong-shape value would
'skip emission, not throw a TypeError mid-graph-build', but a malformed
ELEMENT sailed through. Worse, the obvious-looking failure shape never
throws at all: emitFileCfgs string-templates any edge endpoint into the
BasicBlock id and graph inserts are no-throw, so a non-integer endpoint
silently became a dangling 'BasicBlock:…:undefined' edge that degrades
the DB rel-pair COPY to row-by-row fallback inserts much later.

Layered fix matching house precedents (parsedfile-store reviver,
worker-side per-file catch): a per-element shape+content predicate
(arrays + integer edge endpoints) that warns and skips malformed
elements while valid siblings still emit, plus a per-file try/catch
backstop for shapes that genuinely throw (e.g. a null inside blocks).

Review finding F4 (P3) of PR #2099 tri-review.

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

* fix(parse-cache): drop emit-time edge cap from the pdg chunk key (#2099 F3)

pdgMaxEdgesPerFunction is applied exclusively in emitFileCfgs during
scope-resolution on the main thread — the worker never receives it
(workerData carries only pdg + pdgMaxFunctionLines), so the cached
worker output is byte-identical across cap values. Folding it into the
chunk key (added by a prior review round) only converted a free knob
into a repo-sized cost: every cap change forced a full re-parse and a
durable-store rewrite of unchanged data.

Keep pdg + maxFunctionLines (genuinely worker-visible, shape the cached
cfgSideChannel) and document the classification test in the PdgCacheKey
doc comment so the next option gets sorted deliberately: worker-shard
inputs go in this key; persisted-graph-only inputs belong in the
RepoMeta pdg stamp (F1). Chunks written under the old ns string miss
once and prune — no migration needed.

Review finding F3 (P2) of PR #2099 tri-review.

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

* fix(analyze): record pdg config in RepoMeta; force full writeback on mode flip (#2099 F1)

Running --pdg against an already-indexed repo silently persisted ~zero
CFG: incremental eligibility had no pdg term, RepoMeta recorded no
mode, and extractChangedSubgraph keeps only changed-file nodes — on a
no-change --pdg re-run every freshly built BasicBlock was dropped from
the written subgraph ('Incremental: changed=0', run succeeds, zero
rows). The converse flip left zombie mixed-coverage blocks only --force
could clean. Worse, a clean-tree flip hit the alreadyUpToDate fast path
and never ran the pipeline at all.

- RepoMeta gains an additive-optional pdg stamp ({maxFunctionLines,
  maxEdgesPerFunction}, resolved values; absent ≡ pdg-off, which covers
  every legacy meta). No INCREMENTAL_SCHEMA_VERSION bump — that would
  force a one-time full rebuild for everyone. The end-of-run meta is a
  fresh literal, so omitting the field on a pdg-off run is what clears
  the stamp after an on→off flip.
- pdgModeMismatch (pure, exported) compares the resolved triple; the
  flip check sits before the fast path and always logs its notice (not
  gated on options.force — --skills implies force with no message of
  its own), naming the .gitnexusrc pdg key that pins the mode.
- The full-rebuild branch now writes the incrementalInProgress dirty
  flag (toWriteCount: 0 sentinel) before the wipe whenever a prior meta
  exists, mirroring the incremental branch. This closes the crash
  window where a rebuild dying between the bulk load and saveMeta left
  meta/DB inconsistent and the fast path certified zombie (or missing)
  CFG rows indefinitely — and incidentally closes the same pre-existing
  hole for user --force runs. Recovery log reworded accordingly.

Tests: pdg-mode-flip.test.ts (real git + LadybugDB; primary assertion
is a direct BasicBlock table count — meta.stats aggregates
nondeterministic Community/Process rows) covering off→on, steady-state
fast path, on→off zombie cleanup, cap-change rebuild, and dirty-flag +
flip composition; pure-helper tests for default resolution and the
0=unlimited carve-out.

Review finding F1 (P1) of PR #2099 tri-review.

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-06-10 19:26:45 +01:00
azizur100389andGergő Magyar e26002c37a fix(cpp): suppress deleted overload winners (#2094)
* fix(cpp): suppress deleted overload winners

* test(cpp): update scope capture fingerprint

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-10 18:41:30 +01:00
31a2b19416 fix(storage): prevent registry wipe on transient I/O errors (#2124)
* fix(storage): prevent registry wipe on transient I/O errors

listRegisteredRepos({ validate: true }) used a bare catch {} that
treated ALL fs.access() errors as 'index gone.' Under swap pressure
or I/O storms, EIO/EAGAIN/EBUSY/EACCES errors caused ALL entries to
be pruned and writeRegistry([]) was called — permanently wiping the
registry.

Fix: only prune on ENOENT (file genuinely gone) or ENOTDIR (structural
removal). Transient errors keep the entry alive.

Includes 5 regression tests covering ENOENT, ENOTDIR, EACCES, EIO,
and EAGAIN.

* test(storage): point registry transient-error test at the right PR (#2124)

The describe() title cited #2121, which is the unrelated prebuildify CI
fix (drop broken -t 22 from prebuildify), not the registry-wipe bug. No
dedicated issue exists for this fix, so reference PR #2124 instead so
git blame / bisect readers land on the actual change.

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

* test(storage): remove unused os import (CodeQL alert 693)

The os import was never referenced. Removes the code-scanning
unused-import alert and the PR autofix finding.

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

* test(storage): cover partial prune, on-disk persistence, and EBUSY

The original bug was about *persisting* the wrong registry list, but the
tests only checked the in-memory return value of a single-entry registry.
Add coverage for the paths that actually exercise persistence:

- mixed-batch partial prune: register two repos, fail one with ENOENT and
  the other with EIO in the same validation call, then read registry.json
  off disk and assert exactly the EIO survivor was persisted (not [] from
  over-prune, not both from a no-op). This is the off-by-one path.
- assert the on-disk registry is unchanged in the EACCES/EIO/EAGAIN keep
  tests (the keep path must not rewrite/shrink the file).
- assert the ENOENT prune is persisted ([] written) as a regression guard.
- add the EBUSY keep case named in the source comment but previously
  untested.

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

* docs(storage): clarify the keep-branch comment (EACCES may be permanent)

The previous comment called EACCES "transient," but EACCES is often
permanent (e.g. a chmod'd directory). Reframe the comment around the
actual decision rule — prune only when the index is provably gone
(ENOENT/ENOTDIR), keep on everything else — and note that keeping a
possibly-permanent error is still the correct conservative choice
(a stale entry is harmless and removable; an over-prune destroys data).
Comment-only; behavior unchanged.

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

* feat(storage): warn when keeping a registry entry on a non-fatal fs error

The keep branch was silent, so an I/O storm that keeps entries alive (the
whole point of the fix) was invisible in logs. Emit a structured
logger.warn naming the entry and the fs.access error code on the keep
path only. Observability-only: the keep/prune decision is unchanged and
the warn cannot throw.

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

* docs(storage): describe listRegisteredRepos validate semantics accurately

The doc comment said validation checks each entry's .gitnexus/ "still
exists," which no longer matches the keep-on-transient behavior. Spell
out that validation prunes only provably-gone indexes (ENOENT/ENOTDIR)
and keeps entries that are merely not provably absent — so a kept entry
is "not confirmed present," not "confirmed present."

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

* style(storage): prettier-format the transient-error test imports

Collapse the multi-line repo-manager import to a single line per Prettier,
clearing the PR autofix formatting finding. Formatting-only.

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

---------

Co-authored-by: buihongduc132 <buihongduc132@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:50:08 +01:00
Gergő MagyarandClaude Opus 4.8 2870aa6248 fix(grammars): load vendored tree-sitter grammars from vendor/ by absolute path (#2111) (#2144)
* fix(grammars): load vendored tree-sitter grammars from vendor/ by absolute path (#2111)

The recurring Windows `EPERM: operation not permitted, symlink` (errno -4048)
when adding the MCP server to Antigravity is NOT the #2101/#2110 module-load
crash — it is an install-time arborist failure during the `_npx` reify that the
MCP client triggers on every `npx gitnexus` launch.

Root cause: the `postinstall` materialize step copied each vendored grammar
(`vendor/tree-sitter-{c,dart,proto,swift,kotlin}`) into
`node_modules/gitnexus/node_modules/tree-sitter-*` as a real package so runtime
`require('tree-sitter-dart')` would resolve. Those packages are in no dependency
graph, so every subsequent npm/npx reify treats them as **extraneous** and
prunes/relocates them — on Windows the relocation goes through
`@npmcli/move-file`'s symlink path and throws EPERM (symlinks need Developer
Mode/admin), and on every OS the 2nd run silently deletes the grammars. This is
the same class as #1728, which the materialize step itself claimed to have
fixed.

Fix (the prebuildify + node-gyp-build ecosystem pattern): never copy grammars
into node_modules. Load each by absolute path from `vendor/<name>` via the new
`requireVendoredGrammar` helper — the grammar's own `bindings/node` runs
`node-gyp-build(<dir>)` and loads the committed `vendor/<name>/prebuilds/
<platform>-<arch>/…` directly (all 5 ship all 6 tuples). vendor/ is inside the
package but not a node_modules subtree, so arborist never sees the grammars and
the reify is idempotent — no EPERM, no silent deletion.

- new src/core/tree-sitter/vendored-grammars.ts (requireVendoredGrammar /
  vendoredGrammarDir / VENDORED_GRAMMAR_PACKAGES; VENDOR_ROOT stable in dev+dist)
- route all consumers through it: parser-loader, parse-worker, grpc proto,
  include-extractor (C), http-patterns kotlin, cli optional-grammars probe
- postinstall drops the materialize step; build-tree-sitter-grammars.cjs builds
  in-place under vendor/ (gitignored) and deletes materialize-vendor-grammars.cjs
- tests + grammar-introspection helper load grammars from vendor/ too (single
  source of truth); new vendored-grammars.test.ts guards against reintroducing a
  bare `require('tree-sitter-<vendored>')`

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

* fix(grammars): throw on a non-vendored name in requireVendoredGrammar

Drift guard (PR #2144 review, P3): validate the argument against
VENDORED_GRAMMAR_PACKAGES and fail loudly on an unknown name, so the three
grammar lists (package set / CLI probe / build registry) drifting out of sync
surfaces as a clear error instead of a confusing absolute-path require miss.

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

* fix(grammars): prepack guard against stray vendor/<g>/build/ shadowing prebuilds

Publish hygiene (PR #2144 review, P2). Now that build-tree-sitter-grammars.cjs
source-builds into vendor/<name>/build/, a stray build dir would ship in the
tarball (files:["vendor"] overrides .gitignore/.npmignore) AND shadow the
committed prebuild — node-gyp-build resolves build/Release before prebuilds/.
assert-publish-grammar-coverage.cjs (prepack) now fails `npm pack` if any
vendor/*/build exists (findStrayBuildArtifacts), with a clear `rm -rf` fix hint.
Adds unit coverage for the new pure function.

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

* test(grammars): harden the #2111 no-bare-require regression guard

PR #2144 review (P2). The guard regex missed dynamic import(), side-effect
`import 'x'`, /subpath, and backtick loads, and only scanned src/. It now covers
every node_modules-forcing form (single/double/backtick quotes, optional
subpath), scans test/ too (excluding fixtures and the guard file itself), drops
the `//`-substring false-negative (leading-comment-only heuristic), and adds a
self-test asserting every load form is caught while prose mentions and
tree-sitter-cpp are ignored.

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

* docs(grammars): correct stale vendored-grammar comments

PR #2144 review (P3). kotlin/query.ts called tree-sitter-kotlin an
"optionalDependency" — it is vendored and loaded from vendor/ by absolute path
(#2111). proto.ts now states its remaining `_require` is only for the real
`tree-sitter` dependency, not a vendored grammar (which goes through
requireVendoredGrammar). Comment-only; no behavior change.

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-06-10 14:20:42 +01:00
Gergő MagyarandClaude Opus 4.8 3d30b94c46 fix(parse): survive non-cloneable worker results so large-repo analyze doesn't crash (#2112) (#2135)
* fix(parse): survive non-cloneable worker results so large-repo analyze doesn't crash (#2112)

A parse worker delivers its accumulated result to the main thread via
postMessage, which structured-clones the payload synchronously on the
worker thread and throws a DataCloneError on the first value it can't
serialize. The reporter's case was a node `properties` value pointing at
a native `toString`. The worker re-posted the throw as {type:'error'},
the pool counted it as a worker death, and under
GITNEXUS_WORKER_POOL_SIZE=1 the same graph re-threw on every respawn
until the slot's budget was exhausted and the whole parse phase aborted
-- defeating even the conservative single-worker workaround.

Add a clone-safety net at the worker result boundary. On a clone failure
the worker isolates the offending file, strips the non-cloneable value
from a plain extraction record (keeping the record -- strictly-missing
data, never wrong) or drops a whole ParsedFile so scope-resolution
re-derives it on the main thread with intact edge data, records the
affected paths on the result, warns naming the field + file so the leak
is diagnosable, and re-posts. Healthy runs are byte-identical: the net
runs only after a real DataCloneError, so there is zero overhead on the
fast path. Skipped paths surface via the parsing processor alongside the
skipped-language telemetry. The strip drops the same values the store
path's JSON.stringify already silently removes, so store/no-store runs
converge.

Scope: PR-1 -- failure mode C, the deterministic POOL_SIZE=1 killer. The
timeout/native-abort graceful-degradation cascade (failure modes A & B)
is coupled to downstream-exclusion + a hard worker watchdog and is
tracked as follow-up work.

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

* fix(parse): fail-closed clone-safety recovery + bound recursion depth (#2135 review)

The clone-safety recovery path could re-arm the #2112 worker-death cascade it
was built to prevent: in postResultCloneSafe the sanitizer call and the re-post
sat outside the try/catch, and containsNonCloneable/stripNonCloneable recursed
with a cycle guard but no depth bound. A throw inside the sanitizer (a RangeError
from a deeply-nested record, reproduced at depth >=3000) escaped to the message
handler's {type:'error'}, which under GITNEXUS_WORKER_POOL_SIZE=1 is the
respawn-budget-exhaustion abort.

Wrap the sanitizer + re-post in their own try/catch so any throw fails closed to
a primitive-only {type:'error'} deliberately, and thread a MAX_CLONE_DEPTH bound
through both scan/strip functions so an over-deep subtree is treated as
non-cloneable (dropped/undefined) instead of overflowing the stack. The
isStructuredCloneable catch-all is left broad on purpose — it bounds
structuredClone's own internal recursion in the non-plain-object probe.

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

* fix(parse): harden clone-safety against throwing getters and detached buffers (#2135 review)

Two sanitizer-defeat vectors let the re-post throw a DataCloneError again:

- A throwing getter on a record: containsNonCloneable/stripNonCloneable read
  obj[key], so a getter that throws escaped the scan/strip pass. Read defensively
  — a throwing property read is treated as non-cloneable (scan returns true,
  strip drops the property).
- A detached ArrayBuffer/TypedArray: both passed buffers/views through
  unconditionally, but structuredClone rejects a detached one, so the re-post
  threw. Route buffers/views through the authoritative isStructuredCloneable
  probe instead. No byteLength heuristic — a legitimately empty new Uint8Array(0)
  also has byteLength 0 yet clones fine, so a length check would false-positive.

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

* fix(parse): memoize stripped copies so DAG-aliased records aren't over-dropped (#2135 review)

stripNonCloneable carried a shared `seen` WeakSet and returned the ORIGINAL
(un-stripped) value on revisit. When a non-cloneable was reachable via two paths
(a DAG), the second path spliced the original function-bearing object back into
the output, so the rebuilt element failed the last-resort isStructuredCloneable
guard and the whole record was dropped as "unsalvageable" — contradicting the
"record kept, value stripped" contract.

Replace the WeakSet with a Map<object, stripped-copy>: allocate the empty copy,
memoize it before recursing into children (so cycles return the in-progress
copy), and return the memoized copy on revisit. DAG-aliased subtrees now collapse
to one shared stripped copy and are kept-and-stripped, not dropped. The array
branch moves from .map() to allocate-then-push so its identity can be
pre-inserted. Object Map/Set keys aren't identity-preserved across stripping —
acceptable because parse-result Maps are primitive-keyed.

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

* perf(parse): single-pass clone-safety scan preserving array identity (#2135 review)

makeWorkerResultCloneSafe scanned each dirty array twice — a field-level
whole-array containsNonCloneable probe, then a per-element pass — and always
reassigned the field. Fold into one per-element pass that builds the output
array lazily (copying the clean prefix only once the first dirty element
appears) and reassigns the field only when something changed. A fully-clean
array is now scanned once and keeps its referential identity; the clean prefix
of a dirty array is copied by reference. Behavior is otherwise identical
(failure-path-only code).

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

* refactor(parse): drop unused generic + pin clone-safe field names to keyof (#2135 review)

makeWorkerResultCloneSafe carried a generic `<T extends Record<string,unknown>>`
that was never load-bearing (it mutates in place and returns {skipped}), and the
call site passed untyped string-literal option sets — so renaming `parsedFiles`
or `skippedPaths` would silently disable the drop-whole / skip protection.

Drop the generic (plain `Record<string,unknown>` param) and type the option sets
at the call site as `Set<keyof ParseWorkerResult>`, so a field rename is now a
compile error. The `as unknown as Record<string,unknown>` widening stays — it's
the standard cast for a no-index-signature interface (TS rejects a single-step
`as`); the function genuinely operates structurally on the result's arrays.

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

* fix(parse): keep the per-file reason in the clone-safety skip log (#2135 review)

The processor's skipped-file warning logged only the paths, dropping the
per-file reason the worker already attached — losing the distinction between a
recoverable "stripped N value(s)" and a whole-record "dropped" entry. Format each
entry as `path (reason)` so the aggregate line carries the diagnostic detail.

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

* fix(parse): deterministic findFilePath attribution for ParsedNode (#2135 review)

findFilePath swept all child objects one level deep in Object.keys order, so a
ParsedNode could be attributed to a sibling child's path-like key instead of its
real path at properties.filePath. Check the known `properties` child first, then
fall back to the generic sweep, so node attribution is deterministic.

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

* fix(parse): zero skippedPaths in the slim cache result (#2135 review)

slimParseWorkerResultsForCache spread the worker result without clearing the
clone-safety skippedPaths telemetry, so a sanitized result persisted its skip
list into the on-disk parse-cache shard. Replay already ignores the field; zero
it (like calls/assignments/parsedFiles) to keep shards lean and the intent
explicit.

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

* test(parse): exercise real postResultCloneSafe wiring + tighten RED control (#2135 review)

The integration GREEN worker re-implemented postResultCloneSafe inline, so the
production wiring (the {type:'warning'} post + the skippedPaths append) had no
coverage, and the RED control asserted a bare .rejects.toThrow() that any
failure would satisfy.

Extract postResultCloneSafe into a side-effect-free module (post-result.ts) —
importing it from the parse-worker entry module would construct the parser, post
ready, and attach the real handler — and have the GREEN test worker import and
call the real one. Tighten the RED matcher to the actual abort contract
(/circuit breaker|consecutive failures|respawn budget|could not be cloned/),
which also documents that the raw poison result aborts via the pool's
consecutive-failure circuit breaker under POOL_SIZE=1.

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

* fix(parse): recover the clone-safety net from any post failure, not only DataCloneError (#2135 review)

The V8 structured-clone research surfaced the net's one real correctness hole:
structuredClone invokes getters, and a getter that THROWS surfaces its own error
(a RangeError, etc.) — NOT a DataCloneError (confirmed against a real
MessageChannel). postResultCloneSafe gated recovery on isDataCloneError, so such
a throw re-threw past the sanitizer and re-armed, under POOL_SIZE=1, the
worker-death cascade the net exists to prevent.

Attempt the sanitize + re-post recovery for ANY first-post failure (the sanitizer
already reads properties defensively, so a throwing getter is dropped), falling
closed to a primitive-only {type:'error'} only if the re-post still fails. Adds
an integration case: a node with a throwing getter is recovered and delivered,
not re-thrown.

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

* feat(parse): name the exact offending key path in the clone-skip diagnostic (#2135 review)

The clone-safety net's skip reason named only the array field + file ("stripped
1 value from nodes"), not the offending property key — which is precisely why
the original #2112 leak stayed unpinned. Thread a dotted key path through
stripNonCloneable (recording each stripped value's path: properties.toString,
meta.data[3], …) and surface the first few in the reason ("from nodes:
properties.toString"). Now a single log line — or the contract/strict checks —
names the leaking property, so a residual runtime escape can be fixed at source.

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

* test(parse): clone contract — a representative ParseWorkerResult is structured-cloneable (#2135 review)

Shape-regression guard: builds a representative ParseWorkerResult (typed as the
real interface) and asserts isStructuredCloneable. Typing it as ParseWorkerResult
makes adding a new boundary field a compile error here until the test is updated,
and the runtime assert catches a field whose type regresses to a non-cloneable
shape — independent of language input.

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

* feat(parse): strict-mode clone gate (GITNEXUS_STRICT_CLONE) — fail loudly instead of silent sanitize (#2135 review)

The runtime net's silent recovery in production is exactly what let the original
#2112 leak stay unpinned. Add an opt-in strict mode (GITNEXUS_STRICT_CLONE=1,
inherited by workers): on a clone failure, postResultCloneSafe THROWS with the
exact offending key path instead of sanitizing + delivering, so a leak
introduced by a future provider/extractor change fails loudly at its origin
(CI/dev) rather than being quietly stripped. Off in production, where the net
keeps the run alive.

Adds a self-contained integration case (sets the flag, asserts the poison run
rejects with the key path) and skips the synthetic-poison suite under a global
strict run (its value there is running the REAL-extractor integration tests
under strict). Wiring a strict CI lane (GITNEXUS_STRICT_CLONE=1 on a vitest
integration step) is left to the maintainer — it needs a green full-suite
verification and touches the protected workflow.

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

* fix(server): don't ship pipelineResult across the analyze-worker IPC boundary (#2112)

The forked analyze worker reports completion to the parent over
child_process IPC, which uses Node's DEFAULT 'json' serialization
(api.ts forks with no `serialization:` option). `AnalyzeResult.pipelineResult`
is populated on every successful analysis and carries `pipelineResult.graph`
— the live KnowledgeGraph closure object. Sending the raw result is wrong
three ways: (1) the graph's nodes/relationships getters force-materialize
the entire graph into two arrays and JSON-stringify them on every analyze,
discarded immediately (a multi-hundred-MB no-op on a large repo — the #2112
scenario); (2) the graph's methods are own function properties that JSON
drops silently, so a surviving graph is a husk whose forEachNode() throws far
from the cause; (3) a BigInt/circular value anywhere in the payload makes
process.send throw TypeError synchronously — caught and re-sent as
{type:'error'}, mis-reporting a SUCCESSFUL analysis (DB already written) as a
FAILURE. This is the #2112 failure family on the server path, and unlike the
parse-worker result boundary it has no clone-safety net.

The parent (api.ts) reads only result.repoName; pipelineResult's real
consumers (CLI skill generation, cli/analyze.ts) call runFullAnalysis
in-process and never cross this fork. So project the result down to an
explicit JSON-safe allowlist of scalar fields. Typed as
Omit<AnalyzeResult,'pipelineResult'> so a future non-serializable field added
to AnalyzeResult fails to compile until handled here deliberately.

Found by the #2112 cross-process serialization-boundary audit.

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

* feat(ingestion): Cloneable<T> + assertCloneable() compile-time clone-boundary guard (#2143)

The runtime clone-safety net is the production backstop; this is its
compile-time complement. The worker result is plain data except a few
`unknown`-typed sinks (a node's `properties` bag, the provider
`extractTemplateConstraints` / `collectCaptureSideChannel` hook returns) —
`unknown` lets a non-serializable value (a function, a leaked tree-sitter
SyntaxNode, …) cross the structured-clone boundary with no compile-time
guard. That is the structural hole #2112 leaked through.

`Cloneable<T>` is a homomorphic recursive mapped type that maps a function or
symbol member to `never`, so a struct carrying one is no longer assignable to
its own `Cloneable<T>`. `assertCloneable(value)` is a runtime identity (zero
cost) whose parameter is `T extends Cloneable<T> ? T : Cloneable<T>`, so a
clone-unsafe argument fails to compile, naming the offending key.

Because it is a homomorphic mapped type it preserves `interface` shapes and
`readonly` modifiers and needs NO index signature on the payload types — this
sidesteps the "closed interface is not assignable to a recursive
index-signature type" wall that blocked the original value-typed-`Cloneable`
attempt (the reason #2143 was deferred from PR #2135). The conditional
parameter type avoids the `T extends Cloneable<T>` circular-constraint error.

Tests: runtime identity contract, plus type-level @ts-expect-error assertions
(enforced by tsconfig.test.json) that a function/symbol member is rejected and
clean interface payloads are accepted. Applied to the real provider hooks in
the next commit.

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

* feat(ingestion): guard provider clone-boundary hooks with assertCloneable (#2143)

Apply the compile-time guard to the provider hooks that feed the `unknown`-typed
worker-result sinks, so a future non-serializable value in their payloads is a
compile error at the source site rather than a runtime DataCloneError at the
worker post:

- C++  extractTemplateConstraints  (CppConstraintPayload)
- C++  collectCaptureSideChannel    (CppCaptureSideChannel)
- C    collectCaptureSideChannel    (CCaptureSideChannel)
- Kotlin collectCaptureSideChannel  (KotlinCaptureSideChannel)

The C++ template-constraint adapter previously returned `unknown`; it now
returns the concrete `CppConstraintPayload | undefined` and routes its payload
through `assertCloneable`. The side-channel hooks are wrapped at their provider
wiring sites. `assertCloneable` is a runtime identity, so behavior is unchanged
(C static-linkage + C++ constraint suites stay green); the guarantee is the
type-check — src tsc now proves every nested member of those real payload trees
is structured-clone safe.

Test: type-level assertions (enforced by tsconfig.test.json) that each concrete
payload type is `Cloneable<T>`, INDEPENDENT of the provider wiring — so the
regression is caught even if the assertCloneable wrapper is later removed.
Proven non-vacuous (a function-bearing type fails the same assertion).

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

* fix(parse): scan an array's non-index own properties in the clone sanitizer (#2135 review)

structuredClone serializes an array's NON-index own-enumerable properties (e.g.
`arr.meta = fn`) and throws DataCloneError on a non-cloneable one. The clone
sanitizer's array branches iterated numeric indices only, so such an array was
waved through (containsNonCloneable returned false, makeWorkerResultCloneSafe
left the field unrewritten with skipped:[]) — the re-post then threw, fell
through to the fail-closed {type:'error'}, and re-armed the POOL_SIZE=1 cascade
the net exists to prevent.

Add isArrayIndexKey() and, in BOTH containsNonCloneable and stripNonCloneable
array branches (kept in lockstep), scan/strip the non-index own-enumerable keys
after the index loop. A cloneable non-index prop is carried onto the stripped
copy; a non-cloneable one is stripped and recorded. Not reachable from current
parse output (no extractor attaches non-index array props) — a defense-in-depth
hole closed.

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

* fix(parse): contain a throw inside the clone sanitizer instead of escaping to fail-closed (#2135 review)

findFilePath was documented "never throws" but read element properties
unguarded in its generic sweep — a throwing getter at a non-path key (or a
Proxy with a throwing ownKeys trap) threw out of makeWorkerResultCloneSafe, past
postResultCloneSafe's recovery, to the fail-closed {type:'error'} that under
POOL_SIZE=1 re-arms the cascade the net prevents. Likewise a Proxy with a
throwing getPrototypeOf trap throws inside containsNonCloneable's instanceof
checks.

- findFilePath/pathFromChild now read via safeGet (try/catch) and guard
  Object.keys, honoring the "never throws" contract.
- Each element's sanitize in makeWorkerResultCloneSafe is wrapped: a throw during
  scan/strip drops that one element (recorded as "sanitizer error") rather than
  sinking the whole result — so one pathological element can't fail-close the run.
- Corrected the makeWorkerResultCloneSafe JSDoc ("ONLY after a DataCloneError" →
  after ANY post failure, matching the caller) and documented the deliberate
  failure-path double-traversal (the non-allocating pre-scan is what preserves
  clean-element referential identity).

Tests: a throwing getter on a path-less element is stripped & delivered (not
escaped); a Proxy structural-trap element is dropped, clean siblings survive.

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

* fix(parse): add a final cloneable postcondition gate to the clone sanitizer (#2135 review)

makeWorkerResultCloneSafe rewrote only ARRAY result fields, so a future
non-array sink (a nested object / Map result field) carrying a non-cloneable
value — or an array field whose own non-index property the element loop didn't
reach — would survive the sanitizer and throw on the re-post. Add a final
`if (!isStructuredCloneable(result))` gate that strips any remaining offending
field in place, making "the returned result is structured-cloneable" a hard
postcondition independent of future ParseWorkerResult shape. Failure-path-only
and a no-op once the array loop already made the result clean (the per-field
probe short-circuits every clean field, so it adds no work or skip entries then).

Tests: a function on a non-array result field is stripped & the result becomes
cloneable; the gate adds no skip entry when the array loop already cleaned up.

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

* feat(parse): reject an `any`-typed member in the Cloneable<T> compile-time guard (#2135 review)

`Cloneable<any>` previously resolved to `any` (not `never`), so a payload with
an `any`-typed member — the most likely escape hatch, since `unknown` is already
blocked — passed `assertCloneable` with no compile error. Add an `IsAny<T>`
branch (the canonical `0 extends 1 & T` probe) as the FIRST arm so `any` resolves
to `never`, matching how `unknown` is already rejected. It must precede the
primitive arm: `any extends CloneablePrimitive` would otherwise resolve to `any`
and re-admit it.

The IsAny-first arm perturbs inference for a bare `undefined` literal argument
(T infers as `unknown` → never); real consumers pass `X | undefined` unions
(the provider hooks), which are unaffected (src tsc clean), so the runtime
identity test now uses a `string | undefined` value — the realistic shape.

Tests: an `any` member fails `assertCloneable` (@ts-expect-error, enforced by
tsconfig.test.json) and `Cloneable<any>` resolves to `never` at the type level.

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

* fix(server): type the analyze-worker IPC projection as a Pick allowlist, not Omit (#2135 review)

`AnalyzeResultIpc = Omit<AnalyzeResult,'pipelineResult'>` kept every other field
in the type — including optional ones like `isPrimaryBranch?` — so the type
advertised a field the runtime allowlist never sends, and the doc-comment's
"a future field fails to compile until handled here" only held for REQUIRED
fields. Switch to `Pick<AnalyzeResult, …the six scalar fields…>`: the allowlist
IS the type, so the projection return literal is exhaustive by construction
(omitting a key is a compile error) and a new `AnalyzeResult` field is simply
absent from the wire until deliberately added here. `isPrimaryBranch` is
intentionally excluded (nothing consumes it server-side over this fork; the
parent reads only `repoName`).

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

* refactor(parse): remove the now-dead isDataCloneError export (#2135 review)

postResultCloneSafe recovers on ANY fast-path post failure and never inspects
the error type (a throwing getter surfaces a RangeError, not a DataCloneError —
gating on the type was the original net-gap bug). isDataCloneError has no
production caller; it was only exercised by its own unit test. Remove the
function and that test block.

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

* refactor(parse): use the exported SkippedPath type in parsing-processor (#2135 review)

The clone-safety telemetry accumulator inlined `Array<{path,reason}>` — a
structural duplicate of the exported `SkippedPath`. Import and use the canonical
type so a future rename of its fields is a compile error here instead of a silent
structural drift.

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

* docs(parse): document the cloneable-return contract on the worker-boundary hooks (#2135 review)

extractTemplateConstraints and collectCaptureSideChannel return `unknown` and
feed values across the worker structured-clone boundary, but the hook contracts
didn't state the cloneability requirement — a future language implementing them
without care could leak a non-serializable value. Document that the return MUST
be structured-clone-safe and should be wrapped with assertCloneable, so the
guarantee is a compile error at the source (#2143).

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

* test(parse): assert the clone-skip telemetry surfaces in the GREEN integration case (#2135 review)

The GREEN clone-safety integration test asserted only graph content (all files
present), not that the skippedPaths / {type:'warning'} wiring its docstring
claims to cover actually fired. Capture the production logger via _captureLogger
and assert the sanitize telemetry names the offending file (poison.ts) AND the
exact stripped key path (properties.toString) — proving the worker's
skippedPaths append + the parsing-processor warning surfaced end to end.

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

* test(server): cover the IPC projection against a real KnowledgeGraph (#2135 review)

The IPC projection tests used a hand-built hostile object. Add a case that puts
a real createKnowledgeGraph (whose nodes/relationships getters would materialize
the whole graph under JSON.stringify) in pipelineResult and asserts the
projection drops it entirely — the serialized payload stays under 300 bytes
(a materialized 50-node graph would be thousands), with the scalar fields intact.

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

* test(parse): cover the unsalvageable-drop branch and the skippedPaths merge union (#2135 review)

Two untested clone-safety branches from the tri-review:

- "dropped unsalvageable": a dirty element whose stripped copy is STILL not
  structured-cloneable must be dropped, not delivered (else the re-post throws).
  Add a deterministic test (a non-plain member with a stateful getter that the
  strip-time probe sees clean but that turns into a function on the post-strip
  verification) asserting the element is dropped and the run survives.

- mergeResult skippedPaths union across sub-batches. mergeResult (and its
  appendAll helper) was module-private in the parse-worker ENTRY module, which a
  main-thread test can't import (it runs MessagePort setup). Extract it to a
  side-effect-free result-merge.ts (mirroring post-result.ts) and unit-test the
  union (including the `??=` target-init path), the skippedLanguages sum, and
  array append. parse-worker imports it back; verified the built worker still
  parses + merges via the real-worker integration path.

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

* style(parse): root-prettier format the clone-safety review-fix files (#2135 review)

Clears the failing `quality / format` CI gate (root prettier, not the
gitnexus-local config). Reformats the pre-existing #2143 wrapping lines in
c-cpp.ts + kotlin.ts plus the clone-safety review-fix files touched in this
PR-update (clone-safety.ts and the new/updated tests). Formatting-only — no
behavior change; tsc, the type-level assertions (tsconfig.test.json), and the
unit + integration suites stay green.

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

* test(parse): avoid js/trivial-conditional in the type-level clone assertions (#2135 review)

CodeQL flagged the `expect(a && b && c).toBe(true)` lines in the type-level test
assertions as js/trivial-conditional: after type erasure the operands are
constant `true`, so the `&&` chain always evaluates the same. Replace the `&&`
chain with array equality (`expect([...]).toEqual([true, ...])`) — no
conditional, and the real assertions remain the `const x: …IsNever = true` /
`: IsCloneable<…> = true` annotations (enforced by tsconfig.test.json, which
fail to compile if a guard regresses).

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-06-10 13:47:22 +01:00
Gergő MagyarandClaude Opus 4.8 7f0ab87782 fix(embeddings): resolve onnxruntime-common under pnpm-strict / pnpm dlx (#307) (#2139)
* fix(embeddings): resolve onnxruntime-common under pnpm-strict / pnpm dlx (#307)

`@huggingface/transformers` does a bare `import 'onnxruntime-common'` from its
shipped `dist/transformers.node.mjs`, but never declares onnxruntime-common in
its own `dependencies`. npm's flat node_modules (and pnpm with hoisting) place
it on transformers' resolution path by accident; pnpm's isolated store only
links a package's declared deps into its scope, so under pnpm-strict /
`pnpm dlx` / `pnpx` the import dies with ERR_MODULE_NOT_FOUND before
`analyze --embeddings` can run.

Declaring onnxruntime-common in gitnexus' own deps (#2074) does not fix this
under pnpm: Node resolves the bare specifier from transformers' module scope,
not ours, and overrides/resolutions can only re-version an existing edge, never
add the missing one (verified against a real `hoist=false` install — the
declaration only changes which version wins the hoist, never whether the import
resolves).

Fix: install a synchronous, in-thread ESM resolution hook
(`module.registerHooks`) right before the lazy transformers import that
redirects `onnxruntime-common` to the copy gitnexus depends on — but only when
the default resolver fails. On npm / hoisted layouts the default resolver
succeeds first and the hook never fires, so working setups are unchanged. The
hook only intercepts the exact `onnxruntime-common` specifier on failure, so it
can never mask an unrelated resolution error; onnxruntime-node's native binding
still loads normally from transformers' own scope.

`registerHooks` (sync, in-thread, single inline closure) is preferred over the
older `module.register` (async, off-thread, now deprecated — DEP0205, removed in
Node 26): the redirect is a one-line conditional that needs no worker thread, no
separate hook module, and no `data` marshalling. It is available on Node >= 22.15;
on older runtimes the helper is a graceful no-op (the gitnexus engines floor is
>= 22.0.0, and the import still resolves on hoisted layouts there).

Chosen over bundling transformers (the build is tsc-only, and transformers
carries native onnxruntime-node + WASM onnxruntime-web assets that bundle
poorly). Installation is idempotent, best-effort, and lazy — only on the
local-embedding path, so it never affects analysis, the parse workers, or HTTP
embedding mode.

Validated end-to-end: the compiled resolver fixes a real pnpm `hoist=false`
transformers install (ERR_MODULE_NOT_FOUND -> resolved). The separate
`@ladybugdb/core` native-binary path under pure `pnpm dlx` is unchanged (#1967
handles that gracefully).

Refs #307, #2069

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

* fix(embeddings): version-match the onnxruntime-common redirect target (#307)

Prefer the onnxruntime-common that onnxruntime-node (the native binding
transformers actually loads) depends on, so the redirected copy is version-
matched to that binding even under `pnpm dlx` — where gitnexus' npm-style
`overrides` block does not apply, because it is honoured only from a root
manifest and gitnexus is a transitive dependency there. The walk resolves
transformers' main entry (not its `exports`-blocked package.json) ->
onnxruntime-node -> its onnxruntime-common, and falls back to gitnexus' own
direct dependency when the chain can't be walked. Also corrects the doc comment
that claimed the gitnexus copy was already "version-aligned".

Addresses a PR #2139 tri-review finding (P2).

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

* fix(embeddings): narrow the onnxruntime-common resolve fallback to absence errors (#307)

The resolve closure's `catch` swallowed every error from `nextResolve` and
redirected, which would silently paper over a genuinely present-but-broken
onnxruntime-common install. Only substitute gitnexus' copy when the specifier is
actually absent (ERR_MODULE_NOT_FOUND, or ERR_PACKAGE_PATH_NOT_EXPORTED for an
exports-broken copy); rethrow anything else. Adds a test that an unrelated error
code rethrows.

Addresses a PR #2139 tri-review finding (P3).

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

* test(embeddings): cover the onnxruntime-common resolver best-effort swallow path (#307)

The outer try/catch in ensureOnnxRuntimeCommonResolvable() was untested. A
throwing registerHooks spy drives it; the call must not throw (initEmbedder does
not guard the return, so a throw would break `analyze --embeddings`). The vitest
quirk that surfaced an earlier attempt applies to throwing mock factories, not a
throwing spy implementation, so this is testable cleanly.

Addresses a PR #2139 tri-review finding (P2).

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

* test(embeddings): tighten the onnxruntime-common redirect-URL assertion (#307)

`/^file:\/\/.*onnxruntime-common/` matched the substring anywhere, so a lookalike
path (e.g. `/x/onnxruntime-common-fake/`) would pass. Require an actual
`/node_modules/onnxruntime-common/...js` segment so the assertion proves the
redirect resolves to the real package, not just a string match.

Addresses a PR #2139 tri-review finding (P3).

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

* refactor(embeddings): drop the no-op __resetOnnxRuntimeCommonResolverForTests seam (#307)

The test helper reloads the resolver via vi.resetModules() + a fresh import(),
which already re-initialises the module-level one-shot `attempted` flag to false.
The __reset export it then called was therefore a no-op. Remove the test-only
export and its call; isolation now rests solely on vi.resetModules().

Addresses a PR #2139 tri-review finding (P3).

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

* docs(embeddings): correct the onnxruntime-common resolver isolation comment (#307)

The doc comment claimed the hook "never affects other tools' resolution". Once
installed, `module.registerHooks` is process-global and its resolve closure runs
for every subsequent resolution — it passes them all through untouched and only
substitutes the exact `onnxruntime-common` specifier on genuine absence, at a
cost of one string comparison. Also note `registerHooks` is @experimental and
requires Node >= 22.15 (graceful no-op below that). Comment-only.

Addresses a PR #2139 tri-review finding (P3).

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-06-10 12:00:43 +01:00
Gergő MagyarandClaude Opus 4.8 ae5ec94fd9 fix: stop impact()/route_map under-reporting blast radius (#2129, #1858, #1589/#1852) (#2136)
* fix(query): stop impact()/context() under-reporting blast radius (#2129, #1858)

Two read-side fixes to the "run impact before editing" safety workflow, both
about the tools rendering "I could not give a single confident answer" as
"no impact" — the most dangerous failure mode for a refactor-safety tool.

#2129 — ambiguous resolution no longer hides a real caller behind a bare
`impactedCount: 0`. When a bare name collides with several symbols, the resolver
returns `ambiguous`; previously the payload carried a flat `impactedCount: 0`,
so the real caller (which calls a *different* same-name node) was invisible
unless the user already knew to disambiguate. The ambiguous branch now runs a
bounded, summary-only BFS per candidate (capped at 6) and surfaces each
candidate's true count plus the top-level `maxImpactedCount` / `maxRisk`, ranked
most-impactful-first. `risk` stays `UNKNOWN` (ambiguity must not read as "safe"),
`impactedCount` stays 0 (no single resolved symbol). The BFS and edge storage
are unchanged — an empirical repro confirmed they are correct; the bug was
purely in how the ambiguous case reported. Disambiguation by uid still returns
the exact result.

#1858 — impact()/context() now carry an additive `epistemic` field. When the
queried symbol sits on an interface / indirection boundary (it implements or
extends an interface, or is one) whose consumers bind via a DI container or
dynamic dispatch, those callers are not traced to the concrete symbol, so the
count is a lower bound. The result is annotated `epistemic: 'lower-bound'` with a
human-readable `boundaries[]` note; a fully resolved leaf stays
`epistemic: 'exact'`. Aligned to the surviving numeric confidence model (the
0.85 IMPACT_RELATION_CONFIDENCE heritage floor), not the long-deleted
TIER_CONFIDENCE enum. Purely additive — no existing field or count changes.

Tests: impact-ambiguous-blast-radius (per-candidate surfacing + uid
disambiguation) and impact-epistemic-lower-bound (interface boundary →
lower-bound, resolved leaf → exact, context parity).

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

* feat(routes): configurable fetch wrappers + faster consumer scan (#1589/#1852)

Closes the residual gap behind the now-merged #1852 (which fixed #1589): the
fetch-wrapper consumer scan only traced wrappers the parse phase auto-detected
as calling the bare global `fetch()`. A wrapper built on axios / a custom
client, or one named outside the built-in convention, was invisible — route_map
silently returned `consumers: []` (the exact "named outside convention → silent
zero" hole #1858 calls out as needing a backstop).

- Configurable wrappers: `.gitnexusrc` gains a `fetchWrappers: [...]` list
  (validated as identifier/member names, de-duped, capped, regex-safe), threaded
  AnalyzeOptions → PipelineOptions → routes phase. Configured names are unioned
  with the auto-detected ones; configured names alone now trigger the scan even
  when nothing was auto-detected.
- Perf (F3 from #1852's review): the cross-file scan built one RegExp per
  (file × wrapper) — O(files × wrappers). It now builds a single alternation
  regex per file (O(files)) and reuses file contents already read for handler
  extraction instead of re-reading them.

Tests: configurable-fetch-wrapper (axios-based `doRequest` wrapper — invisible
without config, traced with it) + .gitnexusrc `fetchWrappers` validation cases.

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

* fix(review): harden the under-reporting fixes after adversarial review

Addresses findings from a reviewer-swarm pass over the two prior commits:

- CLI text false-safe (major): `formatImpactResult` (eval-server.ts) had no
  ambiguous branch, so `gitnexus impact <colliding-name>` printed "No
  dependencies found. This symbol appears isolated." for an ambiguous target —
  the exact false-safe #2129 exists to kill, defeating the JSON-layer fix at the
  text surface. Added an ambiguous branch (per-candidate blast radius +
  maxImpactedCount/maxRisk) and a lower-bound branch for both the zero-count and
  non-zero paths, mirroring the context formatter. Covered by new unit tests.
- Group fan-out dead work (major): impactByUid now passes skipEpistemic:true —
  the group cross-impact fan-out consumes only byDepth, so computing the #1858
  boundary per neighbor was wasted round-trips on the highest-volume path.
- Ambiguous all-UNKNOWN risk (minor): if every per-candidate probe fails, maxRisk
  now reports 'UNKNOWN' instead of falling to the 'LOW' seed (which would read as
  "safe").
- Candidate-probe cost (minor): the per-candidate summary BFS now sets
  skipEnrichment:true, bypassing the process/module aggregation passes it does
  not use.
- Epistemic latency (minor): computeEpistemicBoundary now runs concurrently with
  the impact BFS instead of as a trailing serial round-trip.
- Wrapper over-match (minor): the consumer-scan regex uses a `(?<![.\w$])`
  lookbehind instead of `\b`, so a bare configured name like `get` matches the
  free call `get('/x')` but not a member access `client.get(` (and `apiFetch`
  no longer matches `myApiFetch`).
- Boundary wording (nit): correct article ("a class" vs "an interface") and
  singular/plural ("1 implementation").

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

* fix(lint): drop unused describe import in new impact tests

The withTestLbugDB harness wraps describe internally, so the explicit
describe import was unused — unused-imports/no-unused-imports is an error
(not a warning) in the root eslint config, failing quality/lint.

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

* fix(query): flag partialProbe when an ambiguous candidate probe fails (#2129 review F1)

The ambiguous-impact branch hoists maxRisk/maxImpactedCount so a colliding
name can't read as "isolated". But if a per-candidate BFS throws (e.g. DB
pool contention during the ≤6-way fan-out), it was recorded as
risk:'UNKNOWN', impactedCount:0 and silently masked by any benign sibling
success — maxRisk reduced to the benign tier and maxImpactedCount reflected
only successful probes. Track probeFailed and surface partialProbe:true
(additive, intentionally distinct from the traversal-interrupted `partial`
flag); formatImpactResult prints a lower-bound warning. Covered by a
formatter unit test (a natural in-harness probe throw is unreachable —
_runImpactBFS is fully self-catching under summaryOnly+skipEpistemic+
skipEnrichment).

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

* fix(query): report the full match count when ambiguous candidates are truncated (#2129 review F11)

The ambiguous candidate list is capped at AMBIGUOUS_MAX_CANDIDATES (6), but
the CLI headline read the truncated `candidates[]` length — so a name
matching 9 symbols printed "6 symbols share this name" while the JSON message
stated the true count. Add an additive `totalCandidates` field carrying the
full match count, include a "showing N of M" clause in the message when
truncated, and have formatImpactResult report the full count. Covered by
formatter unit tests for the truncated and non-truncated cases.

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

* perf(query): run context() epistemic probe concurrently with methodMetadata (#1858 review F2)

impact() overlaps the #1858 boundary probe with its BFS, but _contextImpl
awaited computeEpistemicBoundary serially after every other query. Start the
probe right after `symKind` is known (the earliest point it can — symKind
depends on the incoming/outgoing round-trips) so it runs concurrently with the
methodMetadata fetch, and await it at result assembly. Output is unchanged
(covered by the existing epistemic context() tests).

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

* fix(query): flag a leaf interface as lower-bound in context() (#1858 review F3)

context() passed `symKind` to computeEpistemicBoundary, but symKind collapses
a single-resolved Interface to 'Class' (resolvedLabel is '' on the
single-candidate path), so the `symType === 'Interface'` self-boundary branch
never fired and a directly-queried leaf interface (implements nothing, but
consumed) was under-reported as 'exact'. Pass an interface-preserving type
(`resolvedLabel || sym.type || symKind`) instead — enrichCandidateLabels runs
before the single-candidate early return and patches sym.type to 'Interface',
mirroring impact()'s derivation. impact() was already unaffected. Covered by a
new context()-on-a-leaf-interface test.

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

* refactor(query): hoist epistemic relation-type lists + add USES to the allowlist (#1858/#2129 review F4, F5)

F4: promote computeEpistemicBoundary's function-local heritage/consumer
relation-type lists to module-level readonly constants
(EPISTEMIC_HERITAGE_RELATION_TYPES / EPISTEMIC_CONSUMER_RELATION_TYPES) next to
VALID_RELATION_TYPES / IMPACT_RELATION_CONFIDENCE, so a future heritage edge
type is visible to the probe. Kept as arrays (not Sets) because they bind as
Cypher params.

F5 (latent bug): USES is emitted (emit-references.ts) and already in the
default impact relTypes + context() queries, but was missing from
VALID_RELATION_TYPES — so impact({relationTypes:['USES']}) filtered to [] and
silently ran the full default traversal. Add it (0.5 confidence fallback,
matching FETCHES/WRAPS). Updates the security.test.ts allowlist assertions
(size 15→16, USES now valid).

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

* docs(query): document the _runImpactBFS enrichment skip-flag composition (#1858/#2129 review F6)

The three skip-flags (skipPerSymbolEnrichment / skipEpistemic / skipEnrichment)
suppress distinct sub-phases and compose implicitly. Add a JSDoc block at the
opts type listing what each suppresses, the three real call patterns, and the
key interaction (skipEnrichment makes skipPerSymbolEnrichment a no-op).
Comment-only.

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

* refactor(cli): genericize the shared string-array validation messages (#1589/#1852 review F7)

The shared `string-array` ValueKind hardcoded fetch-wrapper phrasing in three
messages (non-array, identifier-shape, empty-list). Since `source` already
names the config key, genericize all three so the shared normalizer carries no
fetchWrappers coupling — a future string-array config key gets sensible errors.
Test assertions updated to the new wording.

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

* refactor(query): type the ambiguous candidate summary + epistemicPromise (#1858/#2129 review F8)

The ambiguous per-candidate summary was read through `any`, so a rename of
_runImpactBFS's return fields would silently zero candidate counts. Name the
read shape ({impactedCount, risk, summary?.direct}) at the narrowing site, and
type epistemicPromise as the optional-epistemic union (the skip case's `{}`
subtype) — keeping computeEpistemicBoundary's own return precise (epistemic
required). Type-only; no runtime change.

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

* refactor(routes): trust validated fetchWrappers config, drop redundant re-filter (#1589/#1852 review F9)

`ctx.options.fetchWrappers` is already trimmed/shape-validated/de-duped/capped
in analyze-config.ts, so the routes-phase re-trim/re-typeof pre-pass was
redundant. Pass it straight through; the single Set-construction filter remains
to guard the auto-detected functionName values (which don't pass through
analyze-config). No behavior change — covered by the existing fetch-wrapper
route suites.

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

* fix(routes): make the wrapper-call boundary Unicode-aware (#1852 review F10)

The consumer-scan lookbehind used ASCII `\w`, so a configured bare wrapper name
preceded by a non-ASCII identifier character (`caféget('/x')`) satisfied the
boundary and produced a spurious FETCHES edge. Switch to the `u` flag with
Unicode property classes (`(?<![.\p{L}\p{N}_$])`). Covered by a fixture
consumer (`cafédoRequest('/api/things')`) asserting no spurious edge.

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

* perf(routes): count wrapper-scan line numbers incrementally (#1852 review F12)

The wrapper consumer scan computed each match's line number via
content.substring(0, match.index).split('\n').length — an O(matchIndex)
allocation per match. Matches arrive in ascending index, so accumulate
newlines with a running counter instead. 1-based line numbers are byte-identical
(covered by the existing fetch-wrapper route suites).

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

* fix(test): keep the #1858 epistemic probe from skewing the impact-pagination mock

The impact-pagination mock counts every query containing `r.type IN` as a BFS
depth level. Once the #1858 epistemic boundary probe was parallelized with the
BFS (it fires `MATCH (x)-[r]->(iface) ... r.type IN $heritage` before the
frontier loop), that query was miscounted as depth-1, shifting the real depths
so multi-depth impactedCount read 50 instead of 200. Short-circuit the
epistemic queries (uniquely aliased `iface`) to empty in both mock setups so
only frontier queries count. Test-only; production is unaffected (the epistemic
query is a separate real query there).

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-06-10 11:25:48 +01:00
Gergő Magyar 7eaeb0a0c4 feat: multi-branch indexing and branch-scoped querying (#2106) (#2137)
* feat(git): add getCurrentBranch + resolveRefToCommit helpers (#2106)

* feat(storage): branch-scoped getStoragePaths + branchSlug + resolveBranchPlacement (#2106)

* feat(analyze): branch-aware indexing — per-branch slot, no overwrite (#2106)

* feat(registry): nest non-primary branches under one path entry (#2106)

* feat(mcp): optional branch scope on query tools + list_repos branches (#2106)

* feat(cli): --branch on analyze + query/context/impact/cypher/detect-changes (#2106)

* feat(cli): branch-aware list/status + per-branch staleness meta (#2106)

* fix(review): apply autofix feedback

- guard analyze against --branch != checked-out branch (prevents writing one
  branch's working tree into another branch's index slot)
- fix branch-handle pool reinit thrash (track observed indexedAt by lbugPath,
  since applyBranchScope returns fresh handles)
- remove dead resolveRefToCommit helper (staleness uses HEAD vs branch meta)
- RepoListing.branches -> Omit<BranchSummary,'stats'> for type cohesion
- add tests: branchSlug traversal containment, --branch mismatch reject,
  callTool branch threading, legacy-entry branch routing, status detached/stale

* fix(review): address tri-review findings (#2106)

- P1 data-loss: a detached-HEAD re-analyze (CI's actions/checkout default) no
  longer strips the primary's meta.branch stamp; preserve it so a later branch
  analyze cannot claim & overwrite the flat/primary index. +cascade integration test
- P2: capture validateBranchName's trimmed return for --branch so a
  whitespace-padded value no longer false-rejects on-branch or ghosts an index
- F1: on a lost/rebuilt registry, a branch run reconstructs the primary
  top-level entry from the flat meta, not the feature branch's meta

* fix(storage): only trust a non-empty-string flatMeta.branch (#2106 R5)

* fix(analyze): warn when the default branch is not the primary index (#2106 R8)

* fix(mcp): resolve --branch <primary> on a legacy unstamped flat index (#2106 R4)

* feat(cli): gitnexus clean --branch to remove a single branch index (#2106 R7)

* fix(mcp): evict orphaned branch pools on unregister/clean (#2106 R3)

* fix(analyze): union per-branch cache keys so a branch switch keeps shards (#2106 R6)

* fix(analyze): normalize the auto-detected branch label via sanitizeDetectedBranch (#2106 R1)

* fix(cli): skip AGENTS.md base_ref refresh for a non-primary branch fast path (#2106 R2)

* fix(storage): atomic writeRegistry + re-read-before-write to narrow the registry race (#2106 R9)

* refactor(storage): extract branch primitives to branch-index.ts (#2106 R10)
2026-06-10 10:24:40 +01:00
36ca096e75 feat(ingestion): Java Spring route annotation → Route node extraction (#2078)
* feat(ingestion): add Java Spring route annotation → Route node extraction

Previously, GitNexus only supported Route node generation for JS/TS
ecosystems (Express, Next.js, Fastify, etc.) and Python (FastAPI, Flask).
Java Spring's annotation-based routing (@RequestMapping, @GetMapping,
@PostMapping, etc.) was only supported at the group contract layer
(http-patterns/java.ts) for cross-repo matching, but NOT at the
ingestion layer for generating graph Route nodes.

This commit adds ingestion-layer support:

1. JAVA_QUERIES (tree-sitter-queries.ts):
   - Added method-level annotation captures (@GetMapping, @PostMapping,
     @PutMapping, @DeleteMapping, @PatchMapping) → @decorator captures
   - Added class-level @RequestMapping → @decorator capture (prefix)
   - Supports both positional ("/path") and named (path="/path",
     value="/path") annotation argument forms

2. parse-worker.ts:
   - Java class-level @RequestMapping is detected and stored as a prefix
     (not pushed as a standalone Route)
   - After per-file capture processing, the prefix is applied to all
     method-level routes in the same file via the existing
     ExtractedDecoratorRoute.prefix field
   - The routes phase (normalizeExtractedRoutePath) handles the prefix
     joining, producing final URLs like /api/users/list

3. Tests:
   - Unit test (worker-backed): 4 cases covering prefix joining,
     bare routes, class-level exclusion, multi-file isolation
   - Integration test (full pipeline): 6 cases covering end-to-end
     Route node + HANDLES_ROUTE edge generation

Closes the feature gap where `route_map`, `shape_check`, and
`api_impact` MCP tools returned empty results for Java Spring projects.

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

* fix: address review findings — extract spring.ts module, fix PatchMapping, multi-class support

Addresses all P2 findings from tri-review:

1. **Architecture**: Extracted Spring route logic from parse-worker.ts into
   a dedicated `route-extractors/spring.ts` module (matching the pattern
   of `laravel.ts` and `fastapi-router-bindings.ts`). parse-worker now
   has a single dispatch line — no language-specific logic inline.

2. **PatchMapping bug**: Added `'PatchMapping'` to `ROUTE_DECORATOR_NAMES`
   (was silently dropped before).

3. **Multi-class bug**: The new `extractSpringRoutes` walks each class
   declaration independently with its own prefix — no more single-scalar
   `javaClassPrefix` last-wins issue.

4. **Test hygiene**: Unit tests now import `extractSpringRoutes` directly
   (no dist build / worker pool dependency). Tests run in all tiers.

5. **Removed JAVA_QUERIES decorator patterns**: The Spring extractor does
   its own AST walk, so the tree-sitter query captures for Java annotations
   are no longer needed (avoids duplicate route emission).

Additional test coverage:
- Multi-class in one file with independent prefixes
- @PatchMapping support
- Named annotation args (path= and value=) on class-level @RequestMapping

* refactor: move Spring route extraction to LanguageProvider hook

Addresses the second review comment: instead of an inline
`if (language === SupportedLanguages.Java)` dispatch in parse-worker,
the Spring route extraction is now wired through a new optional
`extractDecoratorRoutes` hook on LanguageProviderConfig.

- Added `extractDecoratorRoutes` to LanguageProviderConfig interface
- Java provider registers `extractSpringRoutes` as its implementation
- parse-worker calls `provider.extractDecoratorRoutes?.()` generically
- Removed direct import of spring.ts from parse-worker

This keeps parse-worker fully language-agnostic — no language names
appear in the dispatch path for route extraction.

* refactor: rewrite spring.ts with tree-sitter captures, fix inline imports

Addresses all 4 inline review comments:

1. Rewrote spring.ts to use a single predicate-free Parser.Query
   (same pattern as group-layer JAVA_ROUTE_ANNOTATION_PATTERNS).
   Two-phase loop: first pass collects class prefixes by node.id,
   second pass resolves method routes via findEnclosingClass.
   No more manual DFS / recursion.

2-3. Moved inline import(...) type references in language-provider.ts
     to proper top-level imports (Parser, ExtractedDecoratorRoute).

4. Covered by #1 — recursive helpers removed entirely.

Added 3 extra test cases: non-route named args filtering,
prefix isolation across mixed classes, line number accuracy.

* refactor: extract shared Spring route primitives + add parity test

Addresses review follow-up on #2078:

- Extract the primitives shared by the ingestion (route-extractors/spring.ts)
  and group (http-patterns/java.ts) Spring extractors into a new
  route-extractors/spring-shared.ts: METHOD_ANNOTATION_TO_HTTP,
  findEnclosingClass, isRouteMemberKey, and a safe unquoteSpringLiteral.
  Both extractors now import from it (group -> ingestion, the layer-correct
  direction) so the shared semantics can't drift apart.

- Replace spring.ts's local unquote() with the safer unquoteSpringLiteral
  (returns null for non-string nodes instead of assuming a quoted string).

- Add test/unit/spring-route-extractor-parity.test.ts: runs one shared Spring
  fixture through both extractors and asserts they surface the same provider
  method/path combinations.

The broader HttpRouteExtractor source-scan optimization is tracked in #2138.

---------

Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-10 09:44:56 +01:00
Gergő MagyarandClaude Opus 4.8 292f26ece3 fix(hooks): silence MCP-owned-DB augment skip for strict hook runners (#1913) (#2134)
* fix(hooks): silence MCP-owned-DB augment skip for strict hook runners

The PreToolUse augment-skip path wrote `[GitNexus] augment skipped: MCP
server owns DB` to stderr unconditionally on a normal (non-error) skip.
Strict hook runners that validate hook output (e.g. Codex `PreToolUse`)
treat that as noisy / "invalid pre-tool-use JSON output".

Gate the diagnostic behind GITNEXUS_DEBUG via a shared `isDebugEnabled()`
helper, so normal skips are silent by default (empty stdout AND stderr,
exit 0) and the reason stays recoverable with `GITNEXUS_DEBUG=1`. Applied
consistently to all three hand-maintained hook copies (claude,
antigravity, claude-plugin).

Tests:
- Unit (claude CJS + plugin): assert default-silent and debug-on behavior
  for the MCP-owned-DB skip and for the fail-closed (lsof ETIMEDOUT) skip
  that routes through the same gated line; the owner-detection tests run
  with GITNEXUS_DEBUG=1 so the skip discriminator stays observable.
- e2e (antigravity): the antigravity adapter shares the identical gated
  skip but only runs from its install dir, so cover it through the install
  pipeline with a faked DB-owner probe (strict empty-stdout/stderr +
  debug-on). Promote the fake-probe helpers (createHookToolDir / hookEnv,
  plus a module-private writeExecutable) into shared hook-test-helpers so
  unit + e2e reuse them.

Fixes #1913

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

* fix(hooks): unify GITNEXUS_DEBUG gating in main() catch handlers

The main() catch-handler in all three hook copies still gated its crash
log on truthy `if (process.env.GITNEXUS_DEBUG)`, while the skip diagnostic
the #1913 fix added is gated on the strict `isDebugEnabled()` helper
(=== '1' || === 'true'). That split meant GITNEXUS_DEBUG=0 or =false
suppressed the skip line yet still enabled crash logging — two conflicting
contract signals in the same file.

Switch the three catch handlers to isDebugEnabled() so GITNEXUS_DEBUG has
one strict meaning everywhere: exactly '1' or 'true' enables all
diagnostics; everything else (incl. '0', 'false', empty, unset) is silent.

Add boundary tests asserting the MCP-owner skip stays silent with
GITNEXUS_DEBUG='0' and 'false' (CJS + Plugin), pinning the strict contract.

Refs #1913

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

* fix(hooks): gate antigravity stale-index hint stderr behind GITNEXUS_DEBUG

The antigravity AfterTool handler mirrored the stale-index hint to stderr
unconditionally on a normal (non-error) success path — the last ungated
stderr write of the class issue #1913 targets, and a divergence from the
claude hook, which never mirrors this hint to stderr.

Gate the stderr mirror behind isDebugEnabled(). The hint still reaches the
agent via additionalContext (stdout JSON) — parts.push(hint) stays
unconditional — so there is no functional loss; only the by-default
terminal mirror moves behind GITNEXUS_DEBUG=1. This knowingly changes the
#1730 terminal-mirror behavior in favor of strict-runner cleanliness and
parity with the claude adapter.

Split the e2e assertion into a default-silent test (hint in
additionalContext, absent from stderr) and a GITNEXUS_DEBUG=1 test (hint
mirrored to stderr).

Refs #1913

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

* docs(hooks): document GITNEXUS_DEBUG=1 for hook diagnostics

GITNEXUS_DEBUG was documented only in the cursor integration README, so
the diagnostic escape hatch for the Claude Code / Antigravity hooks was
undiscoverable. Operators hitting a silent hook skip (MCP server owns the
DB, fail-closed probe timeout, or an already-current index) had no
documented way to surface the reason.

Add a Troubleshooting subsection explaining that the hooks stay silent on
normal skip paths for strict runners, that GITNEXUS_DEBUG=1 surfaces the
reason on stderr, and that only '1'/'true' enable diagnostics (stdout JSON
the agent consumes is unaffected).

Refs #1913

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

* test(hooks): update setup-antigravity unit test for gated stale-index hint

U2 (7995e921) gated the antigravity stale-index hint stderr mirror behind
GITNEXUS_DEBUG, but a second test — setup-antigravity.test.ts's "AfterTool
emits stale-index hint" — also asserted the hint on stderr by default and
was missed (it lives outside the two files validated locally; the full CI
matrix caught it).

Update it to the U2 contract: assert the hint via additionalContext with
stderr silent by default, plus a GITNEXUS_DEBUG=1 run asserting the
terminal mirror reappears.

Refs #1913

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-06-10 09:09:41 +01:00
Gergő MagyarandClaude Opus 4.8 4f9d595c73 fix(docker): ship runtime-needed published assets (hooks/, skills/) into the image (#2130) (#2132)
* fix(docker): copy hooks/ into Dockerfile.cli runtime stage (#2130)

`gitnexus analyze` inside the official image (akonlabs/gitnexus,
ghcr.io/abhigyanpatwari/gitnexus) crashed at startup with:

    Error: Cannot find module '../../hooks/claude/resolve-analyze-cmd.cjs'
    Require stack:
    - /app/gitnexus/dist/cli/resolve-invocation.js

`dist/cli/resolve-invocation.js` does
`createRequire(import.meta.url)('../../hooks/claude/resolve-analyze-cmd.cjs')`
at module load (it is the single source of truth for the npm-11 npx-crash
invocation decision, #1939), and `analyze.ts` statically imports it. The
Dockerfile.cli runtime stage copied dist/node_modules/package.json/the
duckdb script/vendor but never `hooks/`, so the require throws before the
command does any work. `hooks/` is in package.json `files`, so npm already
ships it — Docker was the only distribution dropping it.

Fix: copy `hooks/` into the runtime stage, mirroring what npm publishes.

Also add `test/unit/dockerfile-runtime-asset-parity.test.ts`: a regression
guard that derives every out-of-dist `require()`/`createRequire()` target
from source and asserts each is a runtime-stage `COPY`. Scoped to the
require family (not `fs.access`/`new URL`), so it locks the #2130 class
without false-flagging the intentionally-omitted, gracefully-degrading
`web/` and `skills/` assets.

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

* fix(docker): also ship skills/ into the runtime image

Follow-up to the hooks/ fix: `skills/` is another published runtime asset
(in package.json `files`) the Docker image dropped. The CLI reads the
bundled SKILL.md templates from `<pkg>/skills/` for `gitnexus analyze
--skills` (ai-context skill generation) and `gitnexus setup`/`uninstall`
(installing skills into editor configs). Unlike the hooks/ require(), these
reads degrade SILENTLY when the dir is absent — `--skills` writes minimal
placeholder content (ai-context.ts), `setup` installs zero skills
(setup.ts readdir → []) — so the image looked fine but produced wrong
output. Copy `skills/` so the image is fully usable for all CLI tooling.

`web/` (also in `files`) is intentionally NOT shipped: this image never
builds gitnexus-web (the builder doesn't copy it, build.js logs "skipping
web UI"), so it is API-only by design — the UI is the separate
Dockerfile.web image / hosted app. The duckdb script is the only runtime
asset needed from scripts/, so that stays a single-file copy.

Extends the runtime-asset-parity guard with an explicit skills/ assertion.

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

* fix(test): correct stale docstring that listed skills/ as not copied

The 2nd commit on this branch added a skills/ COPY + an it('copies skills/…')
assertion, but the top-of-file docstring still grouped skills/ with web/ as
'intentionally not copied / out of scope'. Drop skills/ from that sentence and
note it is shipped (and covered by its own test). web/ remains the sole
fs-accessed-but-uncopied example. Documentation-only; assertions unchanged.

* fix(test): make runtime-stage detection case-insensitive on AS

Docker accepts a lowercase `as runtime`; the parity guard's stage-detection
regex was case-sensitive on `AS`, so a future Dockerfile reformat would empty
the parsed COPY set and trip the named assertions. Add the /i flag.

* fix(test): stop runtime-stage COPY parsing at the next FROM

runtimeStageCopiedSources scanned from the runtime FROM to EOF. Bound the scan
to the runtime stage (start after its FROM, break on the next FROM) so a build
stage added after runtime can't have its COPY lines misattributed. No-op today
(runtime is the last stage); the copied set is unchanged.

* fix(test): assert at least one runtime COPY is parsed (no vacuous pass)

If the runtime FROM or the /app/gitnexus/ source prefix ever stops matching,
the copied set goes empty and the parity assertion passes vacuously. Add an
explicit copied.length>0 guard so that failure mode is loud and named.

* fix(test): strip line comments before require-scanning

requiredExternalAssets() regex-scanned raw source, so a future doc-comment such
as a commented-out require('../../web/x') in a shallow src file would resolve
outside dist/ and spuriously fail the parity guard. Strip // line comments
first. Block comments are deliberately not stripped (a naive block strip mangles
slash-star inside string/glob literals). Verified the real-tree scanner output
is byte-identical with and without the strip, and resolve-invocation.ts's
multi-line createRequire is still detected. (Also swaps a stray non-ASCII glyph
in the prior commit's comment for ASCII.)

* fix(test): account for aliased + computed module-load requires (fail-closed)

The parity scanner only matched string-literal require/createRequire, so it
missed module-load requires via aliased createRequire bindings and computed
paths — and already failed to see community-processor.ts's
`_require(leidenPath)` -> vendor/leiden, making the "every out-of-dist asset"
claim untrue.

Broaden the scan:
- Discover per-file createRequire bindings (requireCJS, _require, …) and match
  their literal-arg calls; keep the createRequire(...)('…') IIFE form.
- Detect COMPUTED (non-literal) requires and gate them on MODULE-LOAD position
  (brace-depth 0), so the four in-function computed requires that target
  node_modules/package.json (optional-grammars, native-check, capabilities,
  parse-cache) are correctly out of charter and ignored. A module-load computed
  require must be vetted in KNOWN_COMPUTED_REQUIRES (seed: community-processor ->
  vendor/leiden) or the test FAILS CLOSED for manual review.
- Allowlist entries are coverage-checked via isCovered, never trusted: a new
  test removes the `vendor` COPY from a fixture and asserts leiden surfaces as
  uncovered (so deleting a COPY can't silently pass — the #2130 class).
- Exclude `<id>.resolve(...)` (a path lookup, not a load).
- Upgrade the comment stripper to a string-aware pass that removes line AND
  block comments without mangling slash-star inside string/glob literals — the
  computed branch needs JSDoc requires (e.g. javascript/index.ts) gone, and the
  literal scan output stays byte-identical.

Honest claim wording: the 4th test now says coverage = resolvable + vetted
module-load requires, unrecognized computed requires fail for review. Adds
unit tests for fail-closed, aliased-literal, and in-function-ignored paths.

* fix(test): also scan shipped .cjs/.mjs assets for sibling requires

The guard only scanned src/**/*.ts, so hand-written shipped runtime files were
invisible — and they DO require siblings: hooks/claude/gitnexus-hook.cjs and
hooks/antigravity/gitnexus-antigravity-hook.cjs each require('./hook-lock.cjs'),
'./hook-db-lock-probe.cjs', './resolve-analyze-cmd.cjs'. Add a second pass over
shipped .cjs/.mjs assets (the runtime COPY set minus dep/data roots), resolving
each relative require against the asset's OWN package-relative dir and checking
COPY coverage — by prefix, NOT on-disk existence: the antigravity hook's
'./hook-lock.cjs' resolves to hooks/antigravity/hook-lock.cjs (which doesn't
physically exist; hook-lock.cjs lives under hooks/claude) yet is covered by the
whole-hooks COPY. All 6 shipped sibling requires resolve under the hooks COPY.

* fix(docker): move hooks/skills COPYs past the DuckDB FTS RUN

The hooks/ and skills/ COPYs sat between the vendor COPY and the DuckDB
FTS-extension install RUN, so any edit to hook/skill content invalidated that
RUN's cache layer — which performs a one-time network INSTALL of the extension
(~tens of seconds per affected build). The COPYs have no input dependency on the
DuckDB step; relocate them to after it (before USER node) so stable
infrastructure layers are not rebuilt on hook/skill churn. Image contents are
unchanged. The runtime-asset-parity guard still detects both (its scan covers
the whole runtime stage), and the two are consolidated under one comment.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:38:37 +01:00
Gergő MagyarandClaude Opus 4.8 e46651e42c fix(embeddings): create VECTOR index via conn.query, not the prepared path (#2114)
`gitnexus analyze` silently failed to create the LadybugDB VECTOR/HNSW index because `CALL CREATE_VECTOR_INDEX(...)` was run through the prepared `conn.prepare()` path, which rejects multi-statement procedures — degrading semantic search to exact-scan. Route index creation through `conn.query()` via a new adapter-owned `createVectorIndex` (mirrors `createFTSIndex`), make the previously-swallowed error visible (`{ err }` logging), add an in-process idempotency cache, and add real-`@ladybugdb/core` regression coverage.

Fixes #2114.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 07:59:37 +01:00
dependabot[bot]andGergő Magyar 43e6a3f688 chore(deps)(deps): bump js-yaml from 4.1.1 to 4.2.0 in /gitnexus (#2097)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/commits)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.2.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>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-10 06:33:39 +01:00
dependabot[bot] 581943bbe5 chore(deps)(deps-dev): bump @types/node in /gitnexus (#2128)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.1 to 25.9.2.
- [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: 25.9.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 06:32:57 +01:00
Gergő Magyar 1cf65b339c chore: release v1.6.7 (#2126)
Bump gitnexus 1.6.6 -> 1.6.7, add the 1.6.7 CHANGELOG section (14 PRs since v1.6.6), and sync the Claude plugin manifests (plugin.json + marketplace.json) to 1.6.7.
2026-06-09 21:37:33 +01:00
gitnexus-release-bot[bot]andgitnexus-release-bot[bot] d0452d26a1 chore(vendor): rebuild native prebuilds (tree-sitter-c,tree-sitter-dart,tree-sitter-proto,tree-sitter-kotlin,tree-sitter-swift) (#2125)
Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/27230510159

Co-authored-by: gitnexus-release-bot[bot] <gitnexus-release-bot[bot]@users.noreply.github.com>
2026-06-09 20:57:37 +01:00
Gergő Magyar 247ea431de fix(ci): make the prebuild PR push re-run-safe (--force-with-lease -> --force) (#2123) 2026-06-09 20:28:08 +01:00
Gergő MagyarandClaude Opus 4.8 4682a477d8 feat(mcp): paginate list_repos to avoid client token truncation (#2119) (#2120)
* feat(mcp): paginate list_repos to avoid client token truncation (#2119)

list_repos returned every indexed repository in one unpaginated array,
which large/LLM MCP clients truncate by token limit — so agents with
hundreds of indexed repos could not enumerate them all (the data
transmits fully; the consuming client drops it).

Add bounded limit/offset pagination to the list_repos tool:
- result changes from a bare array to
  { repositories, pagination: { total, limit, offset, returned,
  hasMore, nextOffset } }; default page 50, max 200 (shared constants)
- reject malformed limit/offset; clamp limit above the max
- deterministic order (lower-cased name, then path) over one registry
  snapshot per call, so paging never skips or duplicates an entry
- covers both stdio and remote /api/mcp (shared createMCPServer/callTool)

The internal listRepos() method (5 callers), GET /api/repos, and the
`gitnexus list` CLI are unchanged. The array->object tool-result shape
is a deliberate contract change, documented in CHANGELOG.

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

* fix(mcp): reject list_repos limit above the max instead of clamping (#2119)

parseListReposPagination silently clamped limit>max to the maximum while
throwing on every other out-of-bounds value (limit<1, offset<0, non-integer,
NaN). A client that advanced offset by its requested limit (rather than
pagination.nextOffset) then silently skipped repositories and saw
hasMore:false — defeating the "never skips" guarantee. Reject an over-max
limit too, so validation is symmetric and a caller never gets a smaller page
than it asked for without a clear error. Updates the schema/description, the
helper + ListReposPagination JSDoc, the guide note, and the two clamp tests.

Resolves the cross-engine-corroborated P2 (Codex + adversarial lane) and the
maintainability lane's clamp-vs-throw inconsistency from the PR #2120 review.

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

* refactor(mcp): name the list_repos return type and mark the parser @internal

Extract the inline listRepos() element shape into an exported RepoListing
interface and use it for both listRepos() and listReposPage().repositories,
replacing the opaque Awaited<ReturnType<LocalBackend['listRepos']>> expression
the maintainability review flagged. Tag parseListReposPagination @internal
(it is exported only for unit testing). Pure type/JSDoc change; no behavior.

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

* refactor(eval-server): type formatListReposResult to the paginated shape

Narrow formatListReposResult's parameter from `any` to
{ repositories: RepoListing[]; pagination?: ListReposPagination } and drop the
dead bare-array branch — after #2119 callTool('list_repos') always returns the
paginated object, so the Array.isArray shim was unreachable. Add a list_repos
continuation hint to the eval-server's getNextStepHint (parity with the MCP
server), and cover the previously-untested non-empty + hasMore:false formatter
branch. Migrates the two bare-array formatter tests to the object shape.

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

* test(mcp): harden list_repos pagination coverage

- Exercise the #2054 sibling-clone guarantee through the real callTool tool
  path (in the #2054 describe, which has temp-dir cleanup), proving siblings
  and remoteUrl survive listReposPage's sort+slice — not only listRepos().
- Assert total + limit on the middle-page test (a total miscalculation at a
  non-zero offset would otherwise slip past it).
- Cover the benign boundaries: negative-zero offset (accepted as page 0) and a
  MAX_SAFE_INTEGER offset (empty page).
- Replace the integration test's '\n\n---' split with a string-aware brace
  scan, so a repo path containing braces can never truncate the JSON parse.

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

* docs(skills): sync the list_repos pagination example to the guide mirrors

The .claude and gitnexus-claude-plugin guide mirrors only carried the one-line
table note; add the full "Paginating list_repos" section (shape + multi-page
traversal example + notes) so all three guide copies are byte-consistent with
the canonical gitnexus/skills/gitnexus-guide.md.

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

* chore: drop list_repos CHANGELOG entries from this PR

Restore gitnexus/CHANGELOG.md to match main so this PR contributes no
changelog change; the changelog is curated separately from feature PRs.

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-06-09 19:59:54 +01:00
Gergő Magyar bd90d5bf90 fix(ci): green the tree-sitter prebuild matrix (npm-bundled prebuilds + arm64 runtime) (#2122)
First real dispatch of build-tree-sitter-prebuilds failed every job from two
independent root causes:

1. `c` (kind:'npm'): tree-sitter-c's npm tarball bundles prebuilds/ for all 6
   tuples, so the post-build `find ... -print -quit` picked a non-host tuple
   (win32-x64 on a linux runner) and the "built X, expected Y" assertion failed.
   Clear $pkgdir/prebuilds before prebuildify so only the freshly-built host
   tuple remains. (kotlin is npm too but ships no prebuilds, so it dodged this.)

2. every linux-arm64: validate installed tree-sitter@0.21.1 with
   --ignore-scripts, but that tarball ships no linux-arm64 prebuild, so
   require("tree-sitter") threw "No native build was found ... arch=arm64". The
   grammar's own arm64 .node loaded fine. Drop --ignore-scripts and add node-gyp
   + node-addon-api so the runtime source-builds where upstream ships no prebuild;
   prebuild-covered tuples still use the prebuild. The grammar-vs-runtime ABI
   check still fires at setLanguage.
2026-06-09 19:39:10 +01:00
Gergő Magyar 6ab3f64443 fix(ci): drop the broken -t 22 from prebuildify (build-tree-sitter-prebuilds) (#2121)
The native build step ran `prebuildify --napi --strip -t 22`, but prebuildify
parses the bare `-t 22` as the NUMBER 22 and crashes in resolveTargets
(`TypeError: v.indexOf is not a function`) — so every matrix job (c/dart/proto/
kotlin × 6 tuples) failed on its first real run. N-API prebuilds are
Node-version-agnostic, so `-t <node-version>` is both wrong and the cause; drop
it. Verified locally: `prebuildify --napi --strip` builds the vendored c source
cleanly into prebuilds/<tuple>/tree-sitter-c.node and exports
napi_register_module_v1.
2026-06-09 19:02:06 +01:00
Gergő MagyarandClaude Opus 4.8 cef63dd044 feat(install): toolchain-free tree-sitter via vendored prebuilds (#2113)
* feat(install): toolchain-free tree-sitter via vendored GitNexus-built prebuilds

Eliminate the C/C++-toolchain requirement at install for the at-risk grammars
(dart, proto, kotlin) by generating + vendoring native prebuilds, mirroring the
existing vendored tree-sitter-swift. The 10 grammars that already ship 6 upstream
prebuilds stay npm dependencies (toolchain-free AND dependency-review-tracked).

- .github/workflows/build-tree-sitter-prebuilds.yml: a registry-parameterized
  workflow that builds {dart,proto,kotlin} x {linux,darwin,win32}-{x64,arm64}
  prebuilds natively, validates each loads + parses on its arch, and opens a PR
  vendoring them. A `guard` job gates the heavy matrix to run ONLY on dispatch
  or a real grammar-version change — ordinary code PRs cost zero matrix minutes.
- dart/proto: prefer a committed prebuild; fall back to today's source build
  when none matches (no behavior change until prebuilds are vendored).
- kotlin: vendor it (Swift parity) instead of compiling the third-party
  optionalDependency from source at the user's install — supersedes #2110's
  optionalDependency mechanism. The ~23 MB parser.c is NOT vendored (the
  workflow builds from the published package); only node-types + bindings +
  prebuilds are. Removed from optionalDependencies; lock regenerated; probe,
  parser-loader note, README/.devcontainer docs, and the #2110 tests updated.

DO NOT MERGE until vendor/tree-sitter-kotlin/prebuilds/ is populated by the
build-tree-sitter-prebuilds workflow: until then Kotlin is unavailable (vendored
with no source-build fallback). dart/proto remain fully functional throughout.

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

* test(install): guard 6/6 N-API prebuild coverage for every grammar

Regression guard so a toolchain-less install can never silently lose a tree-sitter
language on a supported platform-arch:

- Vendored grammars (vendor/tree-sitter-*): every one MUST ship a loadable N-API
  prebuild for all 6 tuples {linux,darwin,win32}-{x64,arm64}. Asserts the
  napi_register_module_v1 entry symbol in each .node (cross-platform, no need to
  run the binary). Currently RED for dart/proto/kotlin until the
  build-tree-sitter-prebuilds workflow populates their prebuilds/ — this is the
  must-fill-before-merge gate (swift already passes 6/6).
- npm-dependency grammars: asserts upstream ships 6/6 N-API too, catching a
  future platform drop. tree-sitter-c is allow-listed at 4/6 (missing
  linux-arm64/win32-arm64) pending #2116; the guard also fails if that gap is
  silently closed (prompting allow-list removal).

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

* feat(install): vendor tree-sitter-c at 0.21.4 with GitNexus-built prebuilds (#2116)

tree-sitter-c is the one grammar dependency upstream ships incomplete prebuilds
for (4/6 — no linux-arm64/win32-arm64), AND it is a REQUIRED grammar: its own
`install` (node-gyp-build) compiles from source when no prebuild matches and
exits non-zero, so on a toolchain-less ARM host `npm install gitnexus` HARD-FAILS
at the c step — during npm's dependency phase, before any GitNexus postinstall
runs (so a postinstall "supplement" can't help).

Fix: vendor c prebuild-only at the pinned 0.21.4 (Kotlin pattern), with all six
prebuilds GitNexus-cross-built, and drop it from `dependencies`:
- vendor/tree-sitter-c/ (bindings + node-types + manifest + prebuilds); build
  probe scripts/build-tree-sitter-c.cjs; added to the build workflow registry
  (kind 'npm' — built from c@0.21.4 source).
- materialize-vendor-grammars.cjs: c is REQUIRED, so it is always materialized,
  even under GITNEXUS_SKIP_OPTIONAL_GRAMMARS (it needs no toolchain).
- Removed from package.json dependencies + lockfile (nothing else needs npm c —
  tree-sitter-cpp's dep on c is dev-only and not installed). Preserves the #1242
  ABI pin: vendoring 0.21.4 keeps the good ABI while closing the ARM gap.
- parser-loader note + the prebuild-coverage guard + a cli-commands assertion
  updated; c moves from the npm-gap allow-list into the vendored 6/6 cohort.

Verified: tsc clean, 31 unit tests pass, c loads/parses; the guard is RED for
c/dart/proto/kotlin until the workflow populates prebuilds (the must-fill gate).
Closes the operational risk in #2116.

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

* fix(ci): source-build fallback for vendored c/kotlin so CI is healthy pre-prebuilds

The vendored prebuild-only grammars (c, kotlin) had empty prebuilds/ until the
build-tree-sitter-prebuilds workflow runs, so they could not load in CI — and
C is hard-required by cross-platform tests (tree-sitter-languages/parsing on
ubuntu+macos+windows), which I cannot pre-build for macos/windows locally. The
robust fix is a source-build fallback that works on every CI runner (all have a
toolchain), mirroring dart/proto:

- Vendor the grammar source (binding.gyp + src/) for c and kotlin; their build
  scripts now PREFER a committed prebuild (toolchain-free) and fall back to
  `node-gyp rebuild` from the vendored source when no prebuild matches. Verified
  both compile against the hoisted node-addon-api@^8 and the runtime loads.
- prebuild-coverage guard is now bootstrap-tolerant: a grammar that vendors its
  source (binding.gyp) may have an incomplete prebuild set (the workflow fills
  it); a prebuild-only grammar (swift) still must ship all six. Any present
  prebuild must still be N-API. Guard goes green; it re-tightens per-grammar as
  the workflow populates prebuilds.
- actionlint: silence a false-positive SC2016 (JS template literals inside the
  single-quoted `node -e` validate block).

Note: kotlin's generated parser.c is large (~23 MB on disk; compresses heavily
in git). Once the workflow populates all six kotlin prebuilds, the source serves
only as the fallback and could be slimmed if desired.

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

* fix(docker): re-materialize+rebuild vendored grammars after npm prune

`npm prune --omit=dev` in the gitnexus CLI image drops anything not in
package.json's dependency tree — including the VENDORED tree-sitter grammars
(materialized by postinstall, not declared deps) and their built bindings. The
`serve` image analyzes/parses repos at runtime, so re-run the grammar postinstall
after the prune (in the toolchain-equipped builder) to restore them. Load-bearing
for tree-sitter-c, a core REQUIRED grammar now vendored (#2116): as a former
dependency it survived prune; vendored, it would not. Also restores
swift/dart/proto/kotlin, which were silently pruned from the image before.

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

* feat(grammars): unify tree-sitter-swift with the vendored-source build pipeline

Swift was the last grammar handled differently — it shipped only upstream
prebuilds, while c/dart/proto/kotlin vendor their grammar source and use a
prefer-prebuild -> source-build-fallback activation script. Vendor swift's
source so all five are handled identically (one uniform build path).

- vendor/tree-sitter-swift: add binding.gyp (win-hardened), bindings/node/
  binding.cc, src/parser.c (ABI-14 default, ~18 MB), src/scanner.c, and
  src/tree_sitter/ headers. The 6/6 prebuilds are retained. The legacy
  parser_abi13.c alternate is intentionally not vendored.
- build-tree-sitter-swift.cjs: rewrite the prebuild probe into the dart-style
  prefer-prebuild then source-build fallback (keeps the GITNEXUS_SKIP gate and
  the never-exit-non-zero postinstall invariant).
- build-tree-sitter-prebuilds.yml: register swift (kind 'vendored'); add its
  package.json to the version-gated pull_request paths and a validate snippet.
- prebuild-coverage guard auto-moves swift into the source-fallback cohort
  (binding.gyp now present); refresh the stale "swift is prebuild-only" comments.
- tests: add build-tree-sitter-swift-probe.test.ts; fix the pre-existing
  build-tree-sitter-kotlin-probe.test.ts breakage (it still asserted the old
  probe strings after kotlin's dart-style conversion); assert swift's vendored
  source in cli-commands.test.ts.
- docs: README / .devcontainer / kotlin vendor README — swift's prebuilds are
  now GitNexus-cross-built from vendored source like the rest, not upstream-only.

Verified: swift source-builds against node-addon-api@8 -> N-API binary -> loads
against the pinned tree-sitter@0.21.1 (ABI 14) -> parses cleanly.

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

* feat(publish): gate a lean prebuilds-only npm tarball behind a coverage guard

Vendoring grammar source (parser.c) alongside the prebuilds means the npm
tarball now carries ~50 MB of generated source it almost never compiles (every
supported platform-arch has a prebuild). Prepare to drop it from the published
package once all prebuilds exist — safely.

- .npmignore: add a GATED, commented-out "lean publish" block that excludes the
  source-build inputs (parser.c/scanner.c/tree_sitter/binding.gyp/binding.cc) but
  keeps prebuilds/ + the runtime files. Uncommenting ships prebuilds-only.
- scripts/assert-publish-grammar-coverage.cjs: a prepack guard that refuses to
  pack/publish if the source exclusion is active while any vendored grammar still
  lacks 6/6 prebuilds (which would ship a grammar with no loadable binding). Wired
  into `prepack` (runs on npm pack + publish, incl. the publish.yml dry-run) and
  exposed as `npm run assert-publish-coverage`.
- test: pure-core decision cases + a real-repo publish-safety check that fails CI
  if .npmignore is activated prematurely.

Net: the prebuilds already publish today (files: ["vendor"]); this makes the
future switch to a prebuilds-only tarball a one-line uncomment that can't ship a
dead grammar. The guard currently reports "source + prebuilds" (only swift has
6/6 prebuilds so far) and passes.

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

* refactor(grammars): consolidate the 5 build-tree-sitter-*.cjs into one

The per-grammar activation scripts (c/dart/proto/swift/kotlin) were ~95%
identical — same prefer-prebuild → source-build → never-fail flow, differing only
in name, target_name, required-vs-optional, and the display label in warnings.

- scripts/build-tree-sitter-grammars.cjs: one registry-driven script. Bare call
  builds all (postinstall); `... <name>` builds only the named grammars (so the
  probe test can isolate one). c is `required: true` (ignores the opt-out gate);
  the rest honor GITNEXUS_SKIP_OPTIONAL_GRAMMARS. Per-grammar try/catch + a final
  process.exit(0) preserve the postinstall never-exit-non-zero invariant.
- package.json: postinstall is now `materialize && build-tree-sitter-grammars.cjs`
  (was five chained `build-tree-sitter-<name>.cjs` calls).
- tests: replace the two near-identical *-probe.test.ts files with one
  parameterized build-tree-sitter-grammars-probe.test.ts that also covers the
  required-vs-optional opt-out split and an unknown-grammar arg.
- update cli-commands.test.ts postinstall assertions + the vendor c/kotlin/swift
  README + swift provenance to reference the consolidated script.

Behavior is preserved (warnings normalized to one consistent format). Removes 5
scripts + 1 test file; adds 1 script + 1 test.

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

* fix(ingestion): lazy-load tree-sitter-c to prevent module-load crash

tree-sitter-c is now vendored prebuild-only (#2116) with 0/6 committed
prebuilds, so on a toolchain-less or `--ignore-scripts` install C has no native
binding. Three modules loaded it via a hard top-level `import C from
'tree-sitter-c'`, which throws ERR_MODULE_NOT_FOUND at module-load — crashing
`analyze` before parser-loader's optional/severity:error degradation can run.
This is the #2091/#2093 bug class (previously fixed for swift/dart/kotlin); C was
left static because it used to be an always-present npm dependency.

- languages/c/query.ts: load via the lazy guarded getLanguageGrammar(C), mirroring
  swift/query.ts; the main-thread isLanguageAvailable filter ensures the getters
  are reached only when C is present.
- workers/parse-worker.ts: guarded `_require('tree-sitter-c')` + conditional
  languageMap spread, like swift/dart/kotlin.
- group/extractors/include-extractor.ts: guarded `_require`; getLanguageForFile
  returns null for .c/.h when absent, so C include-extraction degrades to a no-op
  (C++ unaffected).
- extend the registry-import-closure regression test (#2091/#2093) to assert C
  also loads lazily at registry static-import time.

* fix(ci): repin attest-build-provenance to the real v2.4.0 SHA

The workflow pinned actions/attest-build-provenance@bd77c077… commented
`# v2.4.0`, but v2.4.0 is e8998f94… (verified via the GitHub API); bd77c077…
is an untagged mid-stream commit, so the SLSA-attestation step ran unvetted
action code and the comment misrepresented what runs. Repin to the real
v2.4.0 commit and drop the `# PLACEHOLDER-PIN` markers on both this line and
the setup-python pin (a26af69b… is already the correct v5.6.0 — only its
comment was stale). Update the header NOTE accordingly.

* fix(ci): skip the prebuild-PR aggregate when release App secrets are absent

The aggregate job mints a GitHub App token as its first step; with
RELEASE_APP_ID/RELEASE_APP_PRIVATE_KEY unset it hard-failed AFTER a full
(up-to-6-runner) native build. Since the `secrets` context isn't available in
a job-level `if:`, the guard job now computes a `release_app` boolean output
(a step can read secrets) and emits an actionable `::notice::`; aggregate
gates on it and skips cleanly, while the build job's artifacts still upload
(run with open_pr=false for artifacts-only).

* chore(ci): drop package-lock.json from the prebuild paths filter; widen build timeout

`gitnexus/package-lock.json` changes on nearly every dependency PR, so it
fired the prebuild workflow's guard job on unrelated churn (the matrix stayed
correctly skipped — `gitnexus/package.json` already covers the transition-window
pin, so removing the lock only drops guard noise). Also bump the native build
job timeout 30 -> 45 min for headroom compiling the 23 MB kotlin / 18 MB swift
parser.c, especially under arm emulation.

* fix(ci): event-gate the aggregate open-PR condition explicitly

`inputs.open_pr` is null on pull_request events, and the prior
`inputs.open_pr != false` leg relied on GHA's direction-ambiguous null
coercion (Codex F4) to decide whether to open the prebuild PR. Gate
explicitly on the event: a non-fork pull_request that bumped a grammar
version opens the prebuild PR (the documented flow), and `open_pr` is only
consulted on workflow_dispatch — so a manual run with open_pr=false stays
artifacts-only and no event's behavior rests on coercion.

* fix(publish): validate the effective npm-pack contents in the coverage guard

The publish guard inferred "is source shipped?" from a single .npmignore toggle
line, which a partial/out-of-order edit could defeat (exclude binding.gyp but
leave parser.c → unbuildable yet "source-shipping"). It now inspects the
EFFECTIVE tarball via `npm pack --dry-run --ignore-scripts --json` (the
--ignore-scripts avoids re-entering this guard through prepack): a grammar
"ships source" only when EVERY on-disk source-build input (binding.gyp +
binding.cc + parser.c + scanner.c when present + a tree_sitter header) is
actually in the packed file list.

This also surfaced that the gated lean-publish .npmignore block was inert:
package.json's `files: ["vendor"]` allow-list overrides .npmignore for the
vendored subtree, so those exclusion lines never dropped anything. Replace the
dead toggle with documentation of the real mechanism (narrow the `files` field)
and note the guard enforces safety on the effective pack regardless of how the
slim is done.

* test(prebuild): hard-gate declared-fully-prebuilt grammars on 6/6 coverage

The strict 6/6 prebuild assertion was dormant whenever a grammar vendors source
(binding.gyp) — which is every grammar — so a dropped prebuild passed CI
silently. Add a FULLY_PREBUILT allowlist of grammars GitNexus has committed 6/6
for (today: swift); those must keep all six even with a source fallback, so
losing one now fails CI. Grammars graduate into the set as the
build-tree-sitter-prebuilds workflow lands their binaries. (The static-import
degradation smoke is covered by the registry-import-closure regression test
extended in the C lazy-load commit.)

* chore(deps): promote node-gyp-build/node-addon-api to regular dependencies

Every vendored grammar's index.js does `require("node-gyp-build")` at runtime
to load even a prebuilt .node, so node-gyp-build is runtime-load-critical (and
node-addon-api is needed for the source-build fallback). They were
optionalDependencies, surviving `--omit=optional` only via the required
tree-sitter's transitive edge — correct today but fragile. Promote both to
regular dependencies so the contract is explicit (optionalDependencies is now
empty and removed). Lock the contract with a cli-commands assertion.

* chore(vendor): add Windows cflags parity block to tree-sitter-c/binding.gyp

c's binding.gyp used an unconditional `cflags_c: ["-std=c11"]`, while
kotlin/swift gate MSVC flags behind an `OS=='win'` condition (/std:c11 /utf-8).
Inert today (no non-ASCII bytes in c's parser.c, and node-gyp ignores cflags_c
on MSVC anyway), but align the three so a future source-build fallback on
Windows behaves consistently.

* docs(agents): correct stale optional-grammar / postinstall notes

AGENTS.md still said postinstall "patches tree-sitter-swift, builds
tree-sitter-proto" and that only kotlin/swift are "optional". Update to the
vendored-uniform model: postinstall materializes the vendored grammars and
prefers a committed prebuild (source-build only when none matches); c is
required while dart/proto/swift/kotlin are optional + skippable via
GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1, with non-fatal warnings only on a
toolchain-less host with no matching prebuild.

* fix(install): preserve the backup and warn loudly on a failed materialize rollback

If renameSync(partial, dest) failed AND the rollback renameSync(backup, dest)
also failed, the grammar was left unmaterialized (node_modules/<name> missing)
with only a generic "could not materialize" warning — the recoverable backup at
<dest>.materialize-bak was unmentioned. Emit a CRITICAL warning naming the
backup path and the recovery command on that double-failure, and document that
the fail-soft catch removes only the scratch `partial`, never the `backup`
(which may be the sole recoverable copy). Never-throw / exit-0 contract intact.

* fix(publish): make the coverage guard's npm-pack inspection script-safe

The prepack guard shelled out to `npm pack --dry-run --ignore-scripts --json`,
but the `--ignore-scripts` flag is not reliably honored by npm pack's
prepare/prepack lifecycle on the CI npm — so build.js ran, polluted the --json
stdout with `[build] …`, and the guard's JSON.parse threw. That broke every
`npm pack` (packaged-install-smoke on ubuntu+windows) and failed the guard's own
real-repo unit test (the only coverage-job failure). Force script-skipping via
the reliable `npm_config_ignore_scripts` env config (also removes the prepack
re-entry/recursion risk) and parse defensively from the JSON-array start.

* fix(publish): make the coverage guard deterministic — read `files`, not `npm pack`

The npm-pack-based guard timed out in CI: `npm pack`'s prepare/prepack lifecycle
is not skipped by `--ignore-scripts` (flag or env config) on the CI npm, so the
inner pack ran the full build (~20s+) — fine for the slow smoke job, but it blew
past vitest's 30s test timeout in the coverage job (and risked re-entering this
prepack guard).

Replace it with a deterministic, fast (~0.1s) check that needs no subprocess:
since `files: ["vendor"]` OVERRIDES `.npmignore` for the vendored subtree (so
`.npmignore` can never drop vendored source — verified), the ONLY lever that can
exclude source is narrowing the package.json `files` field. The guard now reads
`files` directly: a grammar "ships source" iff `files` includes the vendor
subtree AND the grammar carries a buildable source set on disk. A lean publish
that narrows `files` while a grammar lacks 6/6 prebuilds still fails the gate.

* feat(ci): vendored tree-sitter grammar update monitor

Adds a weekly (+ dispatchable) workflow that checks each vendored grammar against
its source-of-origin (npm for swift/kotlin, the GitHub default branch for
dart/proto; c is excluded — held at 0.21.4 for ABI safety) and opens a PR
re-vendoring any update that is ABI-COMPATIBLE with the pinned tree-sitter@0.21.1
(LANGUAGE_VERSION 13-14).

ABI awareness is the point: most upstreams have moved to ABI 15 (newer
tree-sitter), so a blind "bump to latest" would open PRs that can't build. The
monitor fetches the candidate source, reads its parser.c LANGUAGE_VERSION, and
only re-vendors 13/14 — incompatible updates are reported (notice + job summary),
never applied. (Confirmed live: dart/proto upstreams are ABI 15 today and are
correctly held; swift/kotlin are current.)

The re-vendor refreshes only the source-build inputs + runtime entrypoints,
preserving the GitNexus-hardened binding.gyp / README / prebuilds; the version
bump then triggers build-tree-sitter-prebuilds.yml, whose ABI-validation is the
final safety net so a subtly-wrong re-vendor can't silently ship. PR creation is
gated on the RELEASE_APP secret (skips with a notice if absent), mirroring the
build aggregate. Unit test locks the ABI gate; the script is import-safe.

* feat(ci): monitor tree-sitter-c too (report-only, ABI-pinned)

c was excluded from the update monitor, so an upstream c update went unnoticed.
Include it, but as report-only via a `hold`: c is ABI-pinned at 0.21.4
(#1242/#858) and must not auto-bump without a tree-sitter runtime upgrade, so an
available c update is detected + surfaced (notice + job summary) but never
auto-PR'd — even if it were ABI-13/14. `--apply c` refuses defensively. (Live:
upstream c is 0.24.1 / ABI 15 today, so c is doubly held — reported, not applied.)

* fix(ci): drop the shell in the grammar monitor's github fetch (CodeQL)

CodeQL flagged the GitHub-tarball fetch — it used `bash -c "gh api …/tarball/$ref
> src.tgz && tar xzf src.tgz"`, interpolating the API-derived ref into a shell
command (the shell-command-injection family: "this shell command depends on an
uncontrolled file name"). Replace it with a shell-free path: capture `gh api`'s
binary tarball as a Buffer via execFileSync, write it to a fixed file, and
extract with execFileSync('tar', …). No shell, no injection surface. Verified the
dart/proto fetch + ABI read still work.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:16:24 +01:00
Nilotpal KashyapandGergő Magyar 1716bf7c1e feat(cli): add gitnexus uninstall to reverse setup (#2060) (#2062)
* feat(cli): add `gitnexus uninstall` to reverse setup (#2060)

`gitnexus uninstall` was documented in #168 but never implemented, so the
CLI rejected it with "error: unknown command 'uninstall'" (#2060).

Add an `uninstall` command that reverses `gitnexus setup` target-by-target:
removes the GitNexus MCP server entries (Cursor, Claude Code, Antigravity,
OpenCode, Codex), the installed skill directories, and the Claude Code /
Antigravity hook entries plus their bundled hook scripts. Edits are surgical
and idempotent — only gitnexus-owned keys/entries/dirs are touched, and JSONC
comments/indentation are preserved. Defaults to a dry-run preview; `--force`
applies. Per-repo indexes and the global npm package are left alone with
printed hints, since both are destructive in ways setup never caused.

Adds i18n entries (en + zh-CN), help wiring, README/CHANGELOG docs, and unit
tests covering MCP/hook/skill/Codex-TOML removal, dry-run, corrupt-file
safety, and the no-op case.

* changelog changes

* changelog changes

* fix(cli): harden uninstall against data-loss edge cases (review #2062)

Address review findings on the uninstall command:

- Empty derived skill name no longer wipes the whole skills dir: a bare
  '.md' source file would make basename() return '', resolving to the
  skills dir itself. Skip empty names in derivation and reject
  empty/'.'/'..'/separator names in removeSkillsFrom.
- Corrupt settings.json no longer orphans the hook: gate the hook-script
  dir removal on status !== 'corrupt' so we don't delete a script while a
  still-registered entry points at it (Claude + Antigravity blocks).
- Hook removal is now element-granular: delete only the gitnexus command
  inside an entry's hooks[], removing the whole entry only when it becomes
  empty. Preserves a user command co-located in the same entry.
- Fallback TOML stripper: also remove descendant sub-tables
  ([mcp_servers.gitnexus.env]), track multiline strings so a bracketed
  line inside a value isn't treated as a header, and stop reflowing
  unrelated blank lines.
- Set process.exitCode=1 on partial failure; add a 10s timeout to
  'codex mcp remove'.

Tests expanded 7 -> 17: empty-skill guard, corrupt-settings hook
preservation, shared-entry hook removal, OpenCode MCP keyPath,
Antigravity MCP + AfterTool hooks, codex-remove success path, TOML
sub-table + multiline-string cases, dry-run for hooks/skills, and the
directory-layout skill branch.

* refactor(cli): share setup/uninstall target map + harden TOML fallback (review #2062)

Maintainer review follow-ups:

- Extract editor target identities into editor-targets.ts (MCP paths/keyPaths,
  Codex TOML section, skill dirs, hook settings/events/needles/script dirs,
  shared detectIndentation). Both setup.ts and uninstall.ts consume it, so a
  target change updates both sides — killing the silent drift hazard.
- Add a setup -> uninstall round-trip integration test that iterates
  getEditorTargets(): setup writes every target, uninstall removes all of them,
  and a co-located user MCP server + user hook survive. Drift tripwire in both
  directions.
- Preview now prints the exact paths it would remove; command output + README
  state skills are matched by bundled gitnexus skill name. (Provenance marker
  deferred to a tracked follow-up.)

Hardening of the hand-rolled Codex TOML fallback (found in code review):
- Strip a section header that has a trailing inline comment (was matched as a
  header but failed the exact classify check -> section left behind while
  reported removed).
- Preserve CRLF line endings instead of rewriting the whole file to LF.
- Fix multiline-string scan: a line with an odd count of BOTH """ and '''
  no longer mis-picks the delimiter and desyncs the scanner (left->right scan).
- removeSkillsFrom guard also rejects absolute names.

Regression tests added for each. Full setup/uninstall suite green.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-09 10:50:18 +01:00
Gergő MagyarandClaude Opus 4.8 f1151660b9 fix(install): graceful Kotlin optional-grammar install + accurate toolchain docs (#2110)
* fix(install): document Kotlin optional-grammar toolchain behavior + graceful install probe

tree-sitter-kotlin is a third-party npm optionalDependency that ships
source-only (no upstream prebuilds) and compiles its native binding via
node-gyp at install. It was the only optional grammar without a GitNexus
install-time probe, and the README's GITNEXUS_SKIP_OPTIONAL_GRAMMARS
"no toolchain needed" note omitted Kotlin entirely. This adds a fail-soft
probe (mirroring the Swift one) that warns clearly and always exits 0 so
install never breaks, wires it into postinstall, and corrects the
optional-grammar docs in README.md and .devcontainer/README.md. Shipping
prebuilt .node binaries (the literal request) needs an upstream/CI build
matrix and is intentionally left as follow-up.

Refs #2107

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

* fix: address PR #2110 tri-review findings (Kotlin optional-grammar install)

Addresses the four P2 findings from the PR #2110 tri-review:

- F1: docs no longer imply GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 skips Kotlin's
  toolchain. npm compiles tree-sitter-kotlin via its own node-gyp-build step
  regardless of that variable; point to `npm install --omit=optional` as the
  real lever (README.md + .devcontainer/README.md).
- F2: the install probe now surfaces its "Kotlin unavailable" guidance on the
  dir-absent branch — the dominant toolchain-less case, where npm prunes the
  failed optional dependency so the package dir is gone at postinstall. Gated on
  npm_config_omit so a deliberate `--omit=optional` stays silent. Still never
  throws or exits non-zero.
- F3: add a behavioral test that executes the probe across its skip /
  dir-absent-warn / dir-absent-omit-silent paths and asserts exit code 0
  (guards the postinstall "never exit non-zero" invariant a static assertion
  cannot).
- F4: reframe prebuilt Kotlin as deferred Swift-parity follow-up — GitNexus
  already vendors its own self-built Swift prebuilds and could do the same for
  Kotlin — tracked in #2107, not an upstream-only blocker.

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-06-09 09:04:01 +01:00
Gergő MagyarandClaude Opus 4.8 288b96f3e5 fix: batch query enrichment, bake FTS extension into CLI image, add FTS memory repro (#2108)
* perf(query): batch per-symbol process/cohesion/content lookups (N+1 -> 2-3)

Port of the local-backend query-batching from gitnexus-enterprise PR #222
into the OSS local MCP backend. The query tool traced each matched symbol
to its processes + cohesion (+ content) with up to 3N sequential pool
round-trips; batch them into 2-3 'WHERE n.id IN $nodeIds' queries keyed
back to each symbol by a prepended 'n.id AS nodeId' column. Output is
identical: the aggregation loop is unchanged, iterates merged in the same
order, and reads pre-fetched maps instead of issuing a query per symbol.

Adaptations over a blind cherry-pick (would otherwise change output):
- per-nodeId first-row community pick replaces the per-symbol LIMIT 1, so
  each symbol keeps its own community (not one for the whole batch);
- batched rows regrouped to the originating merged item by nodeId so the
  JS-side RRF item.score still drives process ranking;
- positional fallbacks shift +1 (process row[1..6], cohesion [1]/[2],
  content [1]); CodeRelation{type:...} relation form kept; IN-list chunked
  at 100 like the impact path.

Adds a regression test asserting per-node community/content association
(func:login keeps comm:auth; func:validate inherits no community).

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

* fix(docker): bake LadybugDB FTS extension into the CLI/serve image

The container runs `serve` under the default `load-only` extension policy
(the read pool pins {policy:'load-only'}), so a runtime LOAD EXTENSION fts
never INSTALLs. Dockerfile.cli copied the extension installer but never ran
it, so the runtime user's HOME had no FTS extension: keyword search
silently degraded (no FTS indexes written, ranking falls back to
vector-only with only a warning field). Same class of footgun fixed for
the Hub image in gitnexus-enterprise PR #222.

Run install-duckdb-extension.mjs as the `node` user with the runtime HOME
so INSTALL fts materializes the extension under $HOME/.lbdb/extension where
the runtime LOAD resolves it offline. Pin ENV HOME=/home/node because
Docker does not derive HOME from USER — without it the build-install and
runtime-load would resolve different paths. Verified locally: INSTALL lands
in $HOME/.lbdb/extension/0.17.0 and a fresh offline load-only
`LOAD EXTENSION fts` resolves it. Dockerfile.web is unaffected (static
frontend, no @ladybugdb backend).

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

* test(lbug): FTS evict->reload RSS repro + inert pool RSS tracing

Settles the gitnexus-enterprise PR #222 root-cause hypothesis for OSS:
does re-running LOAD EXTENSION fts on every pool evict->reload strand the
native FTS arena (unbounded RSS growth in long-lived MCP serve), or does
db.close() reclaim it (bounded by MAX_POOL_SIZE)? Static read could not
decide — the native lbugjs.node binary documents no close->extension-unload
contract.

Adds gitnexus/scripts/bench/fts-evict-reload-rss.mjs: a NATIVE mode that
reproduces the exact native sequence doInitLbug()+closeOne() perform
(open Database -> Connection -> LOAD EXTENSION fts -> QUERY_FTS_INDEX ->
close) across K self-built FTS fixtures, and a --via-pool mode that drives
the real compiled pool (initLbug/executeParameterized/closeLbug) against an
existing analyzed repo. Plus a behavior-neutral GITNEXUS_POOL_RSS_TRACE=1
stderr trace on pool init/close (stdout reserved for MCP JSON-RPC; single
env read when disabled).

RESULT (native, 24 and 40 cycles x 6 fixtures, --expose-gc): PLATEAU. RSS
warms up to ~400 MB then flattens (40-cycle: +36 MB over cycles 1-10, +3 MB
over 30-40; decelerating), not the linear climb a per-reload arena leak
would produce (240 reloads x stranded arena = multi-GB). db.close()
reclaims the FTS arena. The unbounded-leak hypothesis is NOT reproduced for
the OSS path: the pool's LRU eviction + close-on-evict BOUNDS the footprint,
which is exactly the protection the enterprise Hub supervisor lacked (it
opened bridge DBs in-process without eviction -> 15 GB). => plan U4
(worker/process isolation) is NOT justified by this evidence; U1 + U2 are
the only OSS-shared changes. Caveat: small fixtures + awaited close; a
--via-pool run against a large analyzed repo over a long session is the
production-faithful follow-up (instrumentation is in place for it).

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

* fix(review): apply ce-code-review autofix feedback (#222 migration)

Adversarial review found the U3 bench PLATEAU->no-leak conclusion was
over-claimed from a 600-row fixture: a size-proportional FTS-arena leak
would be sub-threshold at that scale. Strengthen the bench and make its
verdict honest:
- scale the fixture (--rows, UNWIND batch insert), probe ALL 5 FTS indexes
  in --via-pool (not 2 of 5), add a --no-await-close variant (the pool
  fire-and-forget close shape), and replace the absolute-delta gate with a
  SLOPE-DECELERATION 3-way verdict (PLATEAU / CLIMB / INCONCLUSIVE) plus
  step-discontinuity detection. At production-representative scale the
  synthetic runs are noisy/INCONCLUSIVE (deceleration argues against an
  UNBOUNDED leak but does not prove bounded), so plan U4 stays GATED on a
  --via-pool run against a real large analyzed repo -- not closed.
- Dockerfile.cli: source the scratch-DB size from ENV GITNEXUS_LBUG_MAX_DB_SIZE
  (single source of truth) and add a build-time verify-only LOAD gate
  that fails the build on a HOME/extension-dir mismatch instead of silently
  degrading runtime keyword search.
- install-duckdb-extension.mjs: additive verify-only mode (LOAD-only in a
  fresh process) + robust size parse; back-compatible with the runtime
  positional-size caller (validated).
- tests: wire func:validate into a second process (proc:beta-flow) so the
  batched STEP_IN_PROCESS row[1..6] positional shift is exercised by a
  genuine multi-process symbol, and assert process ranking. No blast radius
  (75 seed-consuming tests pass).
- pool-adapter.ts: trim the traceRss narrated-code comment (DoD 2.3).

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

* fix(bench): classify a sustained sub-floor RSS slope as INCONCLUSIVE, not PLATEAU

Tri-review P2: the FTS evict->reload verdict short-circuited to PLATEAU
whenever secondHalfSlope < SUSTAIN_FLOOR, BEFORE the deceleration check —
so a sustained (non-decelerating) linear leak below 0.5 MB/cycle was
labeled PLATEAU ("no leak"), the label that would wrongly close plan U4.

Extract median/slopeMbPerCycle/classifyVerdict into a pure, side-effect-free
fts-rss-verdict.mjs (zero imports) so it is unit-testable without loading the
native addon or running the bench, and fix the classifier:
- epsilon-first gate: a truly flat tail (< 0.1 MB/cycle) is PLATEAU regardless
  of decelRatio (guards against over-correcting a real negative into
  INCONCLUSIVE);
- a sustained sub-floor positive slope (>= epsilon, < floor, decelRatio >= 0.6)
  is INCONCLUSIVE — a slow creep RSS cannot distinguish from noise at this
  scale, so the honest label is "not resolved", never a clean PLATEAU;
- the noise floor now scales with the WORKING-SET growth (peak-baseline), not
  the pre-DB baseline RSS (which is interpreter/addon overhead, larger in
  --via-pool mode, and would inflate the floor and HIDE leaks).

Reconcile the stale "per-row-relative delta floor" docstring; add floor +
decelRatio to the MACHINE line. New fts-rss-verdict.test.ts pins all label
boundaries (flat->PLATEAU, sustained-sub-floor->INCONCLUSIVE,
decelerated->PLATEAU, sustained-linear->CLIMB, step->INCONCLUSIVE,
working-set floor, no import side effects). U1 does NOT add detection power
for sub-floor leaks (RSS cannot attribute that magnitude) — it stops the
false PLATEAU and routes that regime to the --via-pool run.

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

* fix(query): signal partial/warning on a real enrichment failure (not benign missing-table)

Tri-review P2: when a batched enrichment query (process/cohesion/content)
threw, it was caught + logged and the chunk's symbols silently fell back to
`definitions` with no signal — the caller could not tell "genuinely
standalone" from "enrichment failed".

Track an `enrichmentDegraded` flag in the three enrichment catch blocks and,
at response build, compose a single `warning` (FTS-missing and/or the
enrichment message, so neither overwrites the other) plus `partial: true`.
Both fields are omitted on the clean path, so the success-path response shape
is byte-identical.

Crucially, the flag fires ONLY for a REAL failure (timeout / lock / native
fault), NOT the benign "no Process/Community table" prepare error — a repo
analyzed without processes/communities is a normal config, and firing
`partial` on every such query would desensitize callers
(isBenignMissingTableError gates it).

New unit test test/unit/query-degraded-signal.test.ts (vi.mock pool-adapter,
override hybrid search to feed one matched symbol, route STEP_IN_PROCESS ->
throw): real failure -> warning+partial+symbol still returned; benign
missing-table -> no signal; FTS-missing + enrichment failure -> both messages
in one warning. Plus a success-path no-warning/no-partial assertion in the
calltool integration test.

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-06-09 08:46:46 +01:00
azizur100389andGergő Magyar 3a4247ec36 feat(cpp): resolve inheritance-lattice member lookup (#2077)
* feat(cpp): resolve inheritance-lattice member lookup

* fix(cpp): harden inheritance-lattice lookup

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-09 06:53:11 +01:00
dependabot[bot] 774cd4d568 chore(deps)(deps): bump @ladybugdb/core in /gitnexus (#2098) 2026-06-09 05:59:57 +01:00
Gergő Magyar 4de4d205dd fix(ingestion): lazy-load optional grammars so analyze never crashes when one is missing (#2091, #2093) (#2101) 2026-06-09 04:58:01 +01:00
Gergő Magyar f2c9e69792 feat(ingestion): M0 — taint/PDG substrate (schema + seams + spikes) (#2080) (#2092) 2026-06-08 18:56:10 +01:00
Copilotandgithub-actions[bot] 689e6ef1f8 chore: Sync Claude plugin manifests with the 1.6.6 release (#2090)
* Initial plan

* fix: sync Claude plugin manifest versions

* test: fold manifest sync check into existing node suite

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

* test: run manifest sync guard in always-on suite

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-08 16:50:09 +01:00
Gergő MagyarandClaude Opus 4.8 2cf7e7fa88 chore: release v1.6.6 (#2075)
Bump gitnexus 1.6.5 -> 1.6.6 and add the 1.6.6 CHANGELOG section
covering ~190 PRs merged since v1.6.5 (range v1.6.5..main).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:44:28 +01:00
xianzuyang9-blipandGergő Magyar 1ca4f15267 fix: declare onnxruntime-common runtime dependency (#2074)
* Declare onnxruntime-common runtime dependency

* Declare onnxruntime-common runtime dependency

* Declare onnxruntime-common runtime dependency

* Declare onnxruntime-common runtime dependency

* Declare onnxruntime-common runtime dependency

* Remove package metadata unit test

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-08 08:44:05 +01:00
Gergő MagyarandClaude Opus 4.8 df5ce1f49b fix(ingestion): close remaining open language parsing-layer coverage gaps (#1919) (#2072)
* fix(c): skip computed #include MACRO instead of emitting a garbage import source (F5)

* fix(cpp): emit a Variable per name for structured-binding declarations (F9)

* fix(dart): extract static const/final class fields (F26)

* fix(dart): capture old-style function typedefs (F28)

* fix(dart): read real top-level variable shape instead of a dead type field (F29)

* fix(kotlin): capture callable references (F47)

* fix(kotlin): anchor infix-call capture to the operator only (F49)

* fix(kotlin): extract secondary constructors as members (F48)

* fix(kotlin): capture destructuring declarations (F51)

* fix(kotlin): index companion-object properties as fields (F52)

* test(kotlin): assert callable-reference coverage runs on the worker path (F47)

* fix(swift): extract protocol property requirements (F75)

* fix(swift): recognize enum_class_body as a method body node (F79)

* test(ingestion): rebaseline swift captures-golden + scope-capture fingerprints (#1919)

* fix(kotlin): attribute secondary-constructor body calls to the Constructor node (#1919 review CF1)

A Kotlin secondary constructor's body executes statements like a method body,
but the registry-primary scope-resolution path had no Function scope or
Constructor def for it. A call inside the body resolved its caller anchor up to
the enclosing Class scope, mis-attributing the CALLS edge to the class rather
than the Constructor.

Add `(secondary_constructor) @scope.function` to the Kotlin scope query so the
body becomes its own scope, and synthesize a `@declaration.constructor` (named
`constructor`, qualified `<Class>.constructor`, with parameter metadata) so the
scope owns a Constructor def that bridges to the structure-phase Constructor node.

Also add an arity-disambiguating lookup key for overloadable callables: two
same-name secondary constructors of different arity (e.g. a zero-arg vs a 2-arg)
share the qualified key whose first-write-wins assignment is source-order-
dependent — so a zero-arg overload could resolve to a sibling. The structure
node id encodes `#<arity>`; mirror that in the bridge keyspace and match by the
def's parameterCount. Same-arity overloads collapse onto one arity key exactly
as before, so no regression there.

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

* fix(kotlin): do not own function-local property bindings under the enclosing class (#1919 review CF3)

Kotlin emits destructuring / loop bindings (`val (a,b) = pair`,
`for ((k,v) in m)`) as `@definition.property` to dodge the block-scope
local-symbol pruner. When such a binding sits inside a method body of a class,
the structure-phase owner walk found the enclosing class and emitted a spurious
HAS_PROPERTY edge (e.g. `C -> k`), treating a function-local as a class member.

Guard the Property owner resolution: if a function-like ancestor is reached
before any class container, the property is function-local and gets no owner
edge (it falls back to a File DEFINES edge). Language-agnostic — genuine class
fields sit directly in the class body with no intervening function, so they
keep their HAS_PROPERTY owner edge.

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

* test(kotlin): guard non-companion property isStatic=false (#1919 review CF4)

Add a field-extraction case for a plain non-companion class
`class C { val x: Int = 1 }` asserting the property `x` has isStatic=false,
guarding the `isInsideKotlinCompanion` walk against false-positives.

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

* refactor(kotlin): dedup type_identifier lookup in extractOwnerName (#1919 review CF5)

The `node.namedChildren.find(c => c.type === 'type_identifier')?.text` lookup was
duplicated across the companion and non-companion branches of the Kotlin
field-extractor's extractOwnerName. Hoist it into a single local, preserving the
existing behavior (anonymous companion falls back to "Companion"; other nodes
prefer the `name` field, else the type_identifier text, else undefined).

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

* fix(dart): capture generic old-style function typedefs (#1919 review CF2)

* test(dart): guard multi-name field count and top-level-var labels (#1919 review CF4)

* docs(swift): correct isStatic comment re multi-modifier hasKeyword (#1919 review CF5)

* test(ingestion): rebaseline dart+kotlin scope-capture fingerprints after review remediation (#1919)

* fix(ingestion): correct CF3 owner-strip boundary set for accessor/init bodies and Dart signatures (#1919 review)

The CF3 property-ownership guard used FUNCTION_NODE_TYPES, which (a) includes
Dart bare signatures (function_signature/method_signature) — over-stripping
every Dart class getter/setter's HAS_PROPERTY owner — and (b) omits Kotlin
anonymous_initializer/getter/setter and Swift computed accessors — under-
stripping destructuring/locals inside init{} and accessor bodies, emitting
spurious Class->local HAS_PROPERTY edges. Introduces a guard-specific
LOCAL_SCOPE_BODY_NODE_TYPES set (signatures excluded, accessor/init bodies
included). Adds Dart accessor-ownership + Kotlin init/accessor destructuring
regression fixtures. Both confirmed on the worker pipeline; no cross-language
regression (1597 cross-language tests green).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:09:43 +01:00
Gergő Magyar 3963c497dd fix(parse): correct worker-pool docs drift + surface worker-side stack on crash (#2068) (#2070) 2026-06-08 07:20:12 +01:00
Gergő MagyarandClaude Opus 4.8 4fc2ffa5d0 refactor(ingestion): delete shadow-mode parity harness (RING4-3, #944) (#2071)
Ring 4 retires the legacy call-resolution DAG. With the legacy resolver
gone (RING4-1 #942, RING4-2 #943), shadow mode has nothing to dual-run
against, so the remaining shadow-mode artifacts are dead code.

- Delete gitnexus-shared/src/scope-resolution/shadow/{diff,aggregate}.ts
  (pure parity comparison logic) and its gitnexus-shared barrel exports.
- Delete the static parity dashboard (gitnexus/shadow-parity-dashboard/),
  which also removes the last GITNEXUS_SHADOW_MODE reference in the repo.
- Delete the shadow-mode unit tests (gitnexus/test/unit/shadow/).
- Scrub stale doc comments referencing the shadow harness / parity
  dashboard / removed legacy run (csharp/php/python/typescript index.ts,
  evidence.ts, module-scope-index.ts).

Already removed by RING4-1/-2 (verified): the shadow harness source and
GITNEXUS_SHADOW_MODE env handling; no CI job published dashboard artifacts.

Historical parity records preserved per acceptance: the CHANGELOG entry
(#918, #923, #951, #972) and the ci.yml RING4-1 note remain. Last
documented parity state is that historical coverage — no live
.gitnexus/shadow-parity/ run data exists in-tree (runtime output only).

Closes #944.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:06:42 +01:00
Abhigyan Patwari f0c292f9e7 perf(ingestion): prune inert local value symbols (#2065) 2026-06-07 14:47:51 +01:00
Gergő Magyar 2dc0cc6398 fix(mcp): prevent sibling-clone repo ID collisions and correct generated MCP tool names (#2067) 2026-06-07 10:57:15 +01:00
dependabot[bot]andGergő Magyar 2b4d6e742b chore(deps)(deps): bump @ladybugdb/core in /gitnexus (#2056)
Bumps [@ladybugdb/core](https://github.com/LadybugDB/ladybug) from 0.16.1 to 0.17.0.
- [Release notes](https://github.com/LadybugDB/ladybug/releases)
- [Commits](https://github.com/LadybugDB/ladybug/compare/v0.16.1...v0.17.0)

---
updated-dependencies:
- dependency-name: "@ladybugdb/core"
  dependency-version: 0.17.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>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-07 06:31:45 +01:00
SparshandGergő Magyar baca749e0b fix(vue): F89 JSDoc fix, F90 dual-script merge, F92 lang plumbing (#1936) (#2050)
* fix(vue): F89 JSDoc fix, F90 dual-script merge, F92 lang plumbing (#1936)

* fix(vue): reviewer fixes — P1 lang routing, P2 lineOffset, P2/P3 pipeline tests

* fix(vue): add jsx to lang routing condition

* fix(vue): update F90/F92 fixtures and test assertions for CI

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-07 06:02:30 +01:00
azizur100389 9a40af3d79 fix(java): dedupe inherited RequestMapping prefixes (#2057) 2026-06-07 05:28:10 +01:00
95f87fc12a perf(ingestion): Linux-kernel-scale analysis — worker-pool parse + finalize O(n²) + scope-resolution memory wall (#1983) (#2038)
* fix(ingestion): reduce parse-phase memory for huge repos (#1983)

Stop retaining full parse-cache chunks in RAM alongside the merged graph,
slim on-disk shards, defer worker ParsedFile emission for scope-resolver
languages, and add GITNEXUS_DEBUG_HEAP probes for OOM diagnosis.

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

* fix(ingestion): address #2038 tri-review findings (parse-phase memory)

Resolves the confirmed review findings on PR #2038:

- P1: thread exportedTypeMap through the sequential parse path
  (processParsingSequential) so a no-worker run over a partially-warm
  cache no longer silently drops the sequential-miss files' exported
  types. Cache hits made exportedTypeMap.size > 0, suppressing the
  end-of-loop buildExportedTypeMapFromGraph rebuild, but the sequential
  path never populated the map. Regression test added (fails on the
  pre-fix tree, passes after) plus a fully-sequential differential oracle.
- P2: saveParseCache builds its on-disk index from hashes actually
  written/copied (writtenKeys), never a usedKeys hash whose shard write
  or copy was skipped — no more phantom index entries.
- P2: add a unit test asserting SCOPE_RESOLUTION_LANGUAGES stays in sync
  with SCOPE_RESOLVERS (asymmetric drift would lose a language's ParsedFile).
- Backfill cache coverage: loadParseCacheChunk missing/corrupt -> undefined,
  pruneCache onDiskKeys branch, slim preserves nodes, saveParseCache
  copy-evicted-shard round-trip.
- Cleanups: single-source heap-probe gating via isDebugHeapEnabled();
  hoist the per-chunk mkdir in persistParseCacheChunk behind a
  process-scoped Set; gate COBOL's unused worker-side ParsedFile
  extraction (graph nodes still come from cobolPhase) while keeping
  fileCount/progress unconditional.

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

* refactor(ingestion): remove dead worker-side ParsedFile extraction

After #2038 gated worker `ParsedFile` emission behind `!isScopeResolutionLanguage(language)`, and with all 16 SupportedLanguages registered in SCOPE_RESOLVERS, that gate was structurally always true — the worker already produced no ParsedFiles and scope-resolution re-extracts each file from source on the main thread (run.ts). Remove the now-dead machinery:

- Drop both worker `extractParsedFile` call-sites (tree-sitter processFileGroup + the standalone-provider branch) and the `result.parsedFiles.push`. The standalone branch keeps fileCount/onFileProcessed per file. `result.parsedFiles` stays declared but empty (field removal deferred).
- Remove the now-orphaned `scopeSourceKind` var + `ScopeCaptureSourceKind`/`extractParsedFile`/`isScopeResolutionLanguage` imports.
- Delete the consumerless `migrated-languages.ts` (isScopeResolutionLanguage + SCOPE_RESOLUTION_LANGUAGES) and its drift-guard test — parse-worker was their only importer. Also improves AGENTS.md "shared ingestion code must not name languages" compliance.

`extractParsedFile` and the scope-extractor-bridge stay (scope-resolution/run.ts + Vue resolver use them). Behavior-preserving: worker-sequential-parity passes before and after; tsc/eslint clean; no baseline/golden drift.

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

* refactor(ingestion): worker-pool-only parsing; remove sequential parser (#1983)

Completes the #1983 huge-repo parse-OOM effort by making the worker pool
GitNexus's sole parse path.

Parallel serialization (the perf core): workers serialize their ParsedFiles to
a disk store in parallel and stream them back to scope-resolution, so the main
thread no longer re-parses every file (the tree-sitter native-memory leak that
caused the OOM). Adds chunk merge-pipelining + work-proportional chunk sizing so
the pool stays saturated.

Remove the sequential parser: `--workers 0`, `GITNEXUS_WORKER_POOL_SIZE=0`, and
`skipWorkers` now hard-error (no silent degrade — #1741); the small-repo
threshold no longer selects an in-process path; pool creation stays lazy /
cache-miss-gated so warm all-hit runs never spawn workers.

Worker-path parity fixes — removing sequential surfaced two pre-existing gaps
that tiny-fixture tests had masked by running below the worker threshold, both
fixed by carrying per-file metadata as DATA across the worker boundary (never
re-parsing on the main thread, preserving the OOM fix):
  - C++: templateConstraints wired into worker node identity (SFINAE overload
    disambiguation) + ADL / inline-namespace capture side-channel serialized
    onto the ParsedFile.
  - Kotlin: companion-scope side-channel serialized the same way (companion /
    static dispatch).

Validation: tsc + build clean; full suite green (10,190 pass — the only
deterministic failures were the now-fixed C++/Kotlin worker-path gaps; the 2
remaining full-run failures are pre-existing load flakiness, green in
isolation); cpp-pipeline benchmark stays linear on a 1-worker pool.

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

* fix(ingestion): wire C static-linkage side-channel + ADL O(1) collect + tri-review cleanups (#1983)

Follow-up to the worker-pool-only refactor, from a tri-review of the parse path.

- C static-linkage side-channel (P1): cProvider had no collect/applyCaptureSideChannel,
  so on the now-sole worker path C `static` file-local marks were lost across the worker
  boundary -> false cross-file CALLS edges + over-broad #include wildcard visibility on
  every C analysis (the Linux kernel is C). Mirror the C++/Kotlin wiring: serialize
  `staticNames` per file onto ParsedFile.captureSideChannel and restore it on the main
  thread (no re-parse). + a worker-path regression test (the existing c-static-isolation
  fixture passed vacuously — its collision resolves via #include before the global
  free-call fallback ever consults static-linkage).

- captureSideChannel `kind` discriminant: add `kind:'cpp'`/`kind:'c'` tags + guards
  (Kotlin already had one) now that C/C++/Kotlin share the single generic field.

- Perf: collectCppAdlSideChannel scanned the whole argInfoBySite/noAdlSites maps per file
  (O(F^2) per sub-batch, ~100M parseSiteKey calls at kernel scale). Add per-filePath
  lockstep indexes -> O(1) collect; serialized snapshot byte-identical.

- Cleanups: inline the one-line processParsingWithWorkers wrapper into processParsing;
  drop the always-empty WorkerExtractedData.calls/assignments/constructorBindings fields;
  remove the voided astCache param from processParsing; refresh stale "sequential
  fallback" JSDoc.

Validation: tsc + build clean; cpp 297/297, c 8/8 (incl. the new worker-path
static-linkage guard), typescript + parsedfile-store green; cpp ADL benchmark stays linear.

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

* perf(scope-resolution): index C/C++ #include resolution in finalize (O(n²)→O(n))

Kernel-scale C/C++ analysis ground in finalizeScopeModel because three
per-#include operations each did a full O(F) scan with no index — the
finalize O(n²) that surfaced once the #1983 parse-phase OOM was fixed:

- expand{C,Cpp}WildcardNames: parsedFiles.find() per wildcard edge → O(R·F)
- resolveImportTarget: new Set(allFilePaths) rebuilt per #include
- resolveCImportTarget: suffix-match scanned all workspace paths

Each is replaced with a WeakMap-per-pass index keyed on the stable
parsedFiles/allFilePaths references that scope-resolution run.ts passes
once per pass:

- Map<ScopeId,ParsedFile> for wildcard expansion (c/static-linkage.ts +
  cpp/file-local-linkage.ts)
- memoized augmented header set (c/scope-resolver.ts + cpp/scope-resolver.ts)
- basename-bucketed suffix index in resolveCImportTarget (c/import-target.ts),
  shared by C and C++ since resolveCppImportTarget delegates to it

Collapses the C/C++ finalize from O(R·F) to O(R+F). Pure-perf, byte-identical
edge output: 962 targeted tests green (490 C + 472 C/C++ scope-resolution);
the basename index preserves the exact endsWith('/'+target) match and the
fewest-path-components-then-lexicographic tie-break.

The kernel's ~25-30k .h headers are classified C++, so both providers must
be fixed. Proven on the Linux kernel: the C finalize completed
(sr-post-finalize lang=c → sr-end lang=c), which the pre-fix run never
reached in 16+ min of grinding.

Build-independent follow-ups (separate from this finalize fix), documented
for later: emitFreeCallFallback same-name buckets (emit phase),
buildGraphNodeLookup + precount global setup, the ParsedFile store-load,
the dart/go/ruby expand-wildcards .find siblings, and the ~26GB
scope-resolution memory floor (full kernel completion needs >~40GB RAM).

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

* test(bench): regenerate C scope-capture baseline for the #1983 c-static-linkage-worker fixture

bench/scope-capture/measure.mjs fingerprints emitCScopeCaptures over the
lang-resolution/c-* fixture corpus. The #1983 PR added the
c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — the
worker-path static-linkage side-channel test) but did not regenerate the C
baseline, so `--check` has been red on this branch (main, lacking the
fixture, still matches 0de009b).

Pure fixture-corpus drift — no c/captures.ts or query change branch-vs-main,
existing fixtures' captures byte-identical (c-captures.test.ts 45/45),
scaling stays linear (~0.97). Regenerated: 0de009b -> 39f3a83. Bench now
PASS (14 languages). Unrelated to the finalize O(n²) fix.

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

* perf(scope-resolution): lower kernel-scale resident memory floor + setup cost

Reduce the scope-resolution resident-memory floor and setup throughput on
huge repos (Linux kernel), the wall that remains after #1983 (parse OOM) and
the finalize O(n^2) fix (b71c77b8). Five units; all preserve byte-identical
edge output (C fixture 177n/255e + c/cpp/cross-file/php/static-linkage suites
green, 619 tests).

U1 (src/cli/analyze.ts): RAM-aware auto heap-cap. Replace the hardcoded
16384MB cap with computeHeapCapMb = max(16384, floor(0.75*effectiveRAM)),
where effectiveRAM = min(os.totalmem(), process.constrainedMemory()) with the
unconstrained-sentinel guard. Add --max-semi-space-size=128 on the respawn.
A user-supplied NODE_OPTIONS heap still wins (no re-exec). Verified: 23973MB
on a 31964MB box, 16384 floor on small machines, cgroup-aware, sentinel safe.

U2 (src/storage/parsedfile-store.ts, .../pipeline/phase.ts): export forceGc()
and call it at the per-language eviction boundary, so a finished language's
ParsedFiles are reclaimed before the next language's store-load instead of
collected lazily under the next pass's allocation pressure (which at cap>=RAM
degrades into swap-thrash). Measured on a real drivers/net/ethernet run:
C 2113->894MB and C++ 1754->1057MB reclaimed at the boundary (no fragmentation
defeat). Answers the plan's Open Question 1.

U3 (src/storage/parsedfile-store.ts): intern def objects by nodeId in the load
reviver so a SymbolDefinition's three serialized copies (localDefs /
scope.ownedDefs / scope.bindings[].def) collapse to one shared object on load.
Per-shard def pool (a def's copies are shard-local). Measured ~42% off the
def-object retained heap (3->1; 1.8M->600k distinct objects on 600k defs).

U4 (.../passes/free-call-fallback.ts): memoize pickUniqueGlobalCallable's
post-filter candidate list per (name, callerFilePath), only when no per-caller
visibility filter applies (the list is then a pure function of name+file), so
repeated free calls of one name from a file reuse the same-name-bucket scan
instead of re-walking a potentially huge bucket per site. The cached array is
read-only-consumed by the .filter()-based arity/overload narrowers. Exported
pickUniqueGlobalCallable + buildGlobalCallableIndex and added an equivalence
test (memoized == un-memoized reference for every (name, file, arity),
including warm-cache repeats and cross-file file-local exclusion).

U5 (.../pipeline/phase.ts): replace the O(L*F) per-language precount + repeated
scannedFiles.filter() with a single O(F) partition-by-language pass; bracket
buildGraphNodeLookup with scope-setup-nodeLookup heap probes so the long setup
is no longer silent.

Plan: docs/plans/2026-06-06-001-perf-kernel-scope-resolution-memory-plan.md
(U6 out-of-core global index deferred). Note: the kernel's full C++ pass floor
(~20k headers + the 8.8GB graph) likely still exceeds 24GB by itself, which is
why U6 remains the only unit that clears the wall.

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

* fix(test): match OOM-guidance e2e assertions to the U1 reworded hint

The analyze-heap-oom-e2e real-child-OOM test still asserted the pre-U1
wording ('...out of memory.' + a hardcoded 24576 cap). U1 reworded the hint
to mention the auto heap-cap and use a <MB> placeholder, so the three
toContain substrings no longer matched (the assertion at line 62 failed on
all platforms). Update them to the current message. The unit twin
(analyze-heap-respawn) was already updated in 85bfc216; this integration
test was missed by the targeted local run.

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

* perf(lbug): U6a — deterministic id-sorted graph output behind GITNEXUS_SORT_GRAPH_OUTPUT

First increment of U6 (out-of-core scope-resolution). Adds an optional
deterministic ordering of node + relationship CSV rows by their unique graph
id, behind GITNEXUS_SORT_GRAPH_OUTPUT (default OFF = today's graph-insertion
order, byte-identical — the iterator is returned untouched). With the flag ON
the CSV becomes a pure function of the node/edge SET rather than of emit order.

This is the structural enabler for the windowed/out-of-core resolve (U6b-U6d):
csv-generator.ts:518 currently iterates graph.iterRelationships() in insertion
order with NO terminal sort, so any deviation from parsedFiles-order emit would
change bytes. With U6a on, a windowed emit need only reproduce the same edge
SET, not the global insertion order — removing the single largest byte-identical
hazard from every later windowing step.

Verified: default off keeps the existing csv-pipeline suite byte-identical; on,
node rows are id-sorted and output is independent of graph insertion order
(set-build) with the same node/edge set.

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

* perf(storage): U6d foundation — disk-backed scope store + lazy ScopeTree

Adds scope-index-store.ts: persistScopeShards (per-file scope shards via the
proven mapReplacer + def-interning reviver) + DiskBackedScopeTree, a lazy
ScopeTree that serves getScope from a bounded LRU of decoded shards plus a small
resident skeleton (scopeId -> {shard, childIds, parent}). Exports
makeInterningReviver from parsedfile-store for reuse.

This is the contained, highest-risk mechanism of U6d (out-of-core scope
resolution): the emit passes reach the heavy per-Scope binding payload
(~17-20GB on the kernel) ONLY through scopeTree.getScope (a point lookup) and
getChildren — they never read parsed.scopes directly — so moving that payload to
disk behind getScope is transparent. Every consumer reads a Scope BY VALUE, so a
value-faithful disk round-trip is byte-identical to resolution.

Proven in isolation: DiskBackedScopeTree is value-identical to buildScopeTree
for getScope/getChildren/getParent/getAncestors/has/size across multiple files
and after LRU eviction, and preserves the def-identity collapse (ownedDefs[i]
=== binding.def). Nothing wires it yet (the resolution-pipeline integration is
the next increment) — zero production impact; default off.

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

* perf(scope-resolution): U6d integration — seal scopeTree to disk before emit (GITNEXUS_DISK_SCOPE_INDEX)

Wires the U6d out-of-core scope index into the live pipeline behind
GITNEXUS_DISK_SCOPE_INDEX (default OFF = byte-identical). When on:

- finalize-orchestrator builds a TransitionalScopeTree (validated, fully
  resident) instead of buildScopeTree, so finalize/propagate/resolve are
  unchanged.
- After resolve, before emit, run.ts seals it: persists the scopes to a
  file-sharded scope-index-store, swaps the model's scopeTree to disk-backed
  serving from the inside (the frozen bundle can't be reassigned, but the
  wrapper nulls its own resident backing), and drops the heavy Scope.bindings
  payload from all THREE holders — the model's tree (seal), the caller's
  preExtractedParsedFiles, and run.ts's own parsedFiles (scope-stripped copies
  for emit). Emit reads scopes only via scopeTree.getScope (a point lookup,
  now disk-backed + LRU) — verified it never reads parsed.scopes.

Purpose: lower the per-language resident PEAK (kernel C pass ~20→~12 GB by
moving the ~8-9 GB scope payload to disk) so the analysis fits on smaller-RAM
machines. At >=24 GB the full kernel already fits with U1-U5 (U2's 8.7 GB
inter-language forceGc reclaim keeps each pass under cap) — empirically
confirmed — so this is the sub-24 GB lever, not needed at 24 GB.

Byte-identical evidence: DiskBackedScopeTree/TransitionalScopeTree return
value-identical scopes vs buildScopeTree (getScope/getChildren/getParent/
getAncestors, across files + after LRU eviction + post-seal); emit reads only
getScope + referenceSites; flag-off (394 tests) and flag-on-resident (91 tests)
resolver suites stay green; an end-to-end A/B on a 212-file C+cpp+rust subset
produced identical 17,444 nodes / 31,343 edges with the seal firing per language
(c: 410→141 MB reclaimed). Kernel-scale peak-drop measurement pending the
in-flight verdict run freeing memory.

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

* perf(scope-resolution): U6d — id-back workspaceIndex so the disk seal can reclaim scopes

The kernel run revealed the contained scopeTree seal didn't lower the heap:
WorkspaceResolutionIndex held Scope OBJECTS (classScopeByDefId / moduleScopeByFile),
built from every ParsedFile and live through emit, so the ~28k module + class
scopes stayed pinned past the seal (sr-seal-pre 17,583 -> sr-seal-post 17,771 MB,
no drop). It was the sole residual Scope-object holder (SemanticModel holds none).

Fix: classScopeByDefId / moduleScopeByFile become id-backed ScopeByKeyView
instances — a ReadonlyMap<K, Scope> facade over a K->ScopeId map + the scopeTree,
whose .get fetches via scopeTree.getScope(id). The index now pins only ids, so
once the tree seals to disk the scopes become collectible. Byte-identical: the
view returns the same Scope the resident tree holds (or a value-identical revived
one in disk mode), and iteration keeps the old insertion order. buildWorkspace
ResolutionIndex takes an optional scopeTree (live pipeline passes it); without it
(unit tests) the legacy direct Scope-object maps are returned unchanged.

Verified byte-identical: 733 tests across workspace-index / imported-return-types
/ c / cpp / cross-file / go / java. Kernel peak-drop re-measurement to follow.

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

* perf(scope-resolution): U6d — precompute exportedCallableByName (fix disk-getScope thrash)

The workspaceIndex id-backing freed the kernel scopes but exposed a throughput
collapse: findExportedDefByName's workspace fallback (walkers.ts:1019) scanned
EVERY module scope's bindings per unresolved free call, and under the U6d
disk-backed scopeTree each module-scope access faulted a shard in from disk —
lib ON went ~1min -> ~7.5min.

Fix: precompute the fallback result once into
WorkspaceResolutionIndex.exportedCallableByName (simpleName -> first module-local
callable def, first-file-wins — the exact semantics the scan returned), built
from the resident module-scope bindings at index-build time. findExportedDefByName
now does an O(1) lookup with zero disk reads.

Result: lib ON ~7.5min -> 21s (cache-warm), byte-identical 17,444/31,343; 758
tests green across workspace-index + c/cpp/cross-file/go/python.

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

* docs: rename cryptic U-unit codes to descriptive names in comments

The plan-unit shorthand (U3/U4/U6a/U6d/...) was meaningless in the code.
Renamed in comments + test descriptions (no behavior change, byte-identical):
  out-of-core scope index   (was U6)
  deterministic output      (was U6a)
  disk-backed scope seal    (was U6d)
  def-object interning      (was U3)
  free-call candidate cache (was U4)
Also renamed throughout the PR title/summary. Pushed commit messages keep
their original U-codes as historical record.

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

* fix(ingestion): durable ParsedFile shards for warm-cache coverage (#2038)

On a warm re-analyze where every chunk is a parse-cache HIT, no parse worker
runs, the run-scoped ParsedFile store is cleared at parse start, and the cached
ParseWorkerResult carries no ParsedFiles (the worker writes them to the store
and empties them from the message). Scope-resolution then found an empty store
and fell back to main-thread extractParsedFile — re-opening the #1983
tree-sitter native-leak OOM the disk store closes (abhigyanpatwari review on
parse-cache.ts).

Fix: workers ALSO write their ParsedFiles to a durable, content-addressed store
(parsedfile-cache/) keyed by chunk hash, mirroring the parse cache's lifecycle
(version-gated by PARSE_CACHE_VERSION, pruned in lockstep to the surviving
keys). On a warm hit the chunk's durable shards are byte-COPIED into the
run-scoped store (no re-parse, no re-serialize -> byte-identical), so
scope-resolution streams them exactly as on a cold run. A coherence gate
re-dispatches the worker whenever a cached chunk's durable shards are missing
(migration / pruned / version-stale) -- never the main-thread extract.

- worker-pool/parse-worker: thread chunkHash through dispatch->job->flush
  (incl. split/requeue) so the worker tags its durable shard by content
- parsedfile-store: durable persist / restore / index / prune API (sibling
  dir, never cleared per run); content-addressing makes stale reuse impossible
- parse-impl: load durable index, gate the cache hit on durable coverage,
  restore on hit, dispatch chunkHash on miss
- run-analyze: prune+save the durable store to the parse cache's surviving keys
- saveParseCache returns its written keys (the durable keepKeys)

Verified on linux/lib: warm preExtractedHits = full coverage (520/207/1, zero
main-thread re-parse), byte-identical cold==warm (17,456n/31,353e), warm 8.5x
faster. New two-run + mixed-mode + coherence-gate regression test.

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

* fix(ingestion): clear stale scope-index-store shards on each seal (#2038)

The disk-backed scope index writes sequential s<n>.json shards into a shared
<storagePath>/scope-index-store/ dir, with the index resetting per
persistScopeShards call. A seal that writes fewer shards than a previous one
(a later language with fewer files, or a re-run of a shrunken repo) left stale
tail shards on disk indefinitely -- never read by the disk-backed tree, but
multi-GB on kernel-scale repos.

Add clearScopeIndexStore() and clear at the start of persistScopeShards: the
previously sealed language has finished emit and been released before the next
seal runs, so its DiskBackedScopeTree never reads those shards again. Unit
tests: a stale prior-run shard is removed, a fewer-files re-seal leaves no tail
shards, and the helper is idempotent.

Addresses abhigyanpatwari review on run.ts (disk hygiene for the
GITNEXUS_DISK_SCOPE_INDEX path).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:46:34 +01:00
evolution 3b43eb8b47 fix(go): capture multi-name declarations (#2032) 2026-06-06 04:47:17 +01:00
bb3642ad5f fix(rust): F70 — replace struct_expression name:(_) with three specific patterns (#2051)
* fix(rust): F70 — replace struct_expression name:(_) with 3 specific patterns

* fix(rust): F70 — cover scoped+turbofish struct literals (foo::Bar::<T> {})

The three patterns enumerate struct_expression.name as type_identifier /
scoped_type_identifier / generic_type_with_turbofish, but
generic_type_with_turbofish.type can itself be a scoped_identifier
(e.g. foo::Bar::<i32> {}), which the turbofish pattern — requiring
type:(type_identifier) — did not match. That dropped the constructor
reference entirely (verified: emitRustScopeCaptures returns 0 ctors for
foo::Bar::<i32> {} and a::b::Bar::<i32> {}).

Add a fourth pattern that captures the trailing identifier of the scoped
turbofish path (scoped_identifier.name is an identifier, not a
type_identifier), and correct the comment that claimed all cases were
covered.

Strengthen rust-f70.test.ts: assert exactly one constructor per case, add
negative assertions guarding against the old full-path capture, and add
the scoped+turbofish and crate:: cases.

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-06-05 17:46:07 +01:00
Anton FedotovandGergő Magyar 782f70cc07 feat(wiki): add opencode local provider (#2039)
* feat(wiki): add opencode local provider

* style(wiki): format local cli client

* fix(wiki): harden opencode event parsing

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-05 09:24:00 +01:00
jwcrystal 22304cd4a4 fix(mcp): prevent orphan processes by handling stdin close/end and startup race condition (#2049)
* fix(mcp): prevent orphan processes by handling stdin close/end and startup race condition

Three gaps in stdin EOF handling:

1. Startup race: parent can die before `process.stdin.on("end", ...)` is
   registered, so the event is missed entirely.
2. Missing "close" event: when pipe is forcibly closed (parent SIGKILL),
   "close" fires without "end" on some platforms.
3. Transport layer did not propagate stdin termination to its onclose
   callback.

Fixes:
- Check readableEnded/destroyed in start() before registering listeners.
- Register stdin end+close listeners in CompatibleStdioServerTransport.
- Add _closed guard for idempotent close().
- Throw if start() is called after close().
- Add process.stdin.on("close") in server.ts alongside existing handlers.
- Add 5 regression tests.

* fix(mcp): register stdin shutdown before server connect
2026-06-05 08:36:28 +01:00
89b02286ad fix(csharp): qualified/alias constructor names, : base/: this initializers, generic type-arg strip (#2046)
* fix(csharp): bind qualified constructor names, capture : base/: this, fix generic strip

Mirrors the Java #1928 parsing-layer fixes for the C# scope-resolution path —
the same three defect classes exist verbatim in C#:

- Qualified / qualified-generic / alias-qualified constructor calls
  (`new Ns.Foo()`, `new A.B.Foo()`, `new Ns.Box<int>()`, `new MyAlias::Foo()`,
  `new global::Foo()`) bound only `@reference.call.constructor.qualified` with no
  `@reference.name`, so the central extractor fell back to the whole-expression
  anchor and the reference name became the raw `new Ns.Foo()` text (never
  resolved). Derive the simple-name tail via the existing `terminalTypeNameNode`
  helper (handles qualified_name, generic tail, and alias_qualified_name), and
  add a query arm for the top-level `alias_qualified_name` shape that was not
  captured at all.

- `: base(...)` / `: this(...)` explicit constructor initializers, modeled by
  tree-sitter as `constructor_initializer` and never matched by the scope query,
  dropped the chained-constructor CALLS edges. Synthesize them: `this` → enclosing
  type name; `base` → the base type's bare name (first base-list entry, which C#
  requires to be the base class). Arity attached for overload disambiguation.

- `interpretCsharpTypeBinding`'s qualifier strip used `lastIndexOf('.')` over the
  whole string, cutting inside a qualified generic type ARGUMENT
  (`Dictionary<string, Ns.User>` → `User>`). Make stripQualifier generic-aware:
  reduce only the segment before the first `<`, re-attaching the generic suffix —
  multi-arg generics stay intact so the `.Values`/`.Keys` collection-accessor
  unwrap keeps working.

Tests: capture-level unit tests for every constructor shape (incl. alias-qualified,
double-match guard) and `: base`/`: this` (incl. struct/record/mixed-base);
interpretCsharpTypeBinding unit tests (the corruption case + nullable/nested/
unknown-generic edges); end-to-end resolver tests with new fixtures. The
csharp-captures golden was regenerated — drift is purely additive (only the new
fixtures; zero existing-fixture digests changed).

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

* fix(csharp): enhance constructor resolution and namespace qualification

- Implemented qualified constructor name binding to resolve collisions between types in different namespaces.
- Added support for `: base(...)` and `: this(...)` constructor initializers to ensure correct edge emission in the scope resolution.
- Improved generic argument stripping to prevent incorrect parsing of qualified types.
- Introduced tests for new features, including handling of interface-only base classes and qualified constructor calls.

This update addresses issues related to constructor resolution and namespace qualification, ensuring accurate type references in C# code. Tests have been added to validate these changes.

* fix(csharp): implement namespace prefix tagging for file-level type definitions

- Updated the C# ingestion process to tag file-level type definitions with their enclosing namespace path using a new `namespacePrefix` field, without altering the `qualifiedName`.
- Enhanced the scope resolver to utilize the `namespacePrefix` for resolving same-tail collisions in constructor calls, improving accuracy in type resolution.
- Added unit tests to validate the new functionality, ensuring that namespace prefixes are correctly applied to both block-scoped and file-scoped types, while leaving namespace-free types untagged.

This change addresses issues related to namespace qualification and constructor resolution in C# code, facilitating better handling of type references.

* refactor(scope-resolution): share isOverloadableCallable via util

Extract the ctor/function/method overload predicate into
callable-labels.ts so graph-bridge registration and lookup stay aligned
without duplicated private copies in ids.ts and node-lookup.ts.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-05 07:04:57 +01:00
621 changed files with 1405677 additions and 7077 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.3.3",
"version": "1.6.7",
"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."
}
@@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Workflow
```
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
1. query({query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({query: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@@ -28,11 +28,11 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
```
- [ ] Understand the symptom (error message, unexpected behavior)
- [ ] gitnexus_query for error text or related code
- [ ] query for error text or related code
- [ ] Identify the suspect function from returned processes
- [ ] gitnexus_context to see callers and callees
- [ ] context to see callers and callees
- [ ] Trace execution flow via process resource if applicable
- [ ] gitnexus_cypher for custom call chain traces if needed
- [ ] cypher for custom call chain traces if needed
- [ ] Read source files to confirm root cause
```
@@ -40,7 +40,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
| Symptom | GitNexus Approach |
| -------------------- | ---------------------------------------------------------- |
| Error message | `gitnexus_query` for error text → `context` on throw sites |
| Error message | `query` for error text → `context` on throw sites |
| Wrong return value | `context` on the function → trace callees for data flow |
| Intermittent failure | `context` → look for external calls, async deps |
| Performance issue | `context` → find symbols with many callers (hot paths) |
@@ -48,24 +48,24 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Tools
**gitnexus_query** — find code related to error:
**query** — find code related to error:
```
gitnexus_query({query: "payment validation error"})
query({query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
**gitnexus_context** — full context for a suspect:
**context** — full context for a suspect:
```
gitnexus_context({name: "validatePayment"})
context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates (external API!)
→ Processes: CheckoutFlow (step 3/7)
```
**gitnexus_cypher** — custom call chain traces:
**cypher** — custom call chain traces:
```cypher
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
@@ -75,11 +75,11 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. gitnexus_query({query: "payment error handling"})
1. query({query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError
2. gitnexus_context({name: "validatePayment"})
2. context({name: "validatePayment"})
→ Outgoing calls: verifyCard, fetchRates (external API!)
3. READ gitnexus://repo/my-app/process/CheckoutFlow
@@ -18,8 +18,8 @@ description: "Use when the user asks how code works, wants to understand archite
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
3. query({query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@@ -29,9 +29,9 @@ description: "Use when the user asks how code works, wants to understand archite
```
- [ ] READ gitnexus://repo/{name}/context
- [ ] gitnexus_query for the concept you want to understand
- [ ] query for the concept you want to understand
- [ ] Review returned processes (execution flows)
- [ ] gitnexus_context on key symbols for callers/callees
- [ ] context on key symbols for callers/callees
- [ ] READ process resource for full execution traces
- [ ] Read source files for implementation details
```
@@ -47,18 +47,18 @@ description: "Use when the user asks how code works, wants to understand archite
## Tools
**gitnexus_query** — find execution flows related to a concept:
**query** — find execution flows related to a concept:
```
gitnexus_query({query: "payment processing"})
query({query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
**gitnexus_context** — 360-degree view of a symbol:
**context** — 360-degree view of a symbol:
```
gitnexus_context({name: "validateUser"})
context({name: "validateUser"})
→ Incoming calls: loginHandler, apiMiddleware
→ Outgoing calls: checkToken, getUserById
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
@@ -68,10 +68,10 @@ gitnexus_context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. gitnexus_query({query: "payment processing"})
2. query({query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. gitnexus_context({name: "processPayment"})
3. context({name: "processPayment"})
→ Incoming: checkoutHandler, webhookHandler
→ Outgoing: validateCard, chargeStripe, saveTransaction
4. Read src/payments/processor.ts for implementation details
@@ -36,9 +36,52 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `check` | Check graph invariants such as circular imports |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
```jsonc
{
"repositories": [
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
],
"pagination": {
"total": 437,
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextOffset": 50
}
}
```
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
```text
list_repos {} → repos 1–50, nextOffset 50, hasMore true
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
…
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
```
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.
### Taint findings (`explain`)
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with 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.
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
## Resources Reference
@@ -17,9 +17,9 @@ description: "Use when the user wants to know what will break if they change som
## Workflow
```
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
1. impact({target: "X", direction: "upstream"}) → What depends on this
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
3. gitnexus_detect_changes() → Map current git changes to affected flows
3. detect_changes() → Map current git changes to affected flows
4. Assess risk and report to user
```
@@ -28,11 +28,11 @@ description: "Use when the user wants to know what will break if they change som
## Checklist
```
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
- [ ] impact({target, direction: "upstream"}) to find dependents
- [ ] Review d=1 items first (these WILL BREAK)
- [ ] Check high-confidence (>0.8) dependencies
- [ ] READ processes to check affected execution flows
- [ ] gitnexus_detect_changes() for pre-commit check
- [ ] detect_changes() for pre-commit check
- [ ] Assess risk level and report to user
```
@@ -55,10 +55,10 @@ description: "Use when the user wants to know what will break if they change som
## Tools
**gitnexus_impact** — the primary tool for symbol blast radius:
**impact** — the primary tool for symbol blast radius:
```
gitnexus_impact({
impact({
target: "validateUser",
direction: "upstream",
minConfidence: 0.8,
@@ -73,10 +73,10 @@ gitnexus_impact({
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
```
**gitnexus_detect_changes** — git-diff based impact analysis:
**detect_changes** — git-diff based impact analysis:
```
gitnexus_detect_changes({scope: "staged"})
detect_changes({scope: "staged"})
→ Changed: 5 symbols in 3 files
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
@@ -86,7 +86,7 @@ gitnexus_detect_changes({scope: "staged"})
## Example: "What breaks if I change validateUser?"
```
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
1. impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
@@ -18,10 +18,10 @@ description: "Use when the user wants to review a pull request, understand what
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
@@ -32,10 +32,10 @@ description: "Use when the user wants to review a pull request, understand what
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] detect_changes to map changes to affected execution flows
- [ ] impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
@@ -63,20 +63,20 @@ description: "Use when the user wants to review a pull request, understand what
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
**detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
**impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
@@ -86,20 +86,20 @@ gitnexus_impact({target: "validatePayment", direction: "upstream"})
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
**impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
**context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
@@ -112,20 +112,20 @@ gitnexus_context({name: "validatePayment"})
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
2. detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
3. impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
4. impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
5. context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
@@ -16,9 +16,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru
## Workflow
```
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
2. gitnexus_query({query: "X"}) → Find execution flows involving X
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```
@@ -29,65 +29,65 @@ description: "Use when the user wants to rename, extract, split, move, or restru
### Rename Symbol
```
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
- [ ] gitnexus_detect_changes() — verify only expected files changed
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
```
### Extract Module
```
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
- [ ] context({name: target}) — see all incoming/outgoing refs
- [ ] impact({target, direction: "upstream"}) — find all external callers
- [ ] Define new module interface
- [ ] Extract code, update imports
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
### Split Function/Service
```
- [ ] gitnexus_context({name: target}) — understand all callees
- [ ] context({name: target}) — understand all callees
- [ ] Group callees by responsibility
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
- [ ] impact({target, direction: "upstream"}) — map callers to update
- [ ] Create new functions/services
- [ ] Update callers
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
## Tools
**gitnexus_rename** — automated multi-file rename:
**rename** — automated multi-file rename:
```
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 ast_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```
**gitnexus_impact** — map all dependents first:
**impact** — map all dependents first:
```
gitnexus_impact({target: "validateUser", direction: "upstream"})
impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware, testUtils
→ Affected Processes: LoginFlow, TokenRefresh
```
**gitnexus_detect_changes** — verify your changes after refactoring:
**detect_changes** — verify your changes after refactoring:
```
gitnexus_detect_changes({scope: "all"})
detect_changes({scope: "all"})
→ Changed: 8 files, 12 symbols
→ Affected processes: LoginFlow, TokenRefresh
→ Risk: MEDIUM
```
**gitnexus_cypher** — custom reference queries:
**cypher** — custom reference queries:
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
@@ -98,24 +98,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
| Risk Factor | Mitigation |
| ------------------- | ----------------------------------------- |
| Many callers (>5) | Use gitnexus_rename for automated updates |
| Many callers (>5) | Use rename for automated updates |
| Cross-area refs | Use detect_changes after to verify scope |
| String/dynamic refs | gitnexus_query to find them |
| String/dynamic refs | query to find them |
| External/public API | Version and deprecate properly |
## Example: Rename `validateUser` to `authenticateUser`
```
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits: 10 graph (safe), 2 ast_search (review)
→ Files: validator.ts, login.ts, middleware.ts, config.json...
2. Review ast_search edits (config.json: dynamic reference!)
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
→ Applied 12 edits across 8 files
4. gitnexus_detect_changes({scope: "all"})
4. detect_changes({scope: "all"})
→ Affected: LoginFlow, TokenRefresh
→ Risk: MEDIUM — run tests for these flows
```
@@ -0,0 +1,178 @@
---
name: gitnexus-taint-analysis
description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
---
# CFG & Taint Analysis with GitNexus
Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
graphs, reaching definitions, and intra- + inter-procedural taint. Read this
before touching `gitnexus/src/core/ingestion/cfg/**` or
`gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
## When to Use
- "How does the taint engine work / why is this flow (not) reported?"
- Adding a source, sink, or sanitizer to the model.
- Extending or reviewing the CFG / reaching-defs / taint / summary code.
- Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
- Debugging a false positive or false negative in `--pdg` output.
## The layered substrate (build order)
Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
and a default `analyze` run is **byte-identical** (the golden parity gate is the
hard floor for every change here).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
```
- **Worker-built, main-thread-solved.** The parse worker builds each function's
CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
(plain, structured-clone-safe data — never AST nodes). The main thread runs
the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
OOM).
- **In-phase emit (KTD1).** L1–L4-harvest all run INSIDE the scope-resolution
pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
because the disk-backed ParsedFile store is cleared when that phase ends — a
standalone post-`mro` phase would read empty data. The cross-function fixpoint
(L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
small plain summary data threaded out via `ScopeResolutionOutput`.
- **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
`harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
(no graph, no I/O, no logger; sorted outputs). Snapshot tests and
content-derived edge ids depend on it.
## Intra-procedural taint (L3)
Forward reachability over RD facts from matched **sources** to matched **sinks**,
killed by **sanitizers**. Key design points worth internalizing:
- **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
`exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
precise.
- **Kind-set sanitizer model.** A taint carries a set of *neutralized*
`SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
kill would be a suppressed live injection (the forbidden FN direction).
`path.basename(t)` neutralizes path-traversal only, not command-injection.
- **Statement-level finding identity.** NOT block-pair (block conflation drops
distinct findings; `exec(req.body, req.query)` is two findings).
- Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
`reason` column via the shared versioned codec (`taint/path-codec.ts`).
## Interprocedural taint (L4) — the functional/summary method
The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
reduced to a compact **summary**, and summaries are composed over the already-
resolved `CALLS` graph.
**Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
| Edge | Meaning | Analogue |
|------|---------|----------|
| `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
| `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
| `param→sink` | a param reaches a modelled sink | partial/triggered sink |
| `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
| `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
| `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
**The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
parameter, source)`. Seed from `source→callee-arg`, propagate via
`param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
- **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
lattice (`fn × param × source`), so the worklist converges — a recursive call
just re-proposes an already-visited entry. SCC condensation would only refine
processing order; correctness/termination don't require it.
- **Source-discriminated state (load-bearing).** Key the state by the SOURCE
too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
tainted by source A is marked visited and a later flow from source B is dropped
before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
- **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
site) is fragile; the callee identity is exact and context-insensitivity
taints the callee's param identically at every call site.
- Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
in `reason` via the same codec; confidence < the intra-procedural 1.0.
**Context-insensitivity** is the accepted trade-off at this tier: one summary
per function, return/call-site merging accepted (security-conservative). Expect
some FP from merging; the bigger FN sources are unmodeled features (below).
## Known false-negative classes (documented, deferred)
The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
into a callback is dropped without per-library models (true of CodeQL's JS libs
too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
flows, promise/async-await threading, and **destructured/rest params before a
tainted simple param** (the summary port index is the binding ordinal, not the
formal arg position — needs a formal-param index threaded from the worker
`BindingEntry`). The interprocedural join is also context-insensitive: when one
caller invokes two distinct **same-named callees**, a flow into one
over-attributes to both (sound — over-report, never a missed flow). Absence of a
finding is NOT proof of safety.
## GitNexus-specific gotchas
- **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
`Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
ambiguous → drop (the summary driver counts `unresolved`) rather than
cross-wire.
- **No rel-property index (S1).** Kuzu has no secondary index on relationship
properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
traversed live; `explain` reads it source-anchored + LIMIT-guarded.
- **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
schema (pinned in `security.test.ts`). `explain` enumerates both layers
(cross-function findings carry `interprocedural: true`).
- **One shared codec.** Both the emit path and `explain` import
`taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
fork it. New metadata extends the format WITHIN the version when writer +
reader ship together.
- **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
full writeback. Model content rides `taintModelVersion`.
## Adding a source / sink / sanitizer
Edit the language model in `taint/typescript-model.ts` (registered via the
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
finding (or its absence) in `test/unit/taint/` (real-source harness:
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
`test/integration/cfg/`.
## Validation checklist for any `--pdg` change
```
1. tsc clean (schema additions are exhaustiveness-checked; watch the
api.ts getNodeQuery runtime read-path if a node label is added).
2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
test/integration/cfg) — verify by ISOLATION, not full-suite exit
(known load-flakes). `node scripts/build.js` before worker/integration runs.
3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
5. detect_changes() before commit; impact({direction:'upstream'}) before
editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
```
## Prior art (for deeper design questions)
Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
notation, incl. callback ports); Infer (content-keyed incremental summaries).
+16 -67
View File
@@ -22,26 +22,10 @@
# --format '{{json .Manifest.Digest}}'
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:7c2e711a4f7b02f32d2da16192d5e05aa7c95279be4ce889cff5df316f251c1d
# Build args. We deliberately set no version defaults here. devcontainer.json
# `build.args` is the single source of truth for versions. A standalone
# `docker build .devcontainer/` (for example, a CI smoke test) must pass each
# version with --build-arg. Without a default, the build fails loudly instead of
# silently drifting from the version pinned in devcontainer.json.
ARG CLAUDE_CODE_VERSION
ARG CODEX_VERSION
# Cursor is pinned by version plus a per-arch tarball sha256 hash. The install
# step below verifies that hash. All three values live in devcontainer.json
# build.args. They follow the same rule as the others: one source of truth, and
# no default so the build fails loudly if a value is missing.
ARG CURSOR_VERSION
ARG CURSOR_SHA256_X64
ARG CURSOR_SHA256_ARM64
# Bun is installed via the official remote script (bun.sh/install), pinned by
# version. UNLIKE Cursor and the npm packages, this install path runs an
# UNVERIFIED remote script — there is no tarball-hash check. Chosen explicitly
# at request time over the pin-by-sha256 alternative for install-script
# simplicity. To harden later, switch to a pinned tarball + per-arch sha256 in
# the Cursor style (release artifacts at github.com/oven-sh/bun/releases).
# version. Claude Code and Cursor also use official install scripts (no version
# to pin). To harden Bun: switch to a pinned tarball + per-arch sha256
# (release artifacts at github.com/oven-sh/bun/releases).
ARG BUN_VERSION
ARG TZ=UTC
ARG USERNAME=node
@@ -50,10 +34,7 @@ ARG USERNAME=node
# read them. We deliberately do not set CLAUDE_CONFIG_DIR here. Its one true
# value lives in devcontainer.json `containerEnv`, and the runtime value wins
# anyway.
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
CODEX_VERSION=${CODEX_VERSION} \
CURSOR_VERSION=${CURSOR_VERSION} \
BUN_VERSION=${BUN_VERSION} \
ENV BUN_VERSION=${BUN_VERSION} \
BUN_INSTALL=/home/${USERNAME}/.bun \
TZ=${TZ} \
DEVCONTAINER=true \
@@ -86,51 +67,19 @@ RUN mkdir -p \
USER ${USERNAME}
# Install Claude Code and the Codex CLI globally, as the `node` user. The base
# image sets /usr/local/share/npm-global as the npm-global prefix and makes the
# `npm` group writable by `node`. So `npm install -g` works without sudo. Both
# versions come from build args. To upgrade, bump them in devcontainer.json and
# rebuild.
RUN npm install -g \
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
@openai/codex@${CODEX_VERSION}
# Install Claude Code via the official native installer. Downloads the latest
# self-contained binary for the running platform and places it at
# ~/.local/bin/claude — no Node.js runtime dependency, no version to pin.
RUN curl -fsSL https://claude.ai/install.sh | bash
# Install the Cursor CLI. It is pinned and hash-verified, and we run no remote
# script. The cursor.com/install script just detects os/arch, downloads a
# versioned tarball from
# downloads.cursor.com/lab/<version>/<os>/<arch>/agent-cli-package.tar.gz,
# extracts it, and symlinks `agent`/`cursor-agent` into ~/.local/bin. We do that
# ourselves against a PINNED version plus a per-arch sha256 hash. So the build
# runs no unverified remote code. This matches how we pin the base image and npm
# packages by digest (issue #1451). The download is fail-closed: if the hash
# does not match, the build aborts.
#
# To bump: set CURSOR_VERSION and both CURSOR_SHA256_* in devcontainer.json
# build.args. Get each arch's hash with:
# curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
#
# TARGETARCH is the per-platform build arg that BuildKit sets automatically. It
# must be (re)declared in this stage to be visible. When the build is a
# non-BuildKit `docker build`, TARGETARCH is unset, so we fall back to `dpkg
# --print-architecture`.
ARG TARGETARCH
RUN set -eux; \
arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \
case "$arch" in \
amd64) cursor_arch=x64; cursor_sha="${CURSOR_SHA256_X64}";; \
arm64) cursor_arch=arm64; cursor_sha="${CURSOR_SHA256_ARM64}";; \
*) echo "unsupported architecture for Cursor: $arch" >&2; exit 1;; \
esac; \
url="https://downloads.cursor.com/lab/${CURSOR_VERSION}/linux/${cursor_arch}/agent-cli-package.tar.gz"; \
curl -fSL --retry 3 --max-time 120 -o /tmp/cursor.tgz "$url"; \
echo "${cursor_sha} /tmp/cursor.tgz" | sha256sum -c -; \
dir="/home/${USERNAME}/.local/share/cursor-agent/versions/${CURSOR_VERSION}"; \
install -d "$dir" "/home/${USERNAME}/.local/bin"; \
tar --strip-components=1 -xzf /tmp/cursor.tgz -C "$dir"; \
test -x "$dir/cursor-agent"; \
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/agent"; \
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/cursor-agent"; \
rm -f /tmp/cursor.tgz
# Install the Codex CLI globally via npm. No version pinned — @latest at build
# time. (Codex has no native binary installer; npm is the canonical method.)
RUN npm install -g @openai/codex
# Install the Cursor agent CLI via the official install script. Downloads the
# latest agent-cli-package for the running platform and places `cursor-agent`
# and `agent` into ~/.local/bin — no version or hash to pin.
RUN curl -fsSL https://cursor.com/install | bash
# Install Bun via the official remote installer, pinned by version. The first
# positional arg to `bash` is the release tag (`bun-vX.Y.Z`), so a specific
+2 -2
View File
@@ -310,8 +310,8 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
- **LadybugDB integration tests may fail in containers** (file-locking, `AGENTS.md` § Testing). Default to `npm run test:unit` inside the container; run integration tests on the host. Tracking issue: documented as a known limitation.
- **Single-writer LadybugDB constraint** (`GUARDRAILS.md` § LadybugDB lock). Don't run `gitnexus analyze` on the host and inside the container against the same `.gitnexus/` directory simultaneously — the second writer will get `database busy`.
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift grammars build during `gitnexus`'s `postinstall`. To skip them (loses parsing for those three languages), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` in your shell or add it to `remoteEnv` and rebuild.
- **`tree-sitter-kotlin` warnings on install** are expected (per `AGENTS.md`). Ignore them.
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift/Kotlin are all vendored uniformly: `node-gyp-build` picks a committed GitNexus-built prebuilt `.node` at install time (no compile), and only falls back to compiling from the vendored source during `postinstall` if no prebuild matches the host (then a toolchain is needed). Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` (in your shell or `remoteEnv`, then rebuild) to skip all four; each loses parsing for the affected language(s), and the install still succeeds.
- **`tree-sitter-kotlin`/`tree-sitter-swift` warnings on install** only appear when no prebuild matches the platform-arch (per `AGENTS.md`); they are non-fatal — parsing for that language is simply unavailable.
- **`.mcp.json` works inside the container**: `npx -y gitnexus@latest mcp` resolves cleanly because npm registry is reachable and the workspace bind mount exposes the same `.mcp.json` the host sees.
- **Husky pre-commit fires inside the container** without extra setup. The root `npm install` (run automatically in `postCreateCommand`) installs the hook via `package.json` `prepare`.
-21
View File
@@ -14,16 +14,6 @@
"dockerfile": "Dockerfile",
"context": ".",
"args": {
"CLAUDE_CODE_VERSION": "2.1.156",
"CODEX_VERSION": "0.134.0",
// Cursor: a pinned version plus one sha256 hash per CPU arch. The
// Dockerfile checks the tarball against the hash at build time, so it
// never runs a remote install script. Bump all three values together.
// Re-hash each arch with:
// curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
"CURSOR_VERSION": "2026.05.28-a70ca7c",
"CURSOR_SHA256_X64": "7f8b6a09393e0b84b288cc6952b292fc98d15775f644cc01b0b9aa4f04b268df",
"CURSOR_SHA256_ARM64": "05a0ab361e038729aba25fe7f407531b3e8432912e499d0bffdf1dda0e7833e9",
// Bun: pinned by version. Installed by the official bun.sh/install
// script, which accepts the release tag as its first positional arg
// (`bash -s bun-vX.Y.Z`). UNLIKE Cursor, the install path runs an
@@ -324,17 +314,6 @@
// dependency explicit instead of silently following the default.
"containerEnv": {
"CODEX_HOME": "/home/node/.codex",
"DISABLE_AUTOUPDATER": "1",
// post-create.sh removes `installMethod` from the seeded ~/.claude.json so
// the npm-global binary detects its own install method. This is a backup
// safeguard for Claude Code issue #17289. The install-checks routine probes
// ~/.local/bin/claude just because that directory EXISTS. It does exist
// here, because Cursor drops agent and cursor-agent symlinks there. So even
// when installMethod is non-native, the routine reports a false "claude
// command not found at ~/.local/bin/claude". DISABLE_AUTOUPDATER does NOT
// turn that routine off. DISABLE_INSTALLATION_CHECKS is its dedicated kill
// switch.
"DISABLE_INSTALLATION_CHECKS": "1",
"HISTFILE": "/commandhistory/.zsh_history"
},
+6 -9
View File
@@ -141,15 +141,12 @@ sync_from_host /host/.codex/config.toml /home/node/.codex/config.toml 644
# Seed $HOME/.claude.json from the host, but NOT as a straight copy. That file
# mixes two kinds of state. Some is portable account and onboarding state we
# want to keep: hasCompletedOnboarding, oauthAccount, userID, projects,
# tipsHistory. The rest describes how Claude is installed on the host, and that
# part is never valid here. This image installs Claude with `npm install -g`,
# but the host's `installMethod` (for example "native") makes Claude look for
# ~/.local/bin/claude and fail with
# "claude command not found at /home/node/.local/bin/claude". The fix strips the
# machine-specific fields and forces hasCompletedOnboarding, while handling a
# host file that isn't a JSON object. That logic lives in seed-claude-config.cjs
# so it can be unit-tested and prettier-checked
# (translate-plugin-registries.test.cjs).
# tipsHistory. The rest describes how Claude is installed on the HOST, and that
# part is never valid here — for example the host's `installMethod` value only
# makes sense for the host's binary. The fix strips the machine-specific fields
# and forces hasCompletedOnboarding, while handling a host file that isn't a
# JSON object. That logic lives in seed-claude-config.cjs so it can be
# unit-tested and prettier-checked (translate-plugin-registries.test.cjs).
node "$SCRIPT_DIR/seed-claude-config.cjs"
# Codex auth. Some hosts store credentials in the OS keyring instead of on disk
@@ -0,0 +1,266 @@
#!/usr/bin/env node
/**
* Vendored tree-sitter grammar update monitor.
*
* Checks each vendored grammar against its upstream source-of-origin and, for an
* available AND ABI-compatible update, re-vendors the grammar source in place so
* a PR can be opened. The version bump in vendor/<name>/package.json then triggers
* .github/workflows/build-tree-sitter-prebuilds.yml, which cross-builds + ABI-
* validates the prebuilds — so even an imperfect re-vendor can never silently
* ship: its PR's CI goes red.
*
* ABI awareness is load-bearing. Every grammar is pinned to tree-sitter@0.21.1
* (LANGUAGE_VERSION 13–14, the #1922 gate). Most upstream grammar releases target
* a newer tree-sitter, so a blind "bump to latest" would pull an ABI-incompatible
* parser and open doomed PRs. This monitor fetches the candidate source, reads its
* parser.c `#define LANGUAGE_VERSION`, and only re-vendors when it is 13 or 14;
* incompatible updates are reported (and surfaced as a workflow notice), not
* applied.
*
* Usage:
* node update-vendored-grammars.mjs # detect only → JSON report on stdout
* node update-vendored-grammars.mjs --apply X # re-vendor grammar X in place
*
* tree-sitter-c is MONITORED but report-only (`hold`): it is ABI-pinned at 0.21.4
* (#1242/#858) and must not auto-bump without a tree-sitter runtime upgrade, so an
* available c update is detected + reported but never auto-applied — even if it is
* ABI-13/14. A maintainer re-vendors it deliberately.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const VENDOR = path.join(REPO_ROOT, 'gitnexus', 'vendor');
const COMPATIBLE_ABI = new Set([13, 14]); // tree-sitter@0.21.1 LANGUAGE_VERSION range
// Source-of-origin per grammar. npm grammars resolve `latest` via the registry;
// github grammars (no usable npm release) track the default branch HEAD. A `hold`
// reason makes a grammar report-only: updates are detected + surfaced but never
// auto-applied (c is ABI-pinned and must not move without a runtime upgrade).
const GRAMMARS = {
c: {
name: 'tree-sitter-c',
npm: 'tree-sitter-c',
hold: 'ABI-pinned at 0.21.4 (#1242/#858) — needs a tree-sitter runtime upgrade before bumping',
},
swift: { name: 'tree-sitter-swift', npm: 'tree-sitter-swift' },
kotlin: { name: 'tree-sitter-kotlin', npm: 'tree-sitter-kotlin' },
dart: { name: 'tree-sitter-dart', github: 'UserNobody14/tree-sitter-dart' },
proto: { name: 'tree-sitter-proto', github: 'coder3101/tree-sitter-proto' },
};
const sh = (cmd, args, opts = {}) =>
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
const clean = (v) =>
String(v || '')
.replace(/^[v^~]/, '')
.trim();
function vendoredVersion(g) {
const p = path.join(VENDOR, g.name, 'package.json');
return clean(JSON.parse(fs.readFileSync(p, 'utf8')).version);
}
/** Resolve the upstream candidate: { version, ref, kind }. */
function resolveUpstream(g) {
if (g.npm) {
const version = clean(sh('npm', ['view', g.npm, 'version']));
return { version, ref: version, kind: 'npm' };
}
// github: no reliable release tags here, so track the default branch HEAD sha.
const meta = JSON.parse(sh('gh', ['api', `repos/${g.github}`]));
const branch = meta.default_branch;
const sha = JSON.parse(sh('gh', ['api', `repos/${g.github}/commits/${branch}`])).sha;
// Version key: "<upstreamPkgVersion>-g<sha7>" — safeRef-compatible (no `+`,
// which the build workflow's ref validator rejects) and changes on every commit.
let base = '0.0.0';
try {
const pkg = JSON.parse(
Buffer.from(
JSON.parse(sh('gh', ['api', `repos/${g.github}/contents/package.json?ref=${sha}`])).content,
'base64',
).toString('utf8'),
);
if (pkg.version) base = clean(pkg.version);
} catch {
/* no upstream package.json — base stays 0.0.0 */
}
return { version: `${base}-g${sha.slice(0, 7)}`, ref: sha, kind: 'github' };
}
/** Fetch the candidate source into a temp dir; return the package root. */
function fetchSource(g, ref) {
const work = fs.mkdtempSync(
path.join(os.tmpdir(), `revendor-${Object.keys(GRAMMARS).find((k) => GRAMMARS[k] === g)}-`),
);
if (g.npm) {
sh('npm', ['pack', `${g.npm}@${ref}`, '--silent'], { cwd: work });
const tgz = fs.readdirSync(work).find((f) => f.endsWith('.tgz'));
sh('tar', ['xzf', tgz], { cwd: work });
return path.join(work, 'package');
}
// github tarball at the resolved sha. Download + extract WITHOUT a shell
// (no `bash -c`/redirect): `gh api` writes the binary tarball to stdout, which
// we capture as a Buffer and write to a fixed path, then extract with execFile.
// Avoids the shell-command-injection surface CodeQL flags when an API-derived
// ref is interpolated into a `bash -c` string.
const tgz = path.join(work, 'src.tgz');
fs.writeFileSync(
tgz,
execFileSync('gh', ['api', `repos/${g.github}/tarball/${ref}`], {
maxBuffer: 512 * 1024 * 1024,
}),
);
sh('tar', ['xzf', tgz], { cwd: work });
const dir = fs.readdirSync(work).find((f) => fs.statSync(path.join(work, f)).isDirectory());
return path.join(work, dir);
}
/** Read parser.c's LANGUAGE_VERSION (ABI). Prefer the ABI-14 default parser.c. */
function readAbi(srcRoot) {
const candidates = ['src/parser.c', 'parser.c'];
for (const rel of candidates) {
const p = path.join(srcRoot, rel);
if (!fs.existsSync(p)) continue;
// Read only the head — the #define is near the top.
const head = fs.readFileSync(p, 'utf8').slice(0, 4000);
const m = head.match(/#define\s+LANGUAGE_VERSION\s+(\d+)/);
if (m) return Number(m[1]);
}
return null; // unknown (e.g. parser.c only generated at build time)
}
function detect() {
const report = [];
for (const [key, g] of Object.entries(GRAMMARS)) {
const have = vendoredVersion(g);
let up;
try {
up = resolveUpstream(g);
} catch (err) {
report.push({ grammar: key, error: String(err.message || err) });
continue;
}
const newer = up.kind === 'npm' ? up.version !== have : !have || up.ref.slice(0, 7) !== have;
let abi = null;
if (newer) {
try {
abi = readAbi(fetchSource(g, up.ref));
} catch {
/* fetch/abi best-effort; null = unknown */
}
}
report.push({
grammar: key,
vendored: have,
upstream: up.version,
ref: up.ref,
kind: up.kind,
update: newer,
abi,
abiCompatible: abi == null ? null : COMPATIBLE_ABI.has(abi),
hold: g.hold || null,
// Auto-appliable only when there's an update, the ABI is known-compatible,
// AND the grammar is not on a policy hold (c).
applicable: newer && abi != null && COMPATIBLE_ABI.has(abi) && !g.hold,
});
}
return report;
}
const copyFile = (srcRoot, dest, rel) => {
const from = path.join(srcRoot, rel);
if (!fs.existsSync(from)) return false;
const to = path.join(dest, rel);
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.copyFileSync(from, to);
return true;
};
/**
* Re-vendor one grammar in place from its ABI-compatible upstream candidate.
* Copies ONLY the generated source-build + runtime files; deliberately KEEPS the
* GitNexus-hardened binding.gyp (Windows cflags, target_name), README (vendor
* notice), LICENSE, and prebuilds/ (the build workflow refreshes those). Bumps the
* stripped vendor package.json version + provenance — never re-introduces
* scripts/dependencies (#836/#1728). Returns the new version.
*/
function apply(key) {
const g = GRAMMARS[key];
if (!g) {
console.error(`unknown grammar '${key}'`);
process.exit(2);
}
if (g.hold) {
console.error(
`${key}: report-only (${g.hold}); not auto-applied. Re-vendor manually if intended.`,
);
process.exit(3);
}
const have = vendoredVersion(g);
const up = resolveUpstream(g);
const newer = up.kind === 'npm' ? up.version !== have : !have || up.version !== have;
if (!newer) {
console.error(`${key}: already current (${have}); nothing to apply.`);
process.exit(0);
}
const srcRoot = fetchSource(g, up.ref);
const abi = readAbi(srcRoot);
if (abi == null || !COMPATIBLE_ABI.has(abi)) {
console.error(
`${key}: candidate ${up.version} is ABI ${abi ?? 'unknown'} — not tree-sitter@0.21.1 ` +
`compatible (need 13/14); refusing to re-vendor. Handle manually.`,
);
process.exit(3);
}
const dest = path.join(VENDOR, g.name);
// The source-build inputs + runtime entrypoints that change between versions.
// binding.gyp / README / LICENSE / prebuilds are intentionally NOT touched.
for (const rel of [
'src/parser.c',
'src/scanner.c',
'src/node-types.json',
'src/tree_sitter/alloc.h',
'src/tree_sitter/array.h',
'src/tree_sitter/parser.h',
'bindings/node/binding.cc',
'bindings/node/index.js',
'bindings/node/index.d.ts',
]) {
copyFile(srcRoot, dest, rel);
}
const pkgPath = path.join(dest, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
pkg.version = up.version;
pkg._vendoredBy =
`gitnexus - re-vendored from ${g.npm ? `npm ${g.npm}@${up.version}` : `${g.github}@${up.ref}`} ` +
`by grammar-update-monitor on ABI ${abi}. Source-build inputs (parser.c/scanner.c/src/) refreshed; ` +
`the GitNexus-hardened binding.gyp + vendor README + prebuilds are preserved (prebuilds are ` +
`rebuilt by build-tree-sitter-prebuilds.yml on this version change). No scripts/dependencies here ` +
`(#836/#1728).`;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`${key}: re-vendored ${g.name} → ${up.version} (ABI ${abi}).`);
return up.version;
}
// Run the CLI only when invoked directly (not when imported by a test) — detect()
// makes live network calls, so importing must be side-effect-free.
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
if (process.argv[2] === '--apply') {
apply(process.argv[3]);
} else {
process.stdout.write(JSON.stringify(detect(), null, 2) + '\n');
}
}
export { detect, apply, resolveUpstream, readAbi, vendoredVersion, GRAMMARS, COMPATIBLE_ABI };
@@ -0,0 +1,529 @@
name: Build tree-sitter prebuilds
# Cross-builds the native tree-sitter prebuilds GitNexus vendors itself, so that
# grammars whose upstream packages ship SOURCE ONLY (no usable prebuilds/) never
# require a C/C++ toolchain at a user's install. This is the "no operational
# risk for any tree-sitter grammar" pipeline.
#
# Grammars covered here (the at-risk set — everything else already ships 6
# upstream prebuilds AND stays dependency-review-tracked, so it is left alone).
# All five are vendored under gitnexus/vendor/; `kind` (below) only picks where
# the build job fetches the C source to compile:
# - tree-sitter-c (vendored prebuild-only; built from the published npm
# package — closes upstream's 4/6 ARM gap #2116 for a
# REQUIRED grammar)
# - tree-sitter-dart (vendored source; built from gitnexus/vendor/)
# - tree-sitter-proto (vendored source; built from gitnexus/vendor/)
# - tree-sitter-kotlin (vendored source; built from the published npm package —
# upstream ships source only)
# - tree-sitter-swift (vendored source; built from gitnexus/vendor/ — its
# prebuilds were originally upstream-shipped, now
# GitNexus-cross-built like the rest for uniformity)
#
# Output: gitnexus/vendor/<grammar>/prebuilds/<platform-arch>/<grammar>.node for
# all 6 targets ({linux,darwin,win32}-{x64,arm64}). tree-sitter grammars are
# N-API, so one ABI-stable .node per platform-arch works across all Node majors.
#
# COST DISCIPLINE — this is a HEAVY native matrix (up to 3 grammars x 6 runners,
# incl. macOS + arm64). It is DELIBERATELY NOT wired into normal PR/push CI. It
# runs only:
# 1. on manual dispatch (workflow_dispatch); or
# 2. when a covered grammar's recorded version actually CHANGES — the `guard`
# job is the real gate (it diffs the recorded version vs the PR base); the
# `paths:` filter below only makes ordinary code PRs cost ZERO matrix time.
# Net effect: an ordinary code PR triggers nothing; bumping one grammar costs
# exactly one matrix run for that grammar, which opens a PR committing its rebuilt
# binaries.
#
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
#
# NOTE: every action below is pinned to a release commit SHA (with the matching
# `# vX.Y.Z` tag comment verified against the GitHub API). If a future bump adds
# a new action, pin its real release SHA and allowlist it in .github/zizmor.yml /
# Scorecard before merge.
on:
workflow_dispatch:
inputs:
grammars:
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,swift), or "all".'
required: false
type: string
default: 'all'
ref:
description: 'Upstream version/tag/sha override (only honored when exactly one grammar is selected).'
required: false
type: string
default: ''
force:
description: 'Build even if the recorded version is unchanged (re-cut a broken prebuild).'
required: false
type: boolean
default: false
open_pr:
description: 'Open a PR with the rebuilt prebuilds (false = artifacts only).'
required: false
type: boolean
default: true
pull_request:
branches: [main]
paths:
# Vendored grammars: their version lives in the vendor snapshot package.json.
- 'gitnexus/vendor/tree-sitter-c/package.json'
- 'gitnexus/vendor/tree-sitter-dart/package.json'
- 'gitnexus/vendor/tree-sitter-proto/package.json'
- 'gitnexus/vendor/tree-sitter-kotlin/package.json'
- 'gitnexus/vendor/tree-sitter-swift/package.json'
# Transition window: kotlin's pin still lives here until it is vendored.
- 'gitnexus/package.json'
# Self-test: re-run the guard (normally a no-op) when the recipe changes.
- '.github/workflows/build-tree-sitter-prebuilds.yml'
# Least privilege by default; only `aggregate` opts up.
permissions:
contents: read
# One slot per ref. Collapse PR re-pushes, but never cancel a manual re-cut.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# ── Gate: decide which grammars (if any) need a native rebuild, and emit the
# {grammar x platform-arch} matrix the build job consumes. ───────────────
guard:
name: Decide what to build
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
outputs:
any: ${{ steps.decide.outputs.any }}
matrix: ${{ steps.decide.outputs.matrix }}
release_app: ${{ steps.relapp.outputs.configured }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0 # need base history to diff recorded versions
persist-credentials: false
- name: Decide
id: decide
env:
EVENT: ${{ github.event_name }}
# Untrusted dispatch inputs — read via env only, validated in JS.
INPUT_GRAMMARS: ${{ inputs.grammars }}
INPUT_REF: ${{ inputs.ref }}
FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.force || 'false' }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
node --input-type=module - <<'NODE'
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import { appendFileSync } from 'node:fs';
// Registry of the at-risk grammars this workflow owns. `kind` drives
// how the build job resolves source: 'npm' pulls the published package;
// 'vendored' builds from gitnexus/vendor/<name> (which carries the C
// source + binding.gyp). Extend this list to cover a new grammar.
const REGISTRY = {
// c is vendored prebuild-only but BUILT from the published npm
// package (kind 'npm'), held at 0.21.4 — it closes upstream's 4/6
// ARM gap (#2116) for a REQUIRED grammar that otherwise hard-fails
// install on toolchain-less ARM.
c: { name: 'tree-sitter-c', kind: 'npm' },
dart: { name: 'tree-sitter-dart', kind: 'vendored' },
proto: { name: 'tree-sitter-proto', kind: 'vendored' },
kotlin: { name: 'tree-sitter-kotlin', kind: 'npm' },
// swift is vendored WITH its source (parser.c/scanner.c/binding.gyp),
// so it builds from gitnexus/vendor/ like dart/proto. Its prebuilds
// were originally upstream-shipped; rebuilding them here unifies it.
swift: { name: 'tree-sitter-swift', kind: 'vendored' },
};
const PLATFORMS = [
{ platform_arch: 'linux-x64', os: 'ubuntu-24.04' },
{ platform_arch: 'linux-arm64', os: 'ubuntu-24.04-arm' },
{ platform_arch: 'darwin-arm64', os: 'macos-15' },
{ platform_arch: 'darwin-x64', os: 'macos-15-intel' }, // macos-13 retired Dec-2025; Intel EOL ~Aug-2027
{ platform_arch: 'win32-x64', os: 'windows-2022' },
{ platform_arch: 'win32-arm64', os: 'windows-11-arm' },
];
const clean = (v) => (v || '').replace(/^[\^~]/, '').trim();
const json = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
// Durable version key for a grammar at a checkout root. Prefer the
// vendor snapshot (the post-vendor source of truth); fall back to the
// optionalDependencies pin during the transition window. (A guard keyed
// on the node_modules lock entry would self-disable once a grammar is
// vendored, because that entry is deleted.)
function recordedVersion(root, name) {
const v = json(`${root}/gitnexus/vendor/${name}/package.json`);
if (v && v.version) return clean(v.version);
const pkg = json(`${root}/gitnexus/package.json`);
const od = pkg && (pkg.optionalDependencies || {});
const d = pkg && (pkg.dependencies || {});
return clean((od && od[name]) || (d && d[name]) || '');
}
const event = process.env.EVENT;
const force = process.env.FORCE === 'true';
// Select which grammar shortnames are in play.
let selected;
if (event === 'workflow_dispatch') {
const raw = (process.env.INPUT_GRAMMARS || 'all').trim();
selected = raw === 'all' ? Object.keys(REGISTRY)
: raw.split(',').map((s) => s.trim()).filter(Boolean);
for (const s of selected) if (!REGISTRY[s]) throw new Error(`unknown grammar '${s}'`);
} else {
selected = Object.keys(REGISTRY);
}
// Resolve the base-ref recorded versions (pull_request only) so we can
// diff. On dispatch, base is irrelevant (manual intent / force wins).
const baseRoot = `${process.env.RUNNER_TEMP}/base`;
if (event === 'pull_request') {
const baseSha = process.env.BASE_SHA;
for (const s of selected) {
const name = REGISTRY[s].name;
for (const rel of [`gitnexus/vendor/${name}/package.json`, `gitnexus/package.json`]) {
const dst = `${baseRoot}/${rel}`;
fs.mkdirSync(dst.slice(0, dst.lastIndexOf('/')), { recursive: true });
try {
const buf = execSync(`git show ${baseSha}:${rel}`, { stdio: ['ignore', 'pipe', 'ignore'] });
fs.writeFileSync(dst, buf);
} catch { /* file absent at base — fine */ }
}
}
}
// The single-ref override is only meaningful for a one-grammar dispatch.
const refOverride = clean(process.env.INPUT_REF);
if (refOverride && !(event === 'workflow_dispatch' && selected.length === 1)) {
throw new Error('ref override requires exactly one grammar selected');
}
const safeRef = (r) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(r);
const include = [];
const built = [];
for (const short of selected) {
const { name, kind } = REGISTRY[short];
const head = recordedVersion('.', name);
const ref = refOverride || head;
if (!ref) { console.log(`skip ${short}: no recorded version`); continue; }
if (!safeRef(ref)) throw new Error(`unsafe ref for ${short}: '${ref}'`);
let build = false;
if (event === 'workflow_dispatch') {
build = true; // manual intent (force toggles only the unchanged-guard, which is bypassed here)
} else {
const base = recordedVersion(baseRoot, name);
build = !!head && head !== base;
console.log(`${short}: head='${head || '<absent>'}' base='${base || '<absent>'}' -> ${build ? 'BUILD' : 'skip'}`);
}
if (force) build = true;
if (!build) continue;
built.push(short);
for (const p of PLATFORMS) include.push({ grammar: short, name, kind, ref, ...p });
}
const out = process.env.GITHUB_OUTPUT;
appendFileSync(out, `any=${include.length > 0}\n`);
appendFileSync(out, `matrix=${JSON.stringify({ include })}\n`);
if (include.length === 0) {
console.log('::notice::No covered grammar version changed — skipping native matrix.');
} else {
console.log(`Building: ${built.join(', ')} (${include.length} jobs)`);
}
NODE
# The aggregate job opens a PR via a GitHub App token; without the App
# secrets it would hard-fail AFTER a full native build. Surface their
# presence as a guard output so aggregate skips cleanly (the build job's
# artifacts still upload). secrets aren't available in a job-level `if:`,
# so we compute the boolean here (a step CAN read secrets) and gate on it.
- name: Check release App secret
id: relapp
env:
HAS_APP: ${{ secrets.RELEASE_APP_ID != '' && secrets.RELEASE_APP_PRIVATE_KEY != '' }}
run: |
set -euo pipefail
echo "configured=$HAS_APP" >> "$GITHUB_OUTPUT"
if [ "$HAS_APP" != "true" ]; then
echo "::notice::Release GitHub App secrets (RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY) are not configured — prebuilds will build and upload as artifacts, but the auto-PR is skipped. Provision the App, or run with open_pr=false to suppress this notice."
fi
# ── Build one native prebuild per (grammar, platform-arch). No cross-compile. ─
build:
name: ${{ matrix.grammar }} ${{ matrix.platform_arch }}
needs: guard
if: needs.guard.outputs.any == 'true'
permissions:
contents: read
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.guard.outputs.matrix) }}
runs-on: ${{ matrix.os }}
# 45 (not 30) for headroom: the kotlin parser.c is ~23 MB and swift's ~18 MB,
# and compiling them under emulation on the arm runners is slow.
timeout-minutes: 45
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # this job uploads artifacts (artipacked)
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Ensure Python (arm64 Windows only)
if: matrix.platform_arch == 'win32-arm64'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Build prebuild
id: build
shell: bash
env:
GRAMMAR: ${{ matrix.grammar }}
NAME: ${{ matrix.name }}
KIND: ${{ matrix.kind }}
REF: ${{ matrix.ref }}
PLATFORM_ARCH: ${{ matrix.platform_arch }}
run: |
set -euo pipefail
work="$RUNNER_TEMP/ts-build"
rm -rf "$work"; mkdir -p "$work"; cd "$work"
npm init -y >/dev/null
# node-addon-api must match what the grammar's binding.cc expects.
# GitNexus hoists ^8 for the vendored grammars; npm grammars declare
# their own (do NOT pin it for npm grammars — let the dep resolve it).
if [ "$KIND" = "vendored" ]; then
# Build from the vendored C source (carries parser.c + binding.gyp).
srcdir="$work/$NAME"
cp -R "$GITHUB_WORKSPACE/gitnexus/vendor/$NAME" "$srcdir"
rm -rf "$srcdir/prebuilds" "$srcdir/build" "$srcdir/node_modules"
npm install --no-audit --no-fund --ignore-scripts \
prebuildify@^6 node-gyp@^11 node-addon-api@^8
pkgdir="$srcdir"
export npm_config_node_gyp="$work/node_modules/node-gyp/bin/node-gyp.js"
else
# Pull the published source-only package.
npm install --no-audit --no-fund --ignore-scripts \
"$NAME@${REF}" prebuildify@^6 node-gyp@^11
pkgdir="$work/node_modules/$NAME"
fi
test -f "$pkgdir/binding.gyp" || { echo "::error::no binding.gyp for $NAME@$REF"; exit 1; }
# Drop any prebuilds the package shipped in its own tarball before we
# build. The tree-sitter-org npm grammars (e.g. tree-sitter-c) bundle
# prebuilds/ for all 6 tuples; left in place, the `find ... -print -quit`
# below would pick a non-host tuple (e.g. win32-x64 on a linux runner)
# and the assertion would wrongly fail. prebuildify rebuilds THIS host's
# tuple from the source the tarball also ships. (Vendored grammars are
# already cleaned above; this also covers the npm branch.)
rm -rf "$pkgdir/prebuilds"
# N-API, stripped, single ABI-stable binary for THIS host's arch. No
# `-t <node-version>`: an N-API prebuild is Node-version-agnostic, and
# prebuildify parses a bare `-t 22` as the NUMBER 22 and crashes
# (`v.indexOf is not a function`). prebuildify emits
# prebuilds/<platform>-<arch>/<something>.node.
( cd "$pkgdir" && npx --no-install prebuildify --napi --strip )
out=$(find "$pkgdir/prebuilds" -name '*.node' -print -quit)
test -n "$out" || { echo "::error::prebuildify produced no .node"; exit 1; }
produced=$(basename "$(dirname "$out")")
[ "$produced" = "$PLATFORM_ARCH" ] || { echo "::error::built $produced, expected $PLATFORM_ARCH"; exit 1; }
stage="$RUNNER_TEMP/stage/$GRAMMAR/$PLATFORM_ARCH"; mkdir -p "$stage"
cp "$out" "$stage/$NAME.node"
echo "stage=$stage" >> "$GITHUB_OUTPUT"
- name: Validate the .node loads and parses on this arch
shell: bash
env:
GRAMMAR: ${{ matrix.grammar }}
NAME: ${{ matrix.name }}
PLATFORM_ARCH: ${{ matrix.platform_arch }}
EXPECT_ARCH: ${{ contains(matrix.platform_arch, 'arm64') && 'arm64' || 'x64' }}
run: |
set -euo pipefail
probe="$RUNNER_TEMP/probe"; rm -rf "$probe"
mkdir -p "$probe/prebuilds/$PLATFORM_ARCH"
cp "$RUNNER_TEMP/stage/$GRAMMAR/$PLATFORM_ARCH/$NAME.node" \
"$probe/prebuilds/$PLATFORM_ARCH/$NAME.node"
cd "$probe"
# Pin tree-sitter to the repo's exact runtime peer so an ABI mismatch
# fails HERE, not in a user's install (mirrors the #1922 ABI gate).
# NOT --ignore-scripts: tree-sitter@0.21.1's tarball ships prebuilds for
# the common tuples but NOT linux-arm64 / win32-arm64, so on the arm64
# runners node-gyp-build must source-build the runtime — give it node-gyp
# + node-addon-api to do so. Where tree-sitter ships a prebuild (x64,
# darwin-arm64) node-gyp-build uses it and nothing compiles. The grammar
# .node we built is still loaded as a prebuild; only the runtime peer may
# compile. The grammar-vs-runtime ABI check still fires at setLanguage.
npm install --no-audit --no-fund \
node-gyp-build@^4 node-gyp@^11 node-addon-api@^8 tree-sitter@0.21.1
# The node script is single-quoted on purpose — its ${...} are JS
# template literals read from the environment, not shell expansions.
# shellcheck disable=SC2016
GRAMMAR="$GRAMMAR" EXPECT_ARCH="$EXPECT_ARCH" node -e '
const expect = process.env.EXPECT_ARCH;
// Catch an emulated x64 Node silently mis-passing on an arm64 runner.
if (process.arch !== expect) throw new Error(`runner arch ${process.arch} != ${expect}`);
const snippets = {
c: "int main(void) { return 0; }",
dart: "void main() { print(\"hi\"); }",
proto: "syntax = \"proto3\";\nmessage M { int32 id = 1; }",
kotlin: "fun main() { println(\"hi\") }",
swift: "func greet() { print(\"hi\") }",
};
const lang = require("node-gyp-build")(process.cwd());
const Parser = require("tree-sitter");
const p = new Parser(); p.setLanguage(lang);
const tree = p.parse(snippets[process.env.GRAMMAR]);
if (!tree || !tree.rootNode || tree.rootNode.hasError) {
throw new Error("parse failed/error: " + (tree && tree.rootNode && tree.rootNode.type));
}
console.log("OK", process.env.GRAMMAR, process.platform + "-" + process.arch, tree.rootNode.type);
'
- name: Upload prebuild artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ts-prebuild-${{ matrix.grammar }}-${{ matrix.platform_arch }}
path: ${{ steps.build.outputs.stage }}/${{ matrix.name }}.node
if-no-files-found: error
retention-days: 7
# ── Aggregate every grammar's six prebuilds, assert completeness, open a PR. ─
aggregate:
name: Vendor prebuilds + open PR
needs: [guard, build]
# Open the prebuild PR on a non-fork pull_request that bumped a grammar
# version (the documented version-change -> prebuild-PR flow), or on a manual
# dispatch with open_pr=true. Event-gating is explicit so we never rely on
# GHA coercing a null `inputs.open_pr` on pull_request events (Codex F4):
# `inputs.open_pr` is null off-dispatch, and `null != false` is direction-
# ambiguous, so `open_pr` is only consulted on workflow_dispatch.
if: >-
needs.guard.outputs.any == 'true' &&
needs.guard.outputs.release_app == 'true' &&
github.event.pull_request.head.repo.fork != true &&
(github.event_name == 'pull_request' || inputs.open_pr == true)
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read # actual writes use a short-lived App token below
id-token: write # SLSA provenance attestation
attestations: write
steps:
- name: Mint GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- name: Download all prebuild artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: ${{ runner.temp }}/dl
pattern: ts-prebuild-*
- name: Place prebuilds, assert each built grammar has all 6, write SHA256SUMS
id: place
shell: bash
env:
MATRIX: ${{ needs.guard.outputs.matrix }}
DL: ${{ runner.temp }}/dl
run: |
set -euo pipefail
node --input-type=module - <<'NODE'
import fs from 'node:fs';
import { execSync } from 'node:child_process';
const include = JSON.parse(process.env.MATRIX).include;
const dl = process.env.DL;
const byGrammar = {};
for (const e of include) (byGrammar[e.grammar] ||= { name: e.name, archs: [] }).archs.push(e.platform_arch);
const PLATFORMS = ['linux-x64','linux-arm64','darwin-arm64','darwin-x64','win32-x64','win32-arm64'];
const changed = [];
for (const [grammar, { name }] of Object.entries(byGrammar)) {
const dest = `gitnexus/vendor/${name}/prebuilds`;
// A vendored grammar with 5/6 prebuilds silently breaks node-gyp-build
// on the 6th platform — refuse a partial result.
for (const pa of PLATFORMS) {
const art = `${dl}/ts-prebuild-${grammar}-${pa}/${name}.node`;
if (!fs.existsSync(art)) throw new Error(`missing ${grammar} prebuild for ${pa}`);
fs.mkdirSync(`${dest}/${pa}`, { recursive: true });
fs.copyFileSync(art, `${dest}/${pa}/${name}.node`);
}
execSync(`cd ${dest} && find . -name '*.node' | sort | xargs sha256sum > SHA256SUMS`);
changed.push(name);
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `grammars=${changed.join(',')}\n`);
console.log('Vendored prebuilds for:', changed.join(', '));
NODE
- name: Attest build provenance (SLSA)
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'
- name: Create or update PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GRAMMARS: ${{ steps.place.outputs.grammars }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const { execSync } = require('node:child_process');
const run = (c) => execSync(c, { stdio: ['ignore', 'pipe', 'inherit'] }).toString().trim();
const grammars = process.env.GRAMMARS;
const slug = grammars.replace(/[^a-z0-9]+/gi, '-');
const branch = `chore/vendor-ts-prebuilds-${slug}-${context.runId}`;
run('git add gitnexus/vendor/tree-sitter-*/prebuilds');
if (!run('git status --porcelain -- gitnexus/vendor/tree-sitter-*/prebuilds')) {
core.notice('Prebuilds byte-identical to vendor; nothing to commit.');
return;
}
run('git config user.name "gitnexus-release-bot[bot]"');
run('git config user.email "gitnexus-release-bot[bot]@users.noreply.github.com"');
run(`git checkout -b "${branch}"`);
run(`git commit -m "chore(vendor): rebuild native prebuilds (${grammars})\n\nBuilt by ${process.env.RUN_URL}"`);
const { owner, repo } = context.repo;
const remote = `https://x-access-token:${process.env.GH_TOKEN}@github.com/${owner}/${repo}.git`;
// Plain --force, not --force-with-lease: the branch is ephemeral and
// unique per run (keyed by context.runId), written ONLY by this job, so
// there is no concurrent writer to protect against. --force-with-lease
// would compare against a remote-tracking ref this fresh checkout never
// fetched, so re-running the SAME run (branch already pushed by attempt
// 1) fails with "stale info" instead of overwriting.
run(`git push --force "${remote}" "HEAD:${branch}"`);
const body = [
`Rebuilt the vendored native prebuilds for: **${grammars}**.`,
'',
`Builder run: ${process.env.RUN_URL}`,
'Each `.node` was `require()`-loaded + parsed a real snippet on its target',
'platform-arch before upload. SLSA build-provenance attested; `SHA256SUMS`',
'committed alongside each grammar.',
].join('\n');
const { data: pr } = await github.rest.pulls.create({
owner, repo, head: branch, base: 'main',
title: `chore(vendor): tree-sitter prebuilds (${grammars})`, body,
});
core.info(`Opened PR #${pr.number}`);
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
# persist-credentials: false — this job only reads (tests and syntax
# checks) and never pushes. The setting keeps GITHUB_TOKEN out of
# .git/config, which zizmor flags as the "artipacked" issue.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -57,7 +57,7 @@ jobs:
# persist-credentials: false — this is a read-only build smoke that
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
# which zizmor flags as the "artipacked" issue.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
outputs:
web_changed: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
id: filter
with:
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Configure e2e GitNexus home
run: echo "GITNEXUS_HOME=${RUNNER_TEMP}/gitnexus-home" >> "$GITHUB_ENV"
+5 -5
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ./.github/actions/setup-gitnexus
- run: npx tsc --noEmit
working-directory: gitnexus
@@ -46,7 +46,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ./.github/actions/setup-gitnexus-web
- run: npx tsc -b --noEmit
working-directory: gitnexus-web
@@ -67,7 +67,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Validate workflow concurrency convention
shell: bash
run: |
+1 -1
View File
@@ -125,7 +125,7 @@ jobs:
- name: Checkout (for vitest config)
if: steps.meta.outputs.skip != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
sparse-checkout: gitnexus/vitest.config.ts
sparse-checkout-cone-mode: false
+18 -7
View File
@@ -16,7 +16,7 @@ jobs:
# test-reports artifact (if: always()). The default-persisted token in
# .git/config must not be capturable through that upload (zizmor
# credential-persistence / artipacked audit). The job never pushes.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
@@ -80,7 +80,7 @@ jobs:
steps:
# persist-credentials: false — runs tests only, never pushes (zizmor
# credential-persistence / artipacked audit).
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
@@ -94,8 +94,9 @@ jobs:
# 1. Static, offline: assert every grammar's compiled ABI loads on the
# pinned runtime (check-tree-sitter-upgrade-readiness.py --assert-current).
# 2. Dynamic: run the parser-loader ABI load-smoke on the OS matrix so an
# ABI-incompatible prebuilt (esp. the binary-only Swift vendor, which the
# static check can't introspect) fails on the platform it ships to.
# ABI-incompatible committed vendor prebuilt (e.g. Swift's — the static
# check introspects source, not the shipped .node) fails on the platform
# it ships to.
abi-assert:
name: tree-sitter ABI (${{ matrix.os }})
strategy:
@@ -105,7 +106,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
@@ -137,7 +138,7 @@ jobs:
# from a tarball and never pushes back; the token in .git/config would
# be at risk of leaking through any future artifact-upload step
# (zizmor artipacked audit). Disable upfront.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
@@ -245,7 +246,7 @@ jobs:
# and never pushes; the default-persisted token in .git/config would be at
# risk of leaking through an artifact upload (zizmor credential-persistence
# / artipacked audit). Mirrors the packaged-install-smoke job below.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
@@ -265,6 +266,16 @@ jobs:
run: node --import tsx bench/scope-capture/measure.mjs --check
working-directory: gitnexus
- name: CFG construction time / disk / memory guards (#2081 M1)
# Build-free: asserts collectFunctionCfgs output is unchanged
# (fingerprint) and that wall-time, cfgSideChannel disk bytes, AND
# retained heap all stay sub-quadratic for the straight-line /
# many-functions / branchy scenarios. Catches an O(n^2) re-regression in
# the per-function CFG builder (e.g. an extendBlock concat chain) and a
# memory/disk blow-up. --expose-gc enables the retained-heap measurement.
run: node --expose-gc --import tsx bench/cfg/measure.mjs --check
working-directory: gitnexus
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
env:
GITNEXUS_BENCH: '1'
+1 -1
View File
@@ -129,7 +129,7 @@ jobs:
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
persist-credentials: false
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+2 -2
View File
@@ -101,7 +101,7 @@ jobs:
# When triggered by workflow_call the caller passes the RC tag as an input;
# we check out that tag so the Dockerfile and package.json match the built image.
# For tag-push events github.ref is already the tag ref — no override needed.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.tag || github.ref }}
@@ -138,7 +138,7 @@ jobs:
# Required for multi-platform (linux/arm64) emulation.
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Full history needed for the on-push full-history scan; on PRs the
# action diffs against the base ref so the cost is bounded by the PR.
@@ -0,0 +1,146 @@
name: Vendored grammar update monitor
# Periodically checks each vendored tree-sitter grammar against its
# source-of-origin and opens a PR re-vendoring any update that is ABI-COMPATIBLE
# with the pinned tree-sitter@0.21.1 (LANGUAGE_VERSION 13–14, #1922). The version
# bump then triggers build-tree-sitter-prebuilds.yml, which cross-builds + ABI-
# validates the prebuilds — so a re-vendor that is subtly wrong can never silently
# ship: its PR's CI goes red.
#
# ABI-INCOMPATIBLE updates (the common case — upstreams move to newer tree-sitter)
# are reported as a notice + job summary, NOT applied, so the monitor never opens
# doomed PRs. tree-sitter-c is MONITORED but report-only: it is ABI-pinned at
# 0.21.4 (#1242/#858), so an available c update is surfaced (notice + summary) but
# never auto-bumped — a maintainer re-vendors it deliberately after a runtime
# upgrade.
#
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
on:
schedule:
- cron: '17 6 * * 1' # weekly, Monday 06:17 UTC
workflow_dispatch:
# Least privilege; the actual writes use a short-lived App token minted below.
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
monitor:
name: Check upstreams + open update PRs
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
# secrets aren't usable in a job/step `if:`, so compute presence here.
- name: Check release App secret
id: relapp
env:
HAS_APP: ${{ secrets.RELEASE_APP_ID != '' && secrets.RELEASE_APP_PRIVATE_KEY != '' }}
run: echo "configured=$HAS_APP" >> "$GITHUB_OUTPUT"
- name: Mint GitHub App token
id: app-token
if: steps.relapp.outputs.configured == 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Detect updates, re-vendor ABI-compatible ones, open PRs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
HAS_APP: ${{ steps.relapp.outputs.configured }}
# App token writes; falls back to the read-only job token (PRs then skip).
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
with:
github-token: ${{ steps.app-token.outputs.token || github.token }}
script: |
const { execFileSync } = require('node:child_process');
const SCRIPT = '.github/scripts/update-vendored-grammars.mjs';
const run = (cmd, args, opts = {}) =>
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
const report = JSON.parse(run('node', [SCRIPT]));
const { owner, repo } = context.repo;
const hasApp = process.env.HAS_APP === 'true';
const applied = [], held = [], errors = [], skipped = [];
run('git', ['config', 'user.name', 'gitnexus-release-bot[bot]']);
run('git', ['config', 'user.email', 'gitnexus-release-bot[bot]@users.noreply.github.com']);
const baseSha = run('git', ['rev-parse', 'HEAD']).trim();
for (const r of report) {
if (r.error) { errors.push(r); continue; }
if (!r.update) continue;
if (!r.applicable) { held.push(r); continue; } // ABI-incompatible / unknown
const name = `tree-sitter-${r.grammar}`;
const branch = `chore/update-${name}-${r.upstream}`.replace(/[^a-z0-9._/-]+/gi, '-');
// Idempotency: don't reopen an existing PR for this exact version.
const existing = await github.rest.pulls.list({ owner, repo, head: `${owner}:${branch}`, state: 'all' });
if (existing.data.length > 0) { skipped.push({ ...r, reason: 'PR exists' }); continue; }
// Re-vendor in place (refuses + exits non-zero if ABI turns out wrong).
try {
run('node', [SCRIPT, '--apply', r.grammar]);
} catch (e) {
errors.push({ ...r, error: `apply failed: ${String(e.message || e).slice(0, 200)}` });
run('git', ['checkout', '--', 'gitnexus/vendor']);
continue;
}
if (!hasApp) {
skipped.push({ ...r, reason: 'no RELEASE_APP secret — PR not opened' });
run('git', ['checkout', '--', 'gitnexus/vendor']);
continue;
}
const remote = `https://x-access-token:${process.env.GH_TOKEN}@github.com/${owner}/${repo}.git`;
run('git', ['checkout', '-B', branch, baseSha]);
run('git', ['add', `gitnexus/vendor/${name}`]);
run('git', ['commit', '-m', `chore(vendor): update ${name} to ${r.upstream}`]);
run('git', ['push', '--force-with-lease', remote, `HEAD:${branch}`]);
const body = [
`Automated re-vendor of **${name}** to \`${r.upstream}\` (from ${r.kind === 'npm' ? `npm \`${name}\`` : `\`${r.ref}\``}).`,
'',
`Verified ABI **${r.abi}** — compatible with the pinned \`tree-sitter@0.21.1\` (13–14).`,
'Source-build inputs refreshed; the GitNexus binding.gyp / README / prebuilds are preserved.',
'The version bump triggers `build-tree-sitter-prebuilds.yml` to rebuild + ABI-validate the',
'prebuilds — review its result before merging.',
].join('\n');
const pr = await github.rest.pulls.create({
owner, repo, head: branch, base: 'main',
title: `chore(vendor): update ${name} to ${r.upstream}`, body,
});
applied.push({ ...r, pr: pr.data.number });
run('git', ['checkout', '--force', baseSha]);
}
// Summary
const s = core.summary.addHeading('Vendored grammar update monitor');
if (applied.length) s.addRaw(`\n**Opened PRs:** ${applied.map((a) => `${a.grammar}→${a.upstream} (#${a.pr})`).join(', ')}\n`);
if (held.length) s.addRaw(`\n**Held (not auto-applied):** ${held.map((h) => `${h.grammar} ${h.upstream} (${h.hold ? 'report-only: ' + h.hold : 'ABI ' + (h.abi ?? '?') + ' — needs the tree-sitter runtime upgrade'})`).join(', ')}\n`);
if (skipped.length) s.addRaw(`\n**Skipped:** ${skipped.map((x) => `${x.grammar} (${x.reason})`).join(', ')}\n`);
if (errors.length) s.addRaw(`\n**Errors:** ${errors.map((e) => `${e.grammar}: ${e.error}`).join('; ')}\n`);
if (!applied.length && !held.length && !skipped.length && !errors.length) s.addRaw('\nAll vendored grammars are up to date. ✅\n');
await s.write();
for (const h of held) core.notice(`${h.grammar}: update to ${h.upstream} available — ${h.hold ? `report-only (${h.hold})` : `ABI ${h.abi ?? 'unknown'} (need 13/14), held until the tree-sitter runtime upgrade`}.`);
if (!hasApp && (applied.length || skipped.some((x) => /secret/.test(x.reason)))) {
core.notice('RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY not configured — update PRs were not opened. Provision the App to enable auto-PRs.');
}
+1 -1
View File
@@ -336,7 +336,7 @@ jobs:
# Push auth is provided inline at push time via the URL.
- name: Checkout PR head
if: steps.locate.outputs.found == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5.0.4
with:
repository: ${{ steps.locate.outputs.head_repo }}
ref: ${{ steps.locate.outputs.head_sha }}
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# PR head commit (not the synthetic merge ref) — we need the
# exact tree the contributor pushed so suggestions line up.
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
# Pinned to v7.2.0. Verify SHA via:
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
with:
config-name: release-drafter.yml
dry-run: true
+3 -3
View File
@@ -162,7 +162,7 @@ jobs:
should_run: ${{ steps.decide.outputs.should_run }}
head_sha: ${{ steps.decide.outputs.head_sha }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
fetch-tags: true
@@ -332,7 +332,7 @@ jobs:
# on the RC path.
- name: Checkout (RC)
if: needs.route.outputs.mode == 'rc'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
fetch-tags: true
@@ -349,7 +349,7 @@ jobs:
- name: Checkout (stable)
if: needs.route.outputs.mode == 'stable'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
# path performs no git pushes; the default scope is sufficient.
with:
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -37,7 +37,7 @@ jobs:
# Needed to open/update the tracking issue on scheduled runs.
issues: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ./.github/actions/setup-gitnexus
with:
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
sparse-checkout: .github/scripts/triage
sparse-checkout-cone-mode: false
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -53,7 +53,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+9 -9
View File
@@ -80,18 +80,18 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
@@ -173,6 +173,6 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind
### Gotchas
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift, builds tree-sitter-proto). Native bindings need `python3`, `make`, `g++`.
- `tree-sitter-kotlin` and `tree-sitter-swift` are optional — install warnings expected.
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (materializes the vendored grammars into `node_modules/`, then prefers a committed prebuild per platform-arch and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback.
- The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin}` are handled uniformly: c is required; dart/proto/swift/kotlin are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable.
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.
+20 -11
View File
@@ -15,7 +15,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
## End-to-end flow: index → graph → tools
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 12 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 14 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
@@ -77,11 +77,11 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
## Pipeline Phase DAG
12 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
14 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
```
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
→ crossFile → mro → communities → processes
→ crossFile → scopeResolution → pruneLocalSymbols → mro → communities → processes
```
| Phase | File | Deps | Output |
@@ -95,11 +95,13 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
| `mro` | `mro.ts` | `crossFile`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
| `communities` | `communities.ts` | `mro`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `structure` | Process nodes + STEP_IN_PROCESS edges |
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `orm-extraction.ts` (sequential ORM fallback), `types.ts`, `runner.ts`, `index.ts`.
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `types.ts`, `runner.ts`, `index.ts`.
### DAG runner
@@ -119,7 +121,8 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests). `skipWorkers` forces sequential parsing.
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
- **Local-symbol pruning** — `pruneLocalSymbols` removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with `PipelineOptions.keepLocalValueSymbols` or globally with the `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var.
### How to add a new phase
@@ -199,7 +202,11 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
```
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS`, reads per-file Trees from the parse phase's `scopeTreeCache`, disposes the cache at the end.
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` over the worker-serialized `ParsedFile`s. (Per-language `emitScopeCaptures` hooks may reuse a cached Tree via the orchestrator's `treeCache`, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted `ParsedFile` instead; § Performance notes.)
### Optional CFG/PDG emission (`--pdg`, #2081 M1)
On a `--pdg` run, the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript in M1) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits `BasicBlock` nodes + `CFG` edges from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the CFG emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and bounded by a per-function edge cap that logs any dropped edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides in the `CFG` relationship's `reason` (CFG is a single `CodeRelation` type, not one type per kind). See `core/ingestion/cfg/`.
### `ScopeResolver` contract
@@ -248,7 +255,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
### Performance notes
- **Cross-phase Tree cache**: parse phase writes Trees into `scopeTreeCache` (separate from the chunk-local `astCache`) ONLY for languages with `emitScopeCaptures`. Scope-resolution reads from it to skip the second parse. Cleared at end of the phase. Workers leave the cache empty — Trees can't cross MessageChannels; cache miss = fresh parse. `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
- **Cross-phase Tree cache**: the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) lets a scope-resolution per-language hook (`emitScopeCaptures`) reuse a tree instead of re-parsing. Workers leave it empty — Trees can't cross MessageChannels — so in normal (worker-pool) runs scope-resolution does NOT rely on it: workers serialize each file's `ParsedFile` (+ capture side-channel) and stream them in, so scope-resolution consumes the pre-extracted artifact rather than re-parsing on the main thread (§ Chunked parse-and-resolve). `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
- **Typed relationship iteration**: heritage + MRO walk only the EXTENDS / IMPLEMENTS / HAS_METHOD edges via `iterRelationshipsByType`, not the full relationship map.
- **Workspace-resolution-index**: O(1) `findOwnedMember` / `findExportedDef` / `classScopeByDefId` built once per run.
- **SCC-ordered cross-file return-type propagation** (PR #1050): `propagateImportedReturnTypes` walks `indexes.sccs` in reverse-topological order (leaves first), so multi-hop alias chains like `models.User → service.user → app.user` collapse to the terminal class in a single linear pass. Within each importer, the source module's `typeBindings` is chain-followed BEFORE mirroring (so we mirror terminal types, not intermediate refs), and the importer's own `typeBindings` is chain-followed AFTER mirroring (so local `const x = importedFn()` resolves before downstream importers run). Cyclic SCCs reach a partial fixpoint within a single pass without iterating to convergence — see the `ts-circular` cross-file-binding fixture which only asserts pipeline-no-throw. PROF output (`PROF_SCOPE_RESOLUTION=1`) splits `finalize` from `propagate` so quadratic regressions in the chain-follow surface independently.
@@ -311,7 +318,7 @@ Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSe
### Chunked parse-and-resolve
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
1. Worker pool dispatches files (or sequential fallback via `skipWorkers`)
1. Worker pool dispatches files (the sole parse path — there is no sequential fallback; `skipWorkers`, `--workers 0`, and `GITNEXUS_WORKER_POOL_SIZE=0` are rejected with an actionable error)
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
4. Resolve imports
@@ -321,6 +328,8 @@ Inheritance edges are emitted later, by the scope-resolution phase (`preEmitInhe
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the *sole* parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
### Inheritance and MRO
Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy:
+5
View File
@@ -4,6 +4,11 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
### Fixed
- **Hook db-lock probe no longer strands unkillable `lsof`/`ps` orphans** — the probe's `lsof`/`ps` subprocesses are now wrapped in a self-tested coreutils `timeout`/`gtimeout` (`timeout -k 1 …`), so a hook SIGKILLed by the runner's 10s timeout can no longer leave `lsof` running forever (orphan lifetime bounded at ~3s); `acquireHookSlot` now also gates the probe itself, capping concurrent probes at 3 per repo. Opt out with `GITNEXUS_HOOK_TIMEOUT_PATH=disabled`. (#2163)
- **Hook augment CLI no longer strands orphans either** — `runGitNexusCli` in the Claude, plugin, and Antigravity hook adapters now wraps the `gitnexus augment` subprocess (the longest-lived hook child: 7s local / 12s npx inner budgets) in the same self-tested coreutils `timeout` guard as the probe's `lsof`/`ps`, with a budget of ceil(inner/1000)+1 seconds — strictly above the inner `spawnSync` timeout, so on the supervised path Node's SIGTERM still fires first and observable behavior is unchanged. Once the hook itself has been SIGKILLed the guard takes over, with per-branch semantics: on the direct-exec branches (the CLI is the guard's child) it SIGTERMs at budget and `-k 1` SIGKILLs 1s later; on the npx branches (the CLI is a *grandchild* behind npx) it uses `-s KILL`, SIGKILLing the whole process group at budget — a TERM-first guard there would only kill the obedient npx parent and exit before its `-k` escalation fires, stranding a SIGTERM-immune CLI. Two npx-branch caveats remain (both no worse than the pre-fix behavior, where the grandchild received no signal at all): the group-wide KILL is coreutils semantics, so a busybox `timeout` — which passes the self-test — still signals only its direct child and cannot reach the grandchild; and on the supervised path (hook alive, inner `spawnSync` timeout SIGTERMs the guard) coreutils forwards TERM rather than KILL, so a SIGTERM-immune CLI grandchild is still not reaped there. The guard self-test now also requires exit-status propagation (`sh -c 'exit 42'` must yield 42), so an always-exit-0 stub at `GITNEXUS_HOOK_TIMEOUT_PATH` can no longer be adopted and silently swallow the probe and augment. Windows, `GITNEXUS_HOOK_TIMEOUT_PATH=disabled`, and Unix hosts with no usable coreutils `timeout`/`gtimeout` at all (e.g. macOS without Homebrew coreutils) keep the exact pre-wrap unguarded invocation — every guard-less Unix run, whatever the reason (disabled, nothing usable, probe version skew), is now diagnosed once per hook run under `GITNEXUS_DEBUG`. The Cursor hook is not wrapped yet (it does not install the probe helper) but now reports its slot-saturated skip under `GITNEXUS_DEBUG`. (#2163 follow-up)
### Changed
- Migrated from KuzuDB to LadybugDB v0.15 (`@ladybugdb/core`, `@ladybugdb/wasm-core`)
- Renamed all internal paths from `kuzu` to `lbug` (storage: `.gitnexus/kuzu` → `.gitnexus/lbug`)
+7 -7
View File
@@ -62,18 +62,18 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
+6 -1
View File
@@ -157,7 +157,12 @@ routes between two modes based on the triggering event:
suffix; RC tags are excluded at trigger via a negative glob). Publishes to
the `latest` dist-tag with a changelog-backed GitHub release. Maintainers
are expected to tag from `main` as a convention; the workflow itself does
not enforce branch reachability. No Docker build (RC-only).
not enforce branch reachability. No Docker build (RC-only). Before cutting a
stable release, keep `gitnexus/package.json`,
`gitnexus-claude-plugin/.claude-plugin/plugin.json`,
`.claude-plugin/marketplace.json`, and the matching `CHANGELOG.md` entry in
lockstep — the always-on `gitnexus` unit suite now fails if those manifest
versions drift.
- **Release-candidate mode** — runs on every push to `main` (typically a
merged PR) plus manual `workflow_dispatch`. Docs-only changes are skipped
via `paths-ignore`. Publishes to the `rc` dist-tag with version
+49
View File
@@ -36,6 +36,17 @@ RUN npm ci --prefix gitnexus
# Drop dev dependencies for a smaller runtime layer.
RUN npm prune --omit=dev --prefix gitnexus
# `npm prune` removes anything not in package.json's dependency tree — which
# includes the VENDORED tree-sitter grammars (materialized into node_modules/ by
# postinstall, but not declared as deps) and their freshly-built native bindings.
# The `serve` image analyzes/parses uploaded repos at runtime, so those grammars
# must survive into the runtime layer. Re-run the grammar postinstall here in the
# builder (which still has python3/make/g++ and the hoisted node-addon-api /
# node-gyp-build) to re-materialize + rebuild them after the prune. This is
# load-bearing for tree-sitter-c (a core, REQUIRED grammar now vendored, #2116):
# as a former `dependency` it used to survive prune; vendored, it would not.
RUN npm run postinstall --prefix gitnexus
# -- Runtime -----------------------------------------------------------
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
@@ -67,6 +78,44 @@ COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
# unreachable from $PATH.
RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus
# Bake the LadybugDB FTS extension into the image so BM25 keyword search works
# at runtime. The server runs the default `load-only` extension policy (the read
# pool pins `{ policy: 'load-only' }`), so a runtime `LOAD EXTENSION fts` never
# INSTALLs — the extension must already exist in the runtime user's HOME
# extension dir, or every keyword search silently degrades (no FTS indexes are
# written and ranking falls back to vector-only with only a `warning` field).
# Run the installer as the `node` user with the SAME HOME the server runs under,
# so `INSTALL fts` materializes the extension under `$HOME/.lbdb/extension` where
# the runtime `LOAD` resolves it offline. `ENV HOME` is pinned because Docker
# does not derive HOME from `USER`, so without it build-install and runtime-load
# would resolve different paths. Requires network egress for the one-time
# INSTALL; the build fails loudly if it cannot fetch the extension. The DB-size
# default comes from GITNEXUS_LBUG_MAX_DB_SIZE (single source of truth, matches
# the runtime) — it only sizes the throwaway scratch DB used to run INSTALL.
# The second `--verify-only` step re-LOADs the extension in a FRESH process
# under the same HOME, so a HOME/extension-dir mismatch fails the build here
# rather than silently degrading keyword search to vector-only at runtime.
ENV HOME=/home/node \
GITNEXUS_LBUG_MAX_DB_SIZE=17179869184
RUN su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts" \
&& su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts --verify-only"
# Published runtime assets (in package.json `files`). Placed AFTER the DuckDB
# FTS-extension RUN above so editing hook/skill content does not invalidate that
# network-fetching cache layer; they have no input dependency on it.
# `hooks/`: dist/cli/resolve-invocation.js does
# `require('../../hooks/claude/resolve-analyze-cmd.cjs')` at module load — the
# single source of truth for the npm-11 npx-crash invocation decision (#1939).
# Without it, `gitnexus analyze` inside the image crashes with MODULE_NOT_FOUND
# before it does any work (#2130). `skills/`: the CLI reads the bundled SKILL.md
# templates from `<pkg>/skills/` for `gitnexus analyze --skills` and `gitnexus
# setup`/`uninstall`; absent, those degrade silently (placeholder content / zero
# skills installed). (The web UI bundle `web/`, also in `files`, is deliberately
# NOT shipped: this builder never builds gitnexus-web, so the image is API-only;
# the UI is the separate Dockerfile.web image / hosted app.)
COPY --from=builder --chown=node:node /app/gitnexus/hooks ./gitnexus/hooks
COPY --from=builder --chown=node:node /app/gitnexus/skills ./gitnexus/skills
USER node
# The web UI defaults to http://localhost:4747 - keep that contract.
+14 -7
View File
@@ -117,11 +117,13 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` — those four won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild. See the `tree-sitter-kotlin` note below.
>
> **About `tree-sitter-kotlin`:** like Dart/Proto/Swift, Kotlin is a **vendored** grammar (under `gitnexus/vendor/tree-sitter-kotlin`). Upstream `tree-sitter-kotlin` ships **source only** (no prebuilt binaries), so GitNexus builds the Kotlin platform prebuilds itself (via the `build-tree-sitter-prebuilds` GitHub Actions workflow) and vendors them — the same uniform pipeline now used for Dart, Proto, and Swift (Swift's prebuilds were originally copied from upstream; they're now GitNexus-cross-built too). `node-gyp-build` selects the right `.node` at require time, so **no C/C++ toolchain is needed**. If no prebuild matches your platform-arch, only Kotlin (`.kt`/`.kts`) parsing is unavailable; the rest of `gitnexus` is unaffected.
### MCP Setup
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`.
### Editor Support
@@ -222,7 +224,8 @@ args = ["-y", "gitnexus@latest", "mcp"]
### CLI Commands
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus setup # Configure MCP for detected editors (one-time; use -c to select)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
@@ -236,7 +239,7 @@ gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, be
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
gitnexus analyze --workers <n> # Parse worker pool size (default: cores-1, capped at 16; 0 = sequential)
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16, auto-sized to the repo). 0 is rejected — there is no sequential mode.
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
gitnexus list # List all indexed repositories
@@ -259,6 +262,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
gitnexus group status <name> # Check staleness of repos in a group
```
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
#### Embeddings node limit
@@ -314,7 +319,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| Variable | Default | Effect | Tune when… |
| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size. `0` disables the pool (sequential fallback). Equivalent to `--workers <n>`. | Constrained containers (cgroup CPU limits), CI runners with explicit quotas, or debugging a worker-only crash via `0`. |
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
@@ -328,7 +333,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD`| `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` at install time. | Installing on a host without a C++ toolchain or where Swift prebuilds don't match; you're willing to skip Dart/Proto/Swift parsing. |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
#### Publishing to understand-quickly (opt-in)
@@ -342,7 +347,7 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
| Tool | What It Does | `repo` Param |
| ----------------- | ---------------------------------------------------------------- | ------------ |
| `list_repos` | Discover all indexed repositories | — |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
@@ -698,6 +703,8 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default.
---
## Tool Examples
@@ -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.3.6",
"version": "1.6.7",
"author": {
"name": "GitNexus"
},
+139 -12
View File
@@ -15,7 +15,10 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.js');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
const {
hasGitNexusDbLockedByGitNexusServer,
resolveUnixGuardTimeout,
} = require('./hook-db-lock-probe.cjs');
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
/**
@@ -110,10 +113,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
}
/**
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
* non-error skip paths must stay silent unless the operator explicitly asks
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
*/
function isDebugEnabled() {
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
}
function extractAugmentContext(stderr) {
const output = (stderr || '').trim();
const marker = output.indexOf('[GitNexus]');
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
const debug = isDebugEnabled();
if (debug && output.length > 0) {
// Emit the FULL discarded prefix (everything before the marker, or all of
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
@@ -186,18 +199,90 @@ function extractPattern(toolName, toolInput) {
return null;
}
// Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
// at most one line per (short-lived) hook process, even if a future change
// runs the CLI more than once.
let unguardedCliWarned = false;
/**
* Spawn a gitnexus CLI command synchronously.
* Detects binary on PATH once, then runs exactly once.
*
* SECURITY: Never use shell: true with user-controlled arguments.
* On Windows, invoke gitnexus.cmd directly (no shell needed).
*
* Unix orphan containment (#2163 follow-up): the augment CLI is the
* longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
* npx), so on Unix every CLI-running branch gets the same SIGKILL-surviving
* coreutils `timeout` wrapper as the probe's lsof/ps (the cheap which/where
* PATH check stays unwrapped). The wrapper budget is ceil(inner/1000)+1
* seconds — STRICTLY greater than the inner spawnSync timeout, so on the
* supervised path Node's SIGTERM always fires first and the existing
* error/status contract is untouched. Once the hook itself has been
* SIGKILLed (exactly the orphan case the wrapper exists for), the guard
* semantics differ per branch:
* - direct exec (GITNEXUS_HOOK_CLI_PATH / PATH-installed `gitnexus`; the
* CLI is the guard's CHILD): `-k 1` TERM-first — a SIGTERM-immune CLI
* can hold the guard ~1s past the inner timeout before the `-k` SIGKILL
* escalation reaps it.
* - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
* budget expiry SIGKILLs the whole process group outright. TERM-first
* would kill only the obedient npx parent, making `timeout` reap it and
* return before the `-k` escalation ever fires, stranding a
* SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
* 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
* `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
* on this branch, both bounded by "no worse than pre-fix" (where the
* grandchild received no signal at all): the group-wide SIGKILL is
* coreutils semantics — a busybox `timeout` passes the self-test (it
* has `-k` and propagates exit status) but signals only its direct
* child, so a busybox guard cannot reach the grandchild; and on the
* SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
* guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
* and the guard exits before any KILL fires — so a SIGTERM-immune CLI
* grandchild still escapes in those two cases.
* If the sibling probe predates the resolveUnixGuardTimeout export (version
* skew), the adapter degrades to the unwrapped invocation instead of
* throwing. Windows is deliberately NOT wrapped — there is no coreutils
* timeout to resolve there and the resolver's self-test spawns /bin/sh — so
* on win32 (the gitnexus.cmd / npx.cmd paths) and whenever the guard
* resolves to null (e.g. macOS without Homebrew coreutils — reported once
* under GITNEXUS_DEBUG) the argv stays byte-identical to the pre-wrap
* invocation.
*/
function runGitNexusCli(args, cwd, timeout) {
const isWin = process.platform === 'win32';
// Version-skew guard (#2163 follow-up review): an older sibling probe
// without the resolveUnixGuardTimeout export must degrade to the unwrapped
// invocation — a TypeError here would be swallowed by the caller's catch
// and silently kill the augment.
const guard =
isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
// Diagnose the "stays unwrapped" Unix paths once per hook process: no
// usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
// coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
unguardedCliWarned = true;
process.stderr.write(
'[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
);
}
const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH;
if (hookCli !== undefined && String(hookCli).trim() && fs.existsSync(String(hookCli))) {
return spawnSync(process.execPath, [String(hookCli), ...args], {
const [cmd, cmdArgs] = guard
? [
guard,
[
'-k',
'1',
String(Math.ceil(timeout / 1000) + 1),
process.execPath,
String(hookCli),
...args,
],
]
: [process.execPath, [String(hookCli), ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout,
cwd,
@@ -221,7 +306,12 @@ function runGitNexusCli(args, cwd, timeout) {
}
if (useDirectBinary) {
return spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, {
// A non-null guard implies non-Windows, so the wrapped arm can hardcode
// plain `gitnexus` (the guard resolves it via PATH, like spawnSync does).
const [cmd, cmdArgs] = guard
? [guard, ['-k', '1', String(Math.ceil(timeout / 1000) + 1), 'gitnexus', ...args]]
: [isWin ? 'gitnexus.cmd' : 'gitnexus', args];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout,
cwd,
@@ -229,8 +319,27 @@ function runGitNexusCli(args, cwd, timeout) {
windowsHide: true,
});
}
// npx fallback needs shell on Windows since npx is a .cmd script
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
// npx fallback needs shell on Windows since npx is a .cmd script. The
// wrapped arm leads with `-s KILL` (NOT TERM-first like the direct
// branches above): the CLI here is a grandchild behind npx — see the
// docblock.
const [cmd, cmdArgs] = guard
? [
guard,
[
'-s',
'KILL',
'-k',
'1',
String(Math.ceil((timeout + 5000) / 1000) + 1),
'npx',
'-y',
'gitnexus',
...args,
],
]
: [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,
@@ -266,16 +375,34 @@ function handlePreToolUse(input) {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
if (hasGitNexusServerOwner(gitNexusDir)) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
// as the augment, or concurrent sessions fan out unbounded probe
// subprocesses. Keep the acquire right after the cheap guards above —
// moving it earlier would churn slot files on tool calls that never probe.
const release = acquireHookSlot(gitNexusDir);
if (!release) {
// Normal skip path: all per-repo hook slots are held by concurrent
// sessions. Stay silent for strict hook runners (issue #1913); surface
// the reason only when diagnostics are explicitly requested.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
}
return;
}
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
let result = '';
try {
if (hasGitNexusServerOwner(gitNexusDir)) {
// Normal skip path: the MCP server owns the DB, so the CLI augment would
// contend on the lock. Stay silent for strict hook runners (issue #1913);
// surface the reason only when diagnostics are explicitly requested.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
}
return;
}
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = extractAugmentContext(child.stderr || '');
@@ -366,7 +493,7 @@ function main() {
const handler = handlers[input.hook_event_name || ''];
if (handler) handler(input);
} catch (err) {
if (process.env.GITNEXUS_DEBUG) {
if (isDebugEnabled()) {
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
}
}
@@ -11,6 +11,27 @@
*
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
*
* Unix subprocess containment contract (#2163):
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
* wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
* is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
* survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
* wrapper off deterministically; any other value is adopted only when it
* exists AND passes a one-shot `-k` exit-propagation self-test — otherwise
* resolution FALLS THROUGH to the built-in candidate list (first self-test
* pass wins), so no malformed value of any shape can silently disable
* orphan containment.
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
* A probe before that first query is therefore always false — a known,
* pre-existing race, not a bug in this probe.
* - resolveUnixGuardTimeout is exported so the hook adapters can wrap the
* `gitnexus augment` CLI child — the longest-lived hook subprocess (7s
* local / 12s npx inner budgets) — in the same guard; see runGitNexusCli
* in the adapters (#2163 follow-up).
*/
const fs = require('fs');
@@ -46,6 +67,95 @@ function resolveHookBinary(tool) {
return tool;
}
// Sentinel:
// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
// null = no usable wrapper (disabled, none found, or self-test failed)
let unixGuardTimeoutCache;
/**
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
* (#2163). Unix-only by contract: the probe's win32 dispatch returns before
* reaching it, and the exported callers (the adapters' runGitNexusCli,
* #2163 follow-up) must check the platform first — the self-test below
* spawns /bin/sh. The memoized result is module-wide, so probe and adapter
* share one lazy self-test per hook process.
*
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
* wrapper off; any other value is only a CANDIDATE — an existing file path
* is tried first, but it must pass the `-k` exit-propagation self-test to
* be adopted. On any failure (non-existent path, directory, non-executable
* file, wrapper without `-k` support, always-exit-0 stub, …) resolution
* falls through to the built-in candidates below, tried in order, first
* self-test pass wins. This is strictly stronger than the sibling
* GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only
* check existence): no bad env value of ANY shape can silently disable
* orphan containment.
*
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
* first reached, and the result is memoized. A candidate is adopted only
* when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 — i.e. it must RUN
* the wrapped command AND PROPAGATE its exit status. This rejects two
* failure shapes: wrappers without the coreutils `-k` flag — busybox <1.34,
* toybox, broken symlinks — which would exit with a usage error without
* ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed
* contract into fail-open (#1492 regression); and always-exit-0 stubs
* (/bin/true shapes), which would otherwise be adopted and "succeed" every
* wrapped spawn instantly without running it — a constant no-owner probe
* answer and, worse, a silently dead augment (status 0, empty stderr passes
* the adapters' success check with no context; #2163 follow-up review).
* Only when EVERY candidate fails does the probe fall back to the unwrapped
* status quo (memoized null). busybox ≥1.34 passes the test and is fully
* usable for everything THIS file spawns (lsof/ps are the guard's direct
* children) and for the adapters' direct-exec arm. The adapters' npx arm
* additionally relies on coreutils' process-GROUP signalling for its
* `-s KILL` grandchild reaping; busybox signals only its direct child, and
* this self-test deliberately does not probe that capability — see the
* adapter docblocks for the residual-gap statement.
*/
function passesGuardSelfTest(guard) {
try {
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], {
encoding: 'utf-8',
timeout: 3000,
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
});
return !selfTest.error && selfTest.status === 42;
} catch {
return false;
}
}
function resolveUnixGuardTimeout() {
if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
unixGuardTimeoutCache = null;
const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
const trimmed = fromEnv ? String(fromEnv).trim() : '';
if (trimmed === 'disabled') return unixGuardTimeoutCache;
const candidates = [];
if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
for (const builtin of [
'/usr/bin/timeout',
'/bin/timeout',
'/opt/homebrew/bin/gtimeout',
'/usr/local/bin/gtimeout',
]) {
try {
if (fs.existsSync(builtin)) candidates.push(builtin);
} catch {
/* ignore */
}
}
for (const candidate of candidates) {
if (passesGuardSelfTest(candidate)) {
unixGuardTimeoutCache = candidate;
break;
}
}
return unixGuardTimeoutCache;
}
function resolveWindowsPowerShellPath() {
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
@@ -188,20 +298,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
}
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
const guard = resolveUnixGuardTimeout();
const lsofPath = resolveHookBinary('lsof');
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
// The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
// SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
// SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
// fail-closed contract are untouched. The wrapper only matters once this
// hook process has been SIGKILLed and can no longer deliver that SIGTERM.
const [lsofCmd, lsofArgs] = guard
? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
: [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
const lsof = spawnSync(lsofCmd, lsofArgs, {
encoding: 'utf-8',
timeout: 1000,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
// Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
// result shapes, verified against coreutils 9.1:
// - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
// SELF-RAISES the signal, so spawnSync reports {status: null, signal}
// with no .error (spawnSync's own ETIMEDOUT was handled above). The
// same shape appears when this hook is frozen >2s (SIGSTOP, laptop
// suspend) and the guard expires while it sleeps. By construction, a
// guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
// is a budget/kill outcome.
// - 124: budget expired and the child exited after the plain SIGTERM.
// - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
// or a child SIGKILLed externally (e.g. the OOM killer).
if (guard && lsof.status === null && lsof.signal) return true;
if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
const psPath = resolveHookBinary('ps');
for (const pid of pids) {
if (Number(pid) === myPid) continue;
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
const [psCmd, psArgs] = guard
? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
: [psPath, ['-p', pid, '-o', 'command=']];
const ps = spawnSync(psCmd, psArgs, {
encoding: 'utf-8',
timeout: 500,
stdio: ['ignore', 'pipe', 'ignore'],
@@ -211,6 +347,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
if (ps.error.code === 'ETIMEDOUT') return true;
continue;
}
// Same guard-mediated-death mapping as the lsof call above (signal-death
// from the -k escalation or a frozen hook; 124 budget expiry; 137 only
// for exit-code-propagating wrappers / external SIGKILL).
if (guard && ps.status === null && ps.signal) return true;
if (guard && (ps.status === 124 || ps.status === 137)) return true;
if (isGitNexusServerCommand(ps.stdout || '')) return true;
}
return false;
@@ -238,4 +379,16 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
module.exports = {
hasGitNexusDbLockedByGitNexusServer,
// #2163 follow-up: the hook adapters wrap the augment CLI in the same
// guard. Returns a self-tested wrapper path — the built-in candidates are
// always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
// exact string that passed the self-test. Same string is also the same
// RESOLUTION for absolute paths and for slashless names (PATH lookup is
// cwd-independent); a slash-containing RELATIVE override, however, is
// existsSync-checked and self-tested against this process's cwd while the
// adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
// a value can pass here yet ENOENT at the augment call site — set the
// override to an absolute path. Returns null when the wrapper is
// disabled/unavailable. Never call on win32 (see its JSDoc).
resolveUnixGuardTimeout,
};
@@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Workflow
```
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
1. query({query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({query: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@@ -28,11 +28,11 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
```
- [ ] Understand the symptom (error message, unexpected behavior)
- [ ] gitnexus_query for error text or related code
- [ ] query for error text or related code
- [ ] Identify the suspect function from returned processes
- [ ] gitnexus_context to see callers and callees
- [ ] context to see callers and callees
- [ ] Trace execution flow via process resource if applicable
- [ ] gitnexus_cypher for custom call chain traces if needed
- [ ] cypher for custom call chain traces if needed
- [ ] Read source files to confirm root cause
```
@@ -40,7 +40,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
| Symptom | GitNexus Approach |
| -------------------- | ---------------------------------------------------------- |
| Error message | `gitnexus_query` for error text → `context` on throw sites |
| Error message | `query` for error text → `context` on throw sites |
| Wrong return value | `context` on the function → trace callees for data flow |
| Intermittent failure | `context` → look for external calls, async deps |
| Performance issue | `context` → find symbols with many callers (hot paths) |
@@ -48,24 +48,24 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Tools
**gitnexus_query** — find code related to error:
**query** — find code related to error:
```
gitnexus_query({query: "payment validation error"})
query({query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
**gitnexus_context** — full context for a suspect:
**context** — full context for a suspect:
```
gitnexus_context({name: "validatePayment"})
context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates (external API!)
→ Processes: CheckoutFlow (step 3/7)
```
**gitnexus_cypher** — custom call chain traces:
**cypher** — custom call chain traces:
```cypher
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
@@ -75,11 +75,11 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. gitnexus_query({query: "payment error handling"})
1. query({query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError
2. gitnexus_context({name: "validatePayment"})
2. context({name: "validatePayment"})
→ Outgoing calls: verifyCard, fetchRates (external API!)
3. READ gitnexus://repo/my-app/process/CheckoutFlow
@@ -18,8 +18,8 @@ description: "Use when the user asks how code works, wants to understand archite
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
3. query({query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@@ -29,9 +29,9 @@ description: "Use when the user asks how code works, wants to understand archite
```
- [ ] READ gitnexus://repo/{name}/context
- [ ] gitnexus_query for the concept you want to understand
- [ ] query for the concept you want to understand
- [ ] Review returned processes (execution flows)
- [ ] gitnexus_context on key symbols for callers/callees
- [ ] context on key symbols for callers/callees
- [ ] READ process resource for full execution traces
- [ ] Read source files for implementation details
```
@@ -47,18 +47,18 @@ description: "Use when the user asks how code works, wants to understand archite
## Tools
**gitnexus_query** — find execution flows related to a concept:
**query** — find execution flows related to a concept:
```
gitnexus_query({query: "payment processing"})
query({query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
**gitnexus_context** — 360-degree view of a symbol:
**context** — 360-degree view of a symbol:
```
gitnexus_context({name: "validateUser"})
context({name: "validateUser"})
→ Incoming calls: loginHandler, apiMiddleware
→ Outgoing calls: checkToken, getUserById
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
@@ -68,10 +68,10 @@ gitnexus_context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. gitnexus_query({query: "payment processing"})
2. query({query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. gitnexus_context({name: "processPayment"})
3. context({name: "processPayment"})
→ Incoming: checkoutHandler, webhookHandler
→ Outgoing: validateCard, chargeStripe, saveTransaction
4. Read src/payments/processor.ts for implementation details
@@ -38,7 +38,49 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
```jsonc
{
"repositories": [
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
],
"pagination": {
"total": 437,
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextOffset": 50
}
}
```
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
```text
list_repos {} → repos 1–50, nextOffset 50, hasMore true
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
…
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
```
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.
### Taint findings (`explain`)
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with 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.
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
## Resources Reference
@@ -17,9 +17,9 @@ description: "Use when the user wants to know what will break if they change som
## Workflow
```
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
1. impact({target: "X", direction: "upstream"}) → What depends on this
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
3. gitnexus_detect_changes() → Map current git changes to affected flows
3. detect_changes() → Map current git changes to affected flows
4. Assess risk and report to user
```
@@ -28,11 +28,11 @@ description: "Use when the user wants to know what will break if they change som
## Checklist
```
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
- [ ] impact({target, direction: "upstream"}) to find dependents
- [ ] Review d=1 items first (these WILL BREAK)
- [ ] Check high-confidence (>0.8) dependencies
- [ ] READ processes to check affected execution flows
- [ ] gitnexus_detect_changes() for pre-commit check
- [ ] detect_changes() for pre-commit check
- [ ] Assess risk level and report to user
```
@@ -55,10 +55,10 @@ description: "Use when the user wants to know what will break if they change som
## Tools
**gitnexus_impact** — the primary tool for symbol blast radius:
**impact** — the primary tool for symbol blast radius:
```
gitnexus_impact({
impact({
target: "validateUser",
direction: "upstream",
minConfidence: 0.8,
@@ -73,10 +73,10 @@ gitnexus_impact({
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
```
**gitnexus_detect_changes** — git-diff based impact analysis:
**detect_changes** — git-diff based impact analysis:
```
gitnexus_detect_changes({scope: "staged"})
detect_changes({scope: "staged"})
→ Changed: 5 symbols in 3 files
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
@@ -86,7 +86,7 @@ gitnexus_detect_changes({scope: "staged"})
## Example: "What breaks if I change validateUser?"
```
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
1. impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
@@ -18,10 +18,10 @@ description: "Use when the user wants to review a pull request, understand what
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
@@ -32,10 +32,10 @@ description: "Use when the user wants to review a pull request, understand what
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] detect_changes to map changes to affected execution flows
- [ ] impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
@@ -63,20 +63,20 @@ description: "Use when the user wants to review a pull request, understand what
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
**detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
**impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
@@ -86,20 +86,20 @@ gitnexus_impact({target: "validatePayment", direction: "upstream"})
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
**impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
**context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
@@ -112,20 +112,20 @@ gitnexus_context({name: "validatePayment"})
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
2. detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
3. impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
4. impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
5. context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
@@ -16,9 +16,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru
## Workflow
```
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
2. gitnexus_query({query: "X"}) → Find execution flows involving X
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```
@@ -29,65 +29,65 @@ description: "Use when the user wants to rename, extract, split, move, or restru
### Rename Symbol
```
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
- [ ] gitnexus_detect_changes() — verify only expected files changed
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
```
### Extract Module
```
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
- [ ] context({name: target}) — see all incoming/outgoing refs
- [ ] impact({target, direction: "upstream"}) — find all external callers
- [ ] Define new module interface
- [ ] Extract code, update imports
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
### Split Function/Service
```
- [ ] gitnexus_context({name: target}) — understand all callees
- [ ] context({name: target}) — understand all callees
- [ ] Group callees by responsibility
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
- [ ] impact({target, direction: "upstream"}) — map callers to update
- [ ] Create new functions/services
- [ ] Update callers
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
## Tools
**gitnexus_rename** — automated multi-file rename:
**rename** — automated multi-file rename:
```
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 ast_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```
**gitnexus_impact** — map all dependents first:
**impact** — map all dependents first:
```
gitnexus_impact({target: "validateUser", direction: "upstream"})
impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware, testUtils
→ Affected Processes: LoginFlow, TokenRefresh
```
**gitnexus_detect_changes** — verify your changes after refactoring:
**detect_changes** — verify your changes after refactoring:
```
gitnexus_detect_changes({scope: "all"})
detect_changes({scope: "all"})
→ Changed: 8 files, 12 symbols
→ Affected processes: LoginFlow, TokenRefresh
→ Risk: MEDIUM
```
**gitnexus_cypher** — custom reference queries:
**cypher** — custom reference queries:
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
@@ -98,24 +98,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
| Risk Factor | Mitigation |
| ------------------- | ----------------------------------------- |
| Many callers (>5) | Use gitnexus_rename for automated updates |
| Many callers (>5) | Use rename for automated updates |
| Cross-area refs | Use detect_changes after to verify scope |
| String/dynamic refs | gitnexus_query to find them |
| String/dynamic refs | query to find them |
| External/public API | Version and deprecate properly |
## Example: Rename `validateUser` to `authenticateUser`
```
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits: 10 graph (safe), 2 ast_search (review)
→ Files: validator.ts, login.ts, middleware.ts, config.json...
2. Review ast_search edits (config.json: dynamic reference!)
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
→ Applied 12 edits across 8 files
4. gitnexus_detect_changes({scope: "all"})
4. detect_changes({scope: "all"})
→ Affected: LoginFlow, TokenRefresh
→ Risk: MEDIUM — run tests for these flows
```
@@ -0,0 +1,178 @@
---
name: gitnexus-taint-analysis
description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
---
# CFG & Taint Analysis with GitNexus
Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
graphs, reaching definitions, and intra- + inter-procedural taint. Read this
before touching `gitnexus/src/core/ingestion/cfg/**` or
`gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
## When to Use
- "How does the taint engine work / why is this flow (not) reported?"
- Adding a source, sink, or sanitizer to the model.
- Extending or reviewing the CFG / reaching-defs / taint / summary code.
- Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
- Debugging a false positive or false negative in `--pdg` output.
## The layered substrate (build order)
Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
and a default `analyze` run is **byte-identical** (the golden parity gate is the
hard floor for every change here).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
```
- **Worker-built, main-thread-solved.** The parse worker builds each function's
CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
(plain, structured-clone-safe data — never AST nodes). The main thread runs
the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
OOM).
- **In-phase emit (KTD1).** L1–L4-harvest all run INSIDE the scope-resolution
pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
because the disk-backed ParsedFile store is cleared when that phase ends — a
standalone post-`mro` phase would read empty data. The cross-function fixpoint
(L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
small plain summary data threaded out via `ScopeResolutionOutput`.
- **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
`harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
(no graph, no I/O, no logger; sorted outputs). Snapshot tests and
content-derived edge ids depend on it.
## Intra-procedural taint (L3)
Forward reachability over RD facts from matched **sources** to matched **sinks**,
killed by **sanitizers**. Key design points worth internalizing:
- **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
`exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
precise.
- **Kind-set sanitizer model.** A taint carries a set of *neutralized*
`SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
kill would be a suppressed live injection (the forbidden FN direction).
`path.basename(t)` neutralizes path-traversal only, not command-injection.
- **Statement-level finding identity.** NOT block-pair (block conflation drops
distinct findings; `exec(req.body, req.query)` is two findings).
- Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
`reason` column via the shared versioned codec (`taint/path-codec.ts`).
## Interprocedural taint (L4) — the functional/summary method
The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
reduced to a compact **summary**, and summaries are composed over the already-
resolved `CALLS` graph.
**Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
| Edge | Meaning | Analogue |
|------|---------|----------|
| `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
| `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
| `param→sink` | a param reaches a modelled sink | partial/triggered sink |
| `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
| `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
| `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
**The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
parameter, source)`. Seed from `source→callee-arg`, propagate via
`param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
- **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
lattice (`fn × param × source`), so the worklist converges — a recursive call
just re-proposes an already-visited entry. SCC condensation would only refine
processing order; correctness/termination don't require it.
- **Source-discriminated state (load-bearing).** Key the state by the SOURCE
too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
tainted by source A is marked visited and a later flow from source B is dropped
before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
- **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
site) is fragile; the callee identity is exact and context-insensitivity
taints the callee's param identically at every call site.
- Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
in `reason` via the same codec; confidence < the intra-procedural 1.0.
**Context-insensitivity** is the accepted trade-off at this tier: one summary
per function, return/call-site merging accepted (security-conservative). Expect
some FP from merging; the bigger FN sources are unmodeled features (below).
## Known false-negative classes (documented, deferred)
The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
into a callback is dropped without per-library models (true of CodeQL's JS libs
too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
flows, promise/async-await threading, and **destructured/rest params before a
tainted simple param** (the summary port index is the binding ordinal, not the
formal arg position — needs a formal-param index threaded from the worker
`BindingEntry`). The interprocedural join is also context-insensitive: when one
caller invokes two distinct **same-named callees**, a flow into one
over-attributes to both (sound — over-report, never a missed flow). Absence of a
finding is NOT proof of safety.
## GitNexus-specific gotchas
- **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
`Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
ambiguous → drop (the summary driver counts `unresolved`) rather than
cross-wire.
- **No rel-property index (S1).** Kuzu has no secondary index on relationship
properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
traversed live; `explain` reads it source-anchored + LIMIT-guarded.
- **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
schema (pinned in `security.test.ts`). `explain` enumerates both layers
(cross-function findings carry `interprocedural: true`).
- **One shared codec.** Both the emit path and `explain` import
`taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
fork it. New metadata extends the format WITHIN the version when writer +
reader ship together.
- **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
full writeback. Model content rides `taintModelVersion`.
## Adding a source / sink / sanitizer
Edit the language model in `taint/typescript-model.ts` (registered via the
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
finding (or its absence) in `test/unit/taint/` (real-source harness:
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
`test/integration/cfg/`.
## Validation checklist for any `--pdg` change
```
1. tsc clean (schema additions are exhaustiveness-checked; watch the
api.ts getNodeQuery runtime read-path if a node label is added).
2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
test/integration/cfg) — verify by ISOLATION, not full-suite exit
(known load-flakes). `node scripts/build.js` before worker/integration runs.
3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
5. detect_changes() before commit; impact({direction:'upstream'}) before
editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
```
## Prior art (for deeper design questions)
Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
notation, incl. callback ports); Infer (content-keyed incremental summaries).
@@ -241,7 +241,18 @@ function main() {
if (!pattern || pattern.length < 3) return;
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
if (!release) {
// Normal skip path: all per-repo hook slots are held by concurrent
// sessions. Stays silent by default; surfaced only under the cursor
// hook's own GITNEXUS_DEBUG (truthy) convention. NOTE: unlike the
// claude/plugin/antigravity adapters this integration does not install
// hook-db-lock-probe.cjs, so its augment child is not guard-wrapped
// yet — tracked on the #2163 follow-up list ("cursor probe").
if (process.env.GITNEXUS_DEBUG) {
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
}
return;
}
const cliPath = resolveCliPath();
let result = '';
@@ -15,10 +15,10 @@ description: Trace bugs through call chains using knowledge graph
## Workflow
```
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
1. query({query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({query: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@@ -27,11 +27,11 @@ description: Trace bugs through call chains using knowledge graph
```
- [ ] Understand the symptom (error message, unexpected behavior)
- [ ] gitnexus_query for error text or related code
- [ ] query for error text or related code
- [ ] Identify the suspect function from returned processes
- [ ] gitnexus_context to see callers and callees
- [ ] context to see callers and callees
- [ ] Trace execution flow via process resource if applicable
- [ ] gitnexus_cypher for custom call chain traces if needed
- [ ] cypher for custom call chain traces if needed
- [ ] Read source files to confirm root cause
```
@@ -39,7 +39,7 @@ description: Trace bugs through call chains using knowledge graph
| Symptom | GitNexus Approach |
|---------|-------------------|
| Error message | `gitnexus_query` for error text → `context` on throw sites |
| Error message | `query` for error text → `context` on throw sites |
| Wrong return value | `context` on the function → trace callees for data flow |
| Intermittent failure | `context` → look for external calls, async deps |
| Performance issue | `context` → find symbols with many callers (hot paths) |
@@ -47,22 +47,22 @@ description: Trace bugs through call chains using knowledge graph
## Tools
**gitnexus_query** — find code related to error:
**query** — find code related to error:
```
gitnexus_query({query: "payment validation error"})
query({query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
**gitnexus_context** — full context for a suspect:
**context** — full context for a suspect:
```
gitnexus_context({name: "validatePayment"})
context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates (external API!)
→ Processes: CheckoutFlow (step 3/7)
```
**gitnexus_cypher** — custom call chain traces:
**cypher** — custom call chain traces:
```cypher
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
RETURN [n IN nodes(path) | n.name] AS chain
@@ -71,11 +71,11 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. gitnexus_query({query: "payment error handling"})
1. query({query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError
2. gitnexus_context({name: "validatePayment"})
2. context({name: "validatePayment"})
→ Outgoing calls: verifyCard, fetchRates (external API!)
3. READ gitnexus://repo/my-app/process/CheckoutFlow
@@ -17,8 +17,8 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
3. query({query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@@ -28,9 +28,9 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
```
- [ ] READ gitnexus://repo/{name}/context
- [ ] gitnexus_query for the concept you want to understand
- [ ] query for the concept you want to understand
- [ ] Review returned processes (execution flows)
- [ ] gitnexus_context on key symbols for callers/callees
- [ ] context on key symbols for callers/callees
- [ ] READ process resource for full execution traces
- [ ] Read source files for implementation details
```
@@ -46,16 +46,16 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
## Tools
**gitnexus_query** — find execution flows related to a concept:
**query** — find execution flows related to a concept:
```
gitnexus_query({query: "payment processing"})
query({query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
**gitnexus_context** — 360-degree view of a symbol:
**context** — 360-degree view of a symbol:
```
gitnexus_context({name: "validateUser"})
context({name: "validateUser"})
→ Incoming calls: loginHandler, apiMiddleware
→ Outgoing calls: checkToken, getUserById
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
@@ -65,10 +65,10 @@ gitnexus_context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. gitnexus_query({query: "payment processing"})
2. query({query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. gitnexus_context({name: "processPayment"})
3. context({name: "processPayment"})
→ Incoming: checkoutHandler, webhookHandler
→ Outgoing: validateCard, chargeStripe, saveTransaction
4. Read src/payments/processor.ts for implementation details
@@ -16,9 +16,9 @@ description: Analyze blast radius before making code changes
## Workflow
```
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
1. impact({target: "X", direction: "upstream"}) → What depends on this
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
3. gitnexus_detect_changes() → Map current git changes to affected flows
3. detect_changes() → Map current git changes to affected flows
4. Assess risk and report to user
```
@@ -27,11 +27,11 @@ description: Analyze blast radius before making code changes
## Checklist
```
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
- [ ] impact({target, direction: "upstream"}) to find dependents
- [ ] Review d=1 items first (these WILL BREAK)
- [ ] Check high-confidence (>0.8) dependencies
- [ ] READ processes to check affected execution flows
- [ ] gitnexus_detect_changes() for pre-commit check
- [ ] detect_changes() for pre-commit check
- [ ] Assess risk level and report to user
```
@@ -54,9 +54,9 @@ description: Analyze blast radius before making code changes
## Tools
**gitnexus_impact** — the primary tool for symbol blast radius:
**impact** — the primary tool for symbol blast radius:
```
gitnexus_impact({
impact({
target: "validateUser",
direction: "upstream",
minConfidence: 0.8,
@@ -71,9 +71,9 @@ gitnexus_impact({
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
```
**gitnexus_detect_changes** — git-diff based impact analysis:
**detect_changes** — git-diff based impact analysis:
```
gitnexus_detect_changes({scope: "staged"})
detect_changes({scope: "staged"})
→ Changed: 5 symbols in 3 files
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
@@ -83,7 +83,7 @@ gitnexus_detect_changes({scope: "staged"})
## Example: "What breaks if I change validateUser?"
```
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
1. impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
@@ -18,10 +18,10 @@ description: "Use when the user wants to review a pull request, understand what
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
@@ -32,10 +32,10 @@ description: "Use when the user wants to review a pull request, understand what
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] detect_changes to map changes to affected execution flows
- [ ] impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
@@ -63,20 +63,20 @@ description: "Use when the user wants to review a pull request, understand what
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
**detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
**impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
@@ -86,20 +86,20 @@ gitnexus_impact({target: "validatePayment", direction: "upstream"})
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
**impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
**context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
@@ -112,20 +112,20 @@ gitnexus_context({name: "validatePayment"})
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
2. detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
3. impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
4. impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
5. context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
@@ -15,9 +15,9 @@ description: Plan safe refactors using blast radius and dependency mapping
## Workflow
```
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
2. gitnexus_query({query: "X"}) → Find execution flows involving X
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```
@@ -27,60 +27,60 @@ description: Plan safe refactors using blast radius and dependency mapping
### Rename Symbol
```
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
- [ ] gitnexus_detect_changes() — verify only expected files changed
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
```
### Extract Module
```
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
- [ ] context({name: target}) — see all incoming/outgoing refs
- [ ] impact({target, direction: "upstream"}) — find all external callers
- [ ] Define new module interface
- [ ] Extract code, update imports
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
### Split Function/Service
```
- [ ] gitnexus_context({name: target}) — understand all callees
- [ ] context({name: target}) — understand all callees
- [ ] Group callees by responsibility
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
- [ ] impact({target, direction: "upstream"}) — map callers to update
- [ ] Create new functions/services
- [ ] Update callers
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
## Tools
**gitnexus_rename** — automated multi-file rename:
**rename** — automated multi-file rename:
```
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 ast_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```
**gitnexus_impact** — map all dependents first:
**impact** — map all dependents first:
```
gitnexus_impact({target: "validateUser", direction: "upstream"})
impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware, testUtils
→ Affected Processes: LoginFlow, TokenRefresh
```
**gitnexus_detect_changes** — verify your changes after refactoring:
**detect_changes** — verify your changes after refactoring:
```
gitnexus_detect_changes({scope: "all"})
detect_changes({scope: "all"})
→ Changed: 8 files, 12 symbols
→ Affected processes: LoginFlow, TokenRefresh
→ Risk: MEDIUM
```
**gitnexus_cypher** — custom reference queries:
**cypher** — custom reference queries:
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
RETURN caller.name, caller.filePath ORDER BY caller.filePath
@@ -90,24 +90,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
| Risk Factor | Mitigation |
|-------------|------------|
| Many callers (>5) | Use gitnexus_rename for automated updates |
| Many callers (>5) | Use rename for automated updates |
| Cross-area refs | Use detect_changes after to verify scope |
| String/dynamic refs | gitnexus_query to find them |
| String/dynamic refs | query to find them |
| External/public API | Version and deprecate properly |
## Example: Rename `validateUser` to `authenticateUser`
```
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits: 10 graph (safe), 2 ast_search (review)
→ Files: validator.ts, login.ts, middleware.ts, config.json...
2. Review ast_search edits (config.json: dynamic reference!)
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
→ Applied 12 edits across 8 files
4. gitnexus_detect_changes({scope: "all"})
4. detect_changes({scope: "all"})
→ Affected: LoginFlow, TokenRefresh
→ Risk: MEDIUM — run tests for these flows
```
+28 -2
View File
@@ -44,7 +44,10 @@ export type NodeLabel =
| 'Template'
| 'Section'
| 'Route'
| 'Tool';
| 'Tool'
// Taint/PDG substrate (issue #2080). Intra-procedural control-flow node.
// Emitted by no phase yet — M1 (#2081) populates these behind an opt-in.
| 'BasicBlock';
export type NodeProperties = {
name: string;
@@ -89,6 +92,8 @@ export type NodeProperties = {
responseKeys?: string[];
errorKeys?: string[];
middleware?: string[];
// BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
text?: string;
// Extensible
[key: string]: unknown;
};
@@ -131,7 +136,28 @@ export type RelationshipType =
* `reason` encodes the event name: `vue-emit: <eventName>`.
* Complements `BINDS_EVENT_HANDLER`; a Cypher query joining on the
* component File node reveals all (emitter, handler) pairs. */
| 'EMITS_EVENT';
| 'EMITS_EVENT'
// ── Taint/PDG substrate (issue #2080) ────────────────────────────────────
// Reserved edge types for the taint-first PDG substrate. No phase emits any
// of these yet; they are populated behind an opt-in by later milestones
// (CFG → M1 #2081, REACHING_DEF → M2 #2082, TAINTED/SANITIZES/TAINT_PATH →
// M3/M4 #2083/#2084). Adding them here keeps the shared schema stable so
// downstream work does not re-ripple the exhaustiveness sites.
/** Control-flow edge between two BasicBlock nodes (intra-procedural CFG). */
| 'CFG'
/** Data-dependence edge: a definition of `variable` reaches a use of it.
* The `variable` name is stored in the relation's existing `reason` column
* (M0/S1 verdict: LadybugDB has no secondary index on relationship
* properties, so a dedicated indexed column would not speed the
* variable-filtered path query). */
| 'REACHING_DEF'
/** A tainted value flows from source toward sink. */
| 'TAINTED'
/** A sanitizer clears taint along a flow. */
| 'SANITIZES'
/** Materialized source→sink taint path. Working name — final name/representation
* is confirmed when M3/M4 emits it; no persisted edge exists before then. */
| 'TAINT_PATH';
export interface GraphNode {
id: string;
-10
View File
@@ -183,13 +183,3 @@ export {
stripGitSuffix,
} from './integrations/understand-quickly.js';
export type { UqDispatchPayload } from './integrations/understand-quickly.js';
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
export { diffResolutions } from './scope-resolution/shadow/diff.js';
export type {
ShadowAgreement,
ShadowCallsite,
ShadowDiff,
} from './scope-resolution/shadow/diff.js';
export { aggregateDiffs } from './scope-resolution/shadow/aggregate.js';
export type { LanguageParityRow, ShadowParityReport } from './scope-resolution/shadow/aggregate.js';
@@ -40,6 +40,8 @@ export const NODE_TABLES = [
'Module',
'Route',
'Tool',
// Taint/PDG substrate (issue #2080) — inert until M1 (#2081) emits blocks.
'BasicBlock',
] as const;
export type NodeTableName = (typeof NODE_TABLES)[number];
@@ -67,6 +69,14 @@ export const REL_TYPES = [
'ENTRY_POINT_OF',
'WRAPS',
'QUERIES',
// Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no
// phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH →
// M3/M4). REACHING_DEF's variable name rides the relation's `reason` column.
'CFG',
'REACHING_DEF',
'TAINTED',
'SANITIZES',
'TAINT_PATH',
] as const;
export type RelType = (typeof REL_TYPES)[number];
@@ -8,8 +8,7 @@
*
* Part of RFC #909 Ring 2 SHARED — #913.
*
* Consumed by: #915 (SCC finalize link pass), #923 (shadow harness when
* resolving callsite file → enclosing module).
* Consumed by: #915 (SCC finalize link pass).
*/
import type { ScopeId } from './types.js';
@@ -74,4 +74,50 @@ export interface ParsedFile {
*/
readonly localDefs: readonly SymbolDefinition[];
readonly referenceSites: readonly ReferenceSite[];
/**
* Opaque, language-private serialization of capture-time side-channel
* state that a provider's `emitScopeCaptures` populates into module-level
* maps as a SIDE EFFECT (not onto the scopes/defs of this `ParsedFile`).
*
* Such state is computed inside the parse worker (where `emitScopeCaptures`
* runs) and would otherwise be lost across the worker→main MessageChannel
* and the disk store, because scope-resolution reuses the serialized
* `ParsedFile` and SKIPS re-extraction on the main thread (#1983 — the
* whole point is to avoid a main-thread tree-sitter re-parse). Carrying the
* data here lets the main thread repopulate those maps WITHOUT re-parsing.
*
* Shared / ingestion code treats this as opaque (`unknown`) per AGENTS.md
* (no language names in shared code). The producing language fills it via
* the `LanguageProvider.collectCaptureSideChannel` hook (worker side) and
* consumes it via the `ScopeResolver.applyCaptureSideChannel` hook
* (main-thread resolution side). It MUST be plain JSON-serializable data
* (objects / arrays / primitives) so it round-trips through the disk-backed
* `parsedfile-store` (JSON.stringify + interning reviver).
*
* Optional: providers whose `emitScopeCaptures` is pure (no module-level
* side effects — the contract default) leave this undefined.
*/
readonly captureSideChannel?: unknown;
/**
* Per-function control-flow graphs for this file (#2081 M1, PDG/taint
* substrate). A DISTINCT field from {@link captureSideChannel} — different
* producer, consumer, and lifecycle: the worker builds it from the
* tree-sitter AST via `LanguageProvider.cfgVisitor` (only on a `--pdg` run),
* and scope-resolution emits BasicBlock nodes + CFG edges from it while the
* disk-backed ParsedFile store is still live (it is NOT a capture-time
* marker the resolver restores into module maps). Kept separate so a future
* change to either channel's shape invalidates independently.
*
* Shared / ingestion code treats this as opaque (`unknown`) per AGENTS.md.
* Concretely it is a `readonly FunctionCfg[]` (see
* `core/ingestion/cfg/types.ts`) — plain JSON-serializable data (no AST
* refs, no class instances) so it round-trips through the parse cache and
* the `parsedfile-store` (whose interning reviver keys on `nodeId`, which
* these blocks/edges deliberately lack).
*
* Optional: `undefined` on non-`--pdg` runs and for languages with no
* `cfgVisitor` — the default for every run today.
*/
readonly cfgSideChannel?: unknown;
}
@@ -57,8 +57,7 @@ export interface RawSignals {
*
* Emission order mirrors the `EvidenceWeights` layout: where-found →
* type-binding → corroborators → arity → degraded. Stable order makes
* the per-signal contributions easy to reason about in tests and in the
* shadow-mode parity dashboard.
* the per-signal contributions easy to reason about in tests.
*/
export function composeEvidence(signals: RawSignals): readonly ResolutionEvidence[] {
const out: ResolutionEvidence[] = [];
@@ -141,7 +140,7 @@ export function composeEvidence(signals: RawSignals): readonly ResolutionEvidenc
/**
* Sum evidence weights and clamp to `[0, 1]`. Separate from `composeEvidence`
* so tests and the parity dashboard can inspect the raw evidence list.
* so tests can inspect the raw evidence list.
*/
export function confidenceFromEvidence(evidence: readonly ResolutionEvidence[]): number {
let sum = 0;
@@ -1,188 +0,0 @@
/**
* Shadow-mode aggregation — per-language parity %, per-evidence-kind
* breakdown of divergences. Consumed by the parity dashboard (RING2-PKG-5).
*
* Pure functions; no I/O. The harness persists per-run JSON; the dashboard
* reads `.gitnexus/shadow-parity/latest.json` and renders.
*
* Related types — `ShadowAgreement`, `ShadowCallsite`, `ShadowDiff` — are
* defined alongside `diffResolutions` in `./diff.ts` and re-exported
* through the top-level `gitnexus-shared` barrel. Consumers import all
* three from `gitnexus-shared`, not from this module.
*
* Part of RFC #909 Ring 2 SHARED — #918.
*/
import type { SupportedLanguages } from '../../languages.js';
import type { ResolutionEvidence } from '../types.js';
import type { ShadowAgreement, ShadowDiff } from './diff.js';
// ─── Aggregated report shape ────────────────────────────────────────────────
export interface LanguageParityRow {
readonly language: SupportedLanguages;
readonly totalCalls: number;
readonly bothAgree: number;
readonly onlyLegacy: number;
readonly onlyNew: number;
readonly bothDisagree: number;
readonly bothEmpty: number;
/**
* Fraction in [0, 1]. Numerator = `bothAgree`; denominator = "calls where
* at least one side resolved" = `totalCalls - bothEmpty`.
*
* When the denominator is 0 (all calls for this language were
* `both-empty`), returns 0. Callers rendering the dashboard should treat
* a 0 parity alongside `totalCalls === bothEmpty` as "no signal" rather
* than "total disagreement".
*/
readonly parity: number;
/**
* Divergence signals broken down by `ResolutionEvidence.kind`. Sourced
* from `ShadowDiff.evidenceDelta` on non-agreeing rows only — `both-agree`
* and `both-empty` do not contribute.
*/
readonly evidenceBreakdown: ReadonlyMap<ResolutionEvidence['kind'], number>;
}
export interface ShadowParityReport {
readonly generatedAt: string; // ISO 8601
readonly perLanguage: readonly LanguageParityRow[];
readonly overall: Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'>;
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Aggregate a stream of `ShadowDiff` records into a `ShadowParityReport`,
* bucketed by language. Pure function.
*
* - `perLanguage` rows are sorted alphabetically by `SupportedLanguages`
* value for stable JSON output (the dashboard reads
* `.gitnexus/shadow-parity/latest.json` and diffing snapshots is useful).
* - `overall` is the column-wise sum across languages.
* - `generatedAt` is injected via the `now` parameter so tests stay
* deterministic; production callers let it default to `new Date()`.
*/
export function aggregateDiffs(
diffs: readonly { readonly language: SupportedLanguages; readonly diff: ShadowDiff }[],
now: Date = new Date(),
): ShadowParityReport {
const perLanguageMap = new Map<SupportedLanguages, MutableCounts>();
for (const { language, diff } of diffs) {
let counts = perLanguageMap.get(language);
if (!counts) {
counts = makeEmptyCounts();
perLanguageMap.set(language, counts);
}
tallyDiff(counts, diff);
}
const perLanguage: LanguageParityRow[] = Array.from(perLanguageMap.entries())
.map(([language, counts]) => buildRow(language, counts))
.sort((a, b) => a.language.localeCompare(b.language));
const overall = buildOverallRow(perLanguage);
return {
generatedAt: now.toISOString(),
perLanguage,
overall,
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
interface MutableCounts {
totalCalls: number;
bothAgree: number;
onlyLegacy: number;
onlyNew: number;
bothDisagree: number;
bothEmpty: number;
evidenceBreakdown: Map<ResolutionEvidence['kind'], number>;
}
function makeEmptyCounts(): MutableCounts {
return {
totalCalls: 0,
bothAgree: 0,
onlyLegacy: 0,
onlyNew: 0,
bothDisagree: 0,
bothEmpty: 0,
evidenceBreakdown: new Map(),
};
}
function tallyDiff(counts: MutableCounts, diff: ShadowDiff): void {
counts.totalCalls += 1;
incrementAgreement(counts, diff.agreement);
if (diff.agreement === 'both-agree' || diff.agreement === 'both-empty') return;
for (const ev of diff.evidenceDelta) {
counts.evidenceBreakdown.set(ev.kind, (counts.evidenceBreakdown.get(ev.kind) ?? 0) + 1);
}
}
function incrementAgreement(counts: MutableCounts, agreement: ShadowAgreement): void {
switch (agreement) {
case 'both-agree':
counts.bothAgree += 1;
return;
case 'only-legacy':
counts.onlyLegacy += 1;
return;
case 'only-new':
counts.onlyNew += 1;
return;
case 'both-disagree':
counts.bothDisagree += 1;
return;
case 'both-empty':
counts.bothEmpty += 1;
return;
}
}
function buildRow(language: SupportedLanguages, counts: MutableCounts): LanguageParityRow {
const resolved = counts.totalCalls - counts.bothEmpty;
const parity = resolved > 0 ? counts.bothAgree / resolved : 0;
return {
language,
totalCalls: counts.totalCalls,
bothAgree: counts.bothAgree,
onlyLegacy: counts.onlyLegacy,
onlyNew: counts.onlyNew,
bothDisagree: counts.bothDisagree,
bothEmpty: counts.bothEmpty,
parity,
// Freeze via `new Map` on a sorted-kind copy so downstream consumers
// can't mutate the aggregator's internal state.
evidenceBreakdown: new Map(
Array.from(counts.evidenceBreakdown.entries()).sort(([a], [b]) => a.localeCompare(b)),
),
};
}
function buildOverallRow(
perLanguage: readonly LanguageParityRow[],
): Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'> {
let totalCalls = 0;
let bothAgree = 0;
let onlyLegacy = 0;
let onlyNew = 0;
let bothDisagree = 0;
let bothEmpty = 0;
for (const row of perLanguage) {
totalCalls += row.totalCalls;
bothAgree += row.bothAgree;
onlyLegacy += row.onlyLegacy;
onlyNew += row.onlyNew;
bothDisagree += row.bothDisagree;
bothEmpty += row.bothEmpty;
}
const resolved = totalCalls - bothEmpty;
const parity = resolved > 0 ? bothAgree / resolved : 0;
return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity };
}
@@ -1,126 +0,0 @@
/**
* Shadow-mode diff logic — RFC §6.3.
*
* Pure comparison logic for shadow mode. Takes two `Resolution[]` (legacy
* DAG result + new scope-based registry result) and produces a structured
* diff record for the parity dashboard.
*
* Consumed by the Ring 2 PKG shadow harness (#923), which dual-runs each
* call through legacy + new paths, diffs results, and persists per-run JSON
* for the parity dashboard.
*
* Part of RFC #909 Ring 2 SHARED — #918.
*/
import type { Resolution, ResolutionEvidence } from '../types.js';
// ─── Diff record shape ──────────────────────────────────────────────────────
export type ShadowAgreement =
| 'both-agree' // top match identical (same DefId)
| 'only-legacy' // legacy resolved; new did not
| 'only-new' // new resolved; legacy did not
| 'both-disagree' // both resolved, but to different targets
| 'both-empty'; // both returned empty
export interface ShadowDiff {
readonly callsite: ShadowCallsite;
readonly legacy: Resolution | null;
readonly newResult: Resolution | null;
readonly agreement: ShadowAgreement;
/**
* Symmetric difference of the two top resolutions' `evidence` arrays,
* keyed on `ResolutionEvidence.kind`.
*
* - For `'both-agree'` and `'both-empty'` agreements, always empty.
* - For `'both-disagree'`, contains evidence kinds present on exactly one
* side (not in both).
* - For `'only-legacy'`, contains all of legacy's top evidence.
* - For `'only-new'`, contains all of new's top evidence.
*/
readonly evidenceDelta: readonly ResolutionEvidence[];
}
export interface ShadowCallsite {
readonly filePath: string;
readonly line: number;
readonly col: number;
readonly calledName: string;
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Compare two `Resolution[]` arrays (top matches at `[0]`) and produce a
* `ShadowDiff`. Pure function.
*
* Agreement rules:
* - both arrays empty → `'both-empty'`, `evidenceDelta: []`
* - legacy empty, new non-empty → `'only-new'`, `evidenceDelta` = new's top evidence
* - legacy non-empty, new empty → `'only-legacy'`, `evidenceDelta` = legacy's top evidence
* - both non-empty, same top `def.nodeId` → `'both-agree'`, `evidenceDelta: []`
* - both non-empty, different top `def.nodeId` → `'both-disagree'`,
* `evidenceDelta` = symmetric difference by `ResolutionEvidence.kind`
* (first occurrence of a kind-only-on-legacy then kind-only-on-new; order
* preserved from input arrays)
*
* Evidence-delta rationale: callers aggregating divergences want to know
* which signal kinds explain a disagreement. Keying on `kind` (not full
* equality over `weight`/`note`) avoids spurious deltas when the same
* signal fires with slightly different calibration weights on each side.
*/
export function diffResolutions(
callsite: ShadowCallsite,
legacy: readonly Resolution[],
newResult: readonly Resolution[],
): ShadowDiff {
const legacyTop: Resolution | null = legacy.length > 0 ? legacy[0] : null;
const newTop: Resolution | null = newResult.length > 0 ? newResult[0] : null;
const agreement: ShadowAgreement = (() => {
if (legacyTop === null && newTop === null) return 'both-empty';
if (legacyTop === null) return 'only-new';
if (newTop === null) return 'only-legacy';
return legacyTop.def.nodeId === newTop.def.nodeId ? 'both-agree' : 'both-disagree';
})();
const evidenceDelta = computeEvidenceDelta(legacyTop, newTop, agreement);
return {
callsite,
legacy: legacyTop,
newResult: newTop,
agreement,
evidenceDelta,
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
/**
* Symmetric difference of two evidence arrays, keyed on
* `ResolutionEvidence.kind`. Preserves input order: legacy-only signals
* first (in legacy's original order), then new-only signals (in new's order).
*
* For `'both-agree'` / `'both-empty'` the delta is empty by contract. For
* `'only-legacy'` / `'only-new'` one side's evidence is the delta (nothing to
* subtract against).
*/
function computeEvidenceDelta(
legacy: Resolution | null,
newResult: Resolution | null,
agreement: ShadowAgreement,
): readonly ResolutionEvidence[] {
if (agreement === 'both-agree' || agreement === 'both-empty') return [];
if (agreement === 'only-legacy') return legacy!.evidence;
if (agreement === 'only-new') return newResult!.evidence;
// both-disagree: symmetric difference keyed on `kind`
const legacyKinds = new Set(legacy!.evidence.map((e) => e.kind));
const newKinds = new Set(newResult!.evidence.map((e) => e.kind));
const onlyInLegacy = legacy!.evidence.filter((e) => !newKinds.has(e.kind));
const onlyInNew = newResult!.evidence.filter((e) => !legacyKinds.has(e.kind));
return [...onlyInLegacy, ...onlyInNew];
}
@@ -57,6 +57,10 @@ export interface SymbolDefinition {
* Currently used by C++ overload ranking to exclude explicit constructors
* from implicit user-defined conversion candidates. */
isExplicit?: boolean;
/** True when the callable is declared unavailable (for example C++ `= delete`).
* Unavailable callables still participate in overload selection, but a
* selected unavailable target must suppress edge emission. */
isDeleted?: boolean;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
+112
View File
@@ -0,0 +1,112 @@
import { test, expect } from '@playwright/test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* E2E for the browser folder-upload flow (replaces the removed server-side
* directory picker). Mocks the backend so no live gitnexus server is needed.
*/
const BACKEND_URL = 'http://localhost:4747';
let fixtureDir: string;
test.beforeAll(() => {
// A tiny "repo" folder; Playwright sets webkitRelativePath = <folder>/<file>.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-upload-e2e-'));
fixtureDir = path.join(root, 'myrepo');
fs.mkdirSync(path.join(fixtureDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(fixtureDir, 'README.md'), '# hi\n');
fs.writeFileSync(path.join(fixtureDir, 'src', 'index.ts'), 'export const x = 1;\n');
});
test.beforeEach(async ({ page }) => {
await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] }));
await page.route(`${BACKEND_URL}/api/info`, (route) =>
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
);
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: ':ok\n\n',
}),
);
});
test('uploading a folder posts a multipart upload and starts analysis', async ({ page }) => {
let uploadContentType = '';
await page.route(`${BACKEND_URL}/api/analyze/upload`, async (route) => {
uploadContentType = route.request().headers()['content-type'] ?? '';
await route.fulfill({ json: { jobId: 'job-e2e', status: 'analyzing' } });
});
// SSE progress → immediately complete.
await page.route(`${BACKEND_URL}/api/analyze/job-e2e/progress`, (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
body: 'event: complete\ndata: {"repoName":"myrepo"}\n\n',
}),
);
await page.goto('/');
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
await page.getByRole('tab', { name: 'Local Folder' }).click();
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
// Select the fixture folder via the hidden webkitdirectory input.
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
// The upload endpoint should be hit with a multipart body, and the UI should
// leave the input phase (upload button no longer shown).
await expect.poll(() => uploadContentType).toContain('multipart/form-data');
await expect(page.locator('[data-testid="upload-folder"]')).toBeHidden({ timeout: 10_000 });
});
test('switching modes mid-upload aborts it and never shows progress', async ({ page }) => {
// Hold the upload response until the test releases it, so the mode switch
// happens while the POST is in flight (the review 4470339833 repro).
let releaseUpload!: () => void;
const uploadGate = new Promise<void>((res) => (releaseUpload = res));
let uploadAborted = false;
let progressOpened = false;
// The client-side AbortController kills the POST at mode-switch time; that
// surfaces as a failed request (net::ERR_ABORTED), not as a response.
page.on('requestfailed', (req) => {
if (req.url().includes('/api/analyze/upload') && /ABORTED/.test(req.failure()?.errorText ?? ''))
uploadAborted = true;
});
await page.route(`${BACKEND_URL}/api/analyze/upload`, async (route) => {
await uploadGate;
await route.fulfill({ json: { jobId: 'job-stale', status: 'analyzing' } }).catch(() => {}); // the request may already be gone — that's the point
});
await page.route(`${BACKEND_URL}/api/analyze/job-stale/progress`, (route) => {
progressOpened = true;
return route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
body: 'event: complete\ndata: {"repoName":"myrepo"}\n\n',
});
});
await page.goto('/');
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
await page.getByRole('tab', { name: 'Local Folder' }).click();
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
await expect(page.locator('[data-testid="upload-progress"]')).toBeVisible();
// Switch back to GitHub while the upload POST is still pending, then let
// the (now-stale) route handler finish.
await page.getByRole('tab', { name: 'GitHub URL' }).click();
await expect.poll(() => uploadAborted, { timeout: 10_000 }).toBe(true);
releaseUpload();
// The GitHub form stays clean (no error, immediately usable), and no SSE
// progress stream is ever opened by the stale upload.
await expect(page.getByPlaceholder('https://github.com/owner/repo')).toBeEditable();
await expect(page.locator('[data-testid="upload-progress"]')).toBeHidden();
expect(progressOpened).toBe(false);
});
+2 -2
View File
@@ -218,8 +218,8 @@ test.describe('Flow 3: Analyze form', () => {
// Switch to Local Folder tab
await page.getByRole('tab', { name: 'Local Folder' }).click();
// Browse button should be visible
await expect(page.getByText('Browse for folder')).toBeVisible();
// Upload-a-folder button should be visible (browser folder upload)
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('local-folder-tab.png') });
});
+75 -75
View File
@@ -18,7 +18,7 @@
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"d3": "^7.9.0",
"dompurify": "^3.4.7",
"dompurify": "^3.4.8",
"gitnexus-shared": "file:../gitnexus-shared",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@@ -28,7 +28,7 @@
"graphology-utils": "^2.3.0",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"langchain": "^1.4.2",
"langchain": "^1.4.4",
"lru-cache": "^11.2.4",
"lucide-react": "^1.16.0",
"mermaid": "^11.15.0",
@@ -41,7 +41,7 @@
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"sigma": "^3.0.3",
"tailwindcss": "^4.2.4",
"uuid": "^14.0.0",
"zod": "^4.4.3"
@@ -57,9 +57,9 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.8.8",
"@vercel/node": "^5.8.12",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/coverage-v8": "^4.1.8",
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
@@ -2930,9 +2930,9 @@
}
},
"node_modules/@vercel/build-utils": {
"version": "13.26.4",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.26.4.tgz",
"integrity": "sha512-0g3ZxtZUJZbt4y0Vu4pkHtu1UN58FbVF9cqGT8T6jHp0EHdLGFj5TVCiME8ALeK4tjPImSmxnKZvvB5yb2hqEw==",
"version": "13.27.1",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.27.1.tgz",
"integrity": "sha512-BD9H2U8I/IPGS1c1stSIkdPxBRu6bkCQFqtRjcT2dcdnBawyHXvzTsnnBQhgB3fJVQtgJT47gVZe1oHDmv0Ktg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2949,9 +2949,9 @@
"license": "MIT"
},
"node_modules/@vercel/error-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.1.0.tgz",
"integrity": "sha512-DiJcXBOB9N6QM4d7hYPM9Ck/AUjzBl58XNQPxS74o7CuvIanjzrGgygP/70VsyEASeIJMazk1LrhwcNTR/eZGQ==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.0.tgz",
"integrity": "sha512-WFWiRxfPzoYWYifaj4thSKvAaZZwUOqD4k5GINRIgZgCiS2E3iAJbWbIsIZmkQdTecWFHcWGA6q48CjisgpOBA==",
"dev": true,
"license": "Apache-2.0"
},
@@ -2983,9 +2983,9 @@
}
},
"node_modules/@vercel/node": {
"version": "5.8.8",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.8.tgz",
"integrity": "sha512-+uRT9evnGWUE6klrJJED4fCvlSxNShbIc/UY4FeUzt2sdcy5a5b1IoYlo94RJd7tAY9Jg2lR2cVfGfsnWH81ZA==",
"version": "5.8.12",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.12.tgz",
"integrity": "sha512-XK2ML9YVdAlZ3BmGTW4jQL0D55ZHeRWKS+CLPSWReDyOBKaC4tTnTL2tp3z76bAs0pfgbT55nplMiX2mneSbLA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2993,8 +2993,8 @@
"@edge-runtime/primitives": "4.1.0",
"@edge-runtime/vm": "3.2.0",
"@types/node": "20.11.0",
"@vercel/build-utils": "13.26.4",
"@vercel/error-utils": "2.1.0",
"@vercel/build-utils": "13.27.1",
"@vercel/error-utils": "2.2.0",
"@vercel/nft": "1.10.0",
"@vercel/static-config": "3.4.0",
"async-listen": "3.0.0",
@@ -3101,14 +3101,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.8",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -3122,8 +3122,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.5",
"vitest": "4.1.5"
"@vitest/browser": "4.1.8",
"vitest": "4.1.8"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -3132,16 +3132,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -3150,13 +3150,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"@vitest/spy": "4.1.8",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -3187,9 +3187,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3200,13 +3200,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.8",
"pathe": "^2.0.3"
},
"funding": {
@@ -3214,14 +3214,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/pretty-format": "4.1.8",
"@vitest/utils": "4.1.8",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3230,9 +3230,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3240,13 +3240,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/pretty-format": "4.1.8",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -4461,9 +4461,9 @@
"peer": true
},
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -5752,9 +5752,9 @@
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
"node_modules/langchain": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.2.tgz",
"integrity": "sha512-SLGipy0r4nqQD0aiUOBYLMeGFfB/QiYnMndfZ8sGN89vXDCIXbYqcE7G/4QDDX3nZsM7/emQpoScmlxEX6sDnQ==",
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.4.tgz",
"integrity": "sha512-tepOCwUDaIZOYJ9Eo0O6o5dXEN/0KJheiFDnHHFL8Tx8rfkDLL4cOTSTln4Vpn9LpWzXYkjQ8lkHnnNDQWZPeg==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph": "^1.3.2",
@@ -8153,9 +8153,9 @@
"license": "ISC"
},
"node_modules/sigma": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz",
"integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==",
"license": "MIT",
"dependencies": {
"events": "^3.3.0",
@@ -8822,19 +8822,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
"@vitest/pretty-format": "4.1.8",
"@vitest/runner": "4.1.8",
"@vitest/snapshot": "4.1.8",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -8862,12 +8862,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"@vitest/browser-playwright": "4.1.8",
"@vitest/browser-preview": "4.1.8",
"@vitest/browser-webdriverio": "4.1.8",
"@vitest/coverage-istanbul": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"@vitest/ui": "4.1.8",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+5 -5
View File
@@ -28,7 +28,7 @@
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"d3": "^7.9.0",
"dompurify": "^3.4.7",
"dompurify": "^3.4.8",
"gitnexus-shared": "file:../gitnexus-shared",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@@ -38,7 +38,7 @@
"graphology-utils": "^2.3.0",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"langchain": "^1.4.2",
"langchain": "^1.4.4",
"lru-cache": "^11.2.4",
"lucide-react": "^1.16.0",
"mermaid": "^11.15.0",
@@ -51,7 +51,7 @@
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"sigma": "^3.0.3",
"tailwindcss": "^4.2.4",
"uuid": "^14.0.0",
"zod": "^4.4.3"
@@ -67,9 +67,9 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.8.8",
"@vercel/node": "^5.8.12",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/coverage-v8": "^4.1.8",
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
+15 -1
View File
@@ -27,6 +27,7 @@ import { EmbeddingStatus } from './EmbeddingStatus';
import { RepoAnalyzer } from './RepoAnalyzer';
import { LanguageSwitcher } from './LanguageSwitcher';
import { translateProgressMessage } from '../i18n/progress';
import { formatBackendError } from '../i18n/error-messages';
// Color mapping for node types in search results
const NODE_TYPE_COLORS: Record<string, string> = {
@@ -58,7 +59,7 @@ export const Header = ({
onAnalyzeComplete,
onReposChanged,
}: HeaderProps) => {
const { t } = useTranslation(['common', 'header']);
const { t } = useTranslation(['common', 'header', 'errors']);
const {
projectName,
graph,
@@ -72,6 +73,7 @@ export const Header = ({
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
const [showAnalyzer, setShowAnalyzer] = useState(false);
const [reanalyzing, setReanalyzing] = useState<string | null>(null); // repo name being re-analyzed
const [deleteError, setDeleteError] = useState<string | null>(null); // surfaced when a delete is rejected (e.g. origin-blocked 403)
const [reanalyzeProgress, setReanalyzeProgress] = useState<JobProgress | null>(null);
const reanalyzeSseRef = useRef<AbortController | null>(null);
const repoDropdownRef = useRef<HTMLDivElement>(null);
@@ -305,6 +307,7 @@ export const Header = ({
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
}
setDeleteError(null);
try {
await deleteRepo(repo.name);
const updated = await fetchRepos();
@@ -317,7 +320,11 @@ export const Header = ({
window.location.reload();
}
} catch (err) {
// Surface the failure instead of silently no-opping —
// e.g. an origin-blocked 403 when driving a local
// backend from the hosted UI.
console.error('Failed to delete repo:', err);
setDeleteError(formatBackendError(err, t));
}
}}
className="cursor-pointer rounded p-1 text-text-muted/0 transition-all group-hover:text-text-muted hover:!text-red-400"
@@ -330,6 +337,13 @@ export const Header = ({
</div>
)}
{/* Surfaced delete failure (e.g. origin-blocked 403) */}
{deleteError && (
<div className="px-3 py-2 text-xs text-red-400" role="alert">
{deleteError}
</div>
)}
{/* Re-analyze progress bar */}
{reanalyzing && reanalyzeProgress && (
<div className="border-t border-border-subtle bg-accent/5 px-4 py-2.5">
+172 -40
View File
@@ -21,9 +21,11 @@ import {
startAnalyze,
cancelAnalyze,
streamAnalyzeProgress,
uploadFolder,
type JobProgress,
} from '../services/backend-client';
import { AnalyzeProgress } from './AnalyzeProgress';
import { filterRepoFiles } from '@/lib/upload-filter';
import { useTranslation } from 'react-i18next';
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -165,8 +167,11 @@ export interface RepoAnalyzerProps {
export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProps) => {
const { t } = useTranslation(['common', 'errors', 'onboarding']);
const inputId = useId();
const folderInputRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<InputMode>('github');
const [uploading, setUploading] = useState(false);
const [uploadSummary, setUploadSummary] = useState<{ count: number; dropped: number } | null>(
null,
);
const [githubUrl, setGithubUrl] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('');
const [localPath, setLocalPath] = useState('');
@@ -181,28 +186,73 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const jobIdRef = useRef<string | null>(null);
const sseControllerRef = useRef<AbortController | null>(null);
// Owns the in-flight analyze/upload request. The controller doubles as the
// staleness token: each request captures its own controller in a closure and
// bails after the await when that controller was aborted, so a resolution
// arriving after a mode switch / cancel / unmount can never drive state.
const requestControllerRef = useRef<AbortController | null>(null);
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
return () => {
sseControllerRef.current?.abort();
requestControllerRef.current?.abort();
if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
};
}, []);
// Abort any in-flight analyze/upload request so its settlement can't drive
// state. Aborting is load-bearing: once a mode switch resets `uploading`,
// the `uploading || isLoading` re-entry guard no longer covers the stale
// request — only its aborted signal does.
const invalidateRequest = (): void => {
requestControllerRef.current?.abort();
requestControllerRef.current = null;
};
// Invalidate the previous request and hand the caller a fresh controller.
const renewRequestController = (): AbortController => {
invalidateRequest();
const controller = new AbortController();
requestControllerRef.current = controller;
return controller;
};
// An upload that resolved after invalidation has still created a server-side
// job; cancel it so the single analyze slot isn't held for the job's full
// duration. Upload-path only: every upload owns a fresh job (the server
// stages each upload into a unique dir, never dedup-aliasing), whereas URL
// analyzes dedup-alias by repo — the returned jobId may belong to a job
// another session (or this user's own resubmit) is actively watching, so
// cancelling on that path could kill a live analysis. A stale URL job is
// left to finish: a same-URL resubmit re-attaches to it via dedup, and the
// server's job timeout/TTL sweep bounds the slot occupancy.
const cancelStaleUploadJob = (jobId: string): void => {
void cancelAnalyze(jobId).catch(() => {});
};
const handleModeChange = (m: InputMode) => {
// ModeTabs fires onChange on every click, including the already-active
// tab — never abort the user's own in-flight request for a no-op click.
if (m === mode) return;
invalidateRequest();
setMode(m);
setGithubUrl('');
setGitlabUrl('');
setLocalPath('');
setValidationError(null);
setUploadSummary(null);
setUploading(false);
// An aborted request no longer resolves to move `phase` off 'starting';
// reset so the new mode's form is immediately usable (also clears a stale
// 'error' phase). Only reachable while showInput is true.
setPhase('input');
};
// Use the browser's native directory picker (webkitdirectory doesn't give paths,
// so we use a text input + a "Browse" button that opens a standard file input
// to let users pick files from the folder — the path is typed manually since
// browsers don't expose absolute paths for security reasons).
// For local paths, the user types or pastes the absolute path.
// Local-folder mode uploads the selected folder's files (the browser never
// exposes an absolute path, so the old typed-path/browse approach couldn't
// work — see handleFolderUpload). A typed server path is also still accepted.
const canSubmit =
mode === 'github'
@@ -228,6 +278,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
setValidationError(null);
setPhase('starting');
// Staleness guard only (no wire abort): the POST is short-lived and
// self-terminates, but its resolution must not drive state after a mode
// switch / cancel / unmount invalidated this request.
const controller = renewRequestController();
try {
const request =
mode === 'github'
@@ -236,8 +290,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
? { url: gitlabUrl.trim() }
: { path: localPath.trim() };
const { jobId } = await startAnalyze(request);
jobIdRef.current = jobId;
setPhase('analyzing');
// Stale resolution: return without cancelling — URL jobIds may be
// dedup-aliased to a job another session owns (see cancelStaleUploadJob).
if (controller.signal.aborted) return;
const nameSource =
mode === 'github'
@@ -245,29 +300,84 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
: mode === 'gitlab'
? gitlabUrl.trim()
: localPath.trim();
const controller = streamAnalyzeProgress(
jobId,
(p) => setProgress(p),
(data) => {
const name =
data.repoName ??
nameSource.split(/[/\\]/).filter(Boolean).at(-1) ??
t('onboarding:repoAnalyzer.defaultRepoName');
setCompletedRepoName(name);
setPhase('done');
sseControllerRef.current = null;
completeTimerRef.current = setTimeout(() => {
completeTimerRef.current = null;
onComplete(name);
}, 1200);
},
(errMsg) => {
setValidationError(errMsg || t('errors:analysisFailed'));
setPhase('error');
},
);
sseControllerRef.current = controller;
trackJob(jobId, nameSource);
} catch (err) {
// Unmount aborts the controller, so this also covers the unmounted case.
if (controller.signal.aborted) return;
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
setPhase('error');
}
};
// Drive an already-created analysis job through the SSE progress stream to
// completion. Shared by the path/URL analyze flow and the folder-upload flow.
const trackJob = (jobId: string, fallbackNameSource: string | null) => {
// Callers reach here only with a live (non-aborted) request controller, so
// the component is mounted — unmount aborts the controller.
jobIdRef.current = jobId;
setPhase('analyzing');
const controller = streamAnalyzeProgress(
jobId,
(p) => setProgress(p),
(data) => {
const name =
data.repoName ??
(fallbackNameSource
? fallbackNameSource.split(/[/\\]/).filter(Boolean).at(-1)
: undefined) ??
t('onboarding:repoAnalyzer.defaultRepoName');
setCompletedRepoName(name);
setPhase('done');
sseControllerRef.current = null;
completeTimerRef.current = setTimeout(() => {
completeTimerRef.current = null;
onComplete(name);
}, 1200);
},
(errMsg) => {
setValidationError(errMsg || t('errors:analysisFailed'));
setPhase('error');
},
);
sseControllerRef.current = controller;
};
// Upload a browser-selected folder (webkitdirectory) and start analysis. The
// upload endpoint returns a jobId, which then joins the normal SSE flow.
const handleFolderUpload = async (fileList: FileList) => {
if (uploading || isLoading) return; // guard against a concurrent upload
const { files, manifest, droppedCount } = filterRepoFiles(fileList);
if (files.length === 0) {
setValidationError(t('onboarding:repoAnalyzer.upload.empty'));
return;
}
setValidationError(null);
setUploadSummary({ count: files.length, dropped: droppedCount });
setUploading(true);
setPhase('starting');
// The selected folder's name (manifest entries are `<folder>/<rest>`) is a
// sensible fallback if the server's complete event omits repoName.
const folderName = manifest[0]?.split('/')[0] ?? null;
const controller = renewRequestController();
try {
const { jobId } = await uploadFolder(files, manifest, controller.signal);
if (controller.signal.aborted) {
// The abort raced the response: the server already created the job.
// (Unmount aborts the controller, so this also covers unmounted.)
cancelStaleUploadJob(jobId);
return;
}
setUploading(false);
trackJob(jobId, folderName);
} catch (err) {
// An abort surfaces in two shapes — BackendError('Request aborted')
// when it lands during fetch, raw AbortError when it lands during the
// response-body read — so branch on the closure controller's signal,
// never on the error identity. In the second shape the server may have
// already launched a job whose id we never learn; that orphan is bounded
// by the server's job timeout and terminal-job TTL sweep.
if (controller.signal.aborted) return;
setUploading(false);
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
setPhase('error');
}
@@ -276,6 +386,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const handleCancel = async () => {
sseControllerRef.current?.abort();
sseControllerRef.current = null;
// Defensive: no UI path can reach handleCancel while a request is in
// flight (the cancel affordance renders only at phase === 'analyzing'),
// but invalidate it anyway so the guard topology has no holes.
invalidateRequest();
if (jobIdRef.current) {
try {
await cancelAnalyze(jobIdRef.current);
@@ -284,6 +398,8 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
}
setPhase('input');
setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') });
setUploading(false);
setUploadSummary(null);
};
const isLoading = phase === 'starting';
@@ -443,35 +559,51 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
)}
</div>
{/* Native folder picker + Browse button — below the input */}
{/* Upload a folder from your computer — no server path or mount needed.
The browser can't expose an absolute path, so we upload the files. */}
<input
ref={folderInputRef}
type="file"
// @ts-expect-error -- webkitdirectory is non-standard but widely supported
webkitdirectory=""
multiple
className="hidden"
data-testid="folder-upload-input"
onChange={(e) => {
const files = e.target.files;
if (files && files.length > 0) {
const rel = files[0].webkitRelativePath;
const folderName = rel.split('/')[0];
if (folderName) {
setLocalPath(folderName);
setValidationError(null);
}
if (e.target.files && e.target.files.length > 0) {
handleFolderUpload(e.target.files);
}
e.target.value = '';
}}
/>
<button
type="button"
data-testid="upload-folder"
onClick={() => folderInputRef.current?.click()}
disabled={isLoading}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
>
<FolderOpen className="h-3.5 w-3.5" />
{t('onboarding:repoAnalyzer.browseForFolder')}
{t('onboarding:repoAnalyzer.upload.button')}
</button>
{uploading && (
<div role="status" aria-busy="true" data-testid="upload-progress" className="space-y-1">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
<div className="h-full w-1/3 animate-pulse rounded-full bg-accent" />
</div>
<p className="text-xs text-text-muted">
{t('onboarding:repoAnalyzer.upload.uploading')}
</p>
</div>
)}
{uploadSummary && !uploading && phase !== 'error' && (
<p className="text-xs text-text-muted" data-testid="upload-summary">
{t('onboarding:repoAnalyzer.upload.selected', {
fileCount: uploadSummary.count,
dropped: uploadSummary.dropped,
})}
</p>
)}
</div>
)}
+2
View File
@@ -14,6 +14,8 @@ export function formatBackendError(error: unknown, t: TFunction): string {
return t('errors:backend.rateLimited', { seconds, defaultValue: fallback });
case 'not_found':
return t('errors:backend.notFound', { defaultValue: fallback });
case 'origin_blocked':
return t('errors:backend.originBlocked', { defaultValue: fallback });
case 'client':
return t('errors:backend.client', { message: error.message, defaultValue: fallback });
case 'server':
+2
View File
@@ -38,6 +38,7 @@ export const NODE_COLORS: Record<NodeLabel, string> = {
Template: '#a78bfa', // Violet light - like Type
Route: '#f43f5e', // Rose - like Process
Tool: '#a855f7', // Purple - like Project
BasicBlock: '#475569', // Slate darker - control-flow node (muted, taint/PDG substrate)
};
// Node sizes by type - clear visual hierarchy with dramatic size differences
@@ -79,6 +80,7 @@ export const NODE_SIZES: Record<NodeLabel, number> = {
Template: 3, // Like Type
Route: 5, // Like Enum
Tool: 5, // Like Enum
BasicBlock: 2, // Tiny - control-flow node (taint/PDG substrate)
};
// Community color palette for cluster-based coloring
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { filterRepoFiles, MAX_FILE_BYTES } from './upload-filter';
type FileLike = { name: string; size: number; webkitRelativePath?: string };
function f(webkitRelativePath: string, size = 10): FileLike {
const name = webkitRelativePath.split('/').pop() ?? webkitRelativePath;
return { name, size, webkitRelativePath };
}
describe('filterRepoFiles', () => {
it('keeps source files and builds an order-aligned manifest', () => {
const input = [f('repo/src/index.ts', 100), f('repo/README.md', 50)];
const r = filterRepoFiles(input);
expect(r.files).toHaveLength(2);
expect(r.manifest).toEqual(['repo/src/index.ts', 'repo/README.md']);
expect(r.totalBytes).toBe(150);
expect(r.droppedCount).toBe(0);
});
it('excludes .git / node_modules / build dirs anywhere in the path', () => {
const input = [
f('repo/.git/HEAD'),
f('repo/node_modules/x/index.js'),
f('repo/dist/bundle.js'),
f('repo/src/app.ts'),
f('repo/.gitnexus/meta.json'),
];
const r = filterRepoFiles(input);
expect(r.manifest).toEqual(['repo/src/app.ts']);
expect(r.droppedCount).toBe(4);
});
it('drops files over the per-file size cap', () => {
const input = [f('repo/big.bin', MAX_FILE_BYTES + 1), f('repo/small.ts', 10)];
const r = filterRepoFiles(input);
expect(r.manifest).toEqual(['repo/small.ts']);
expect(r.droppedCount).toBe(1);
});
it('falls back to name when webkitRelativePath is absent', () => {
const r = filterRepoFiles([{ name: 'lone.ts', size: 5 }]);
expect(r.manifest).toEqual(['lone.ts']);
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Client-side pre-filter for a webkitdirectory folder upload.
*
* Drops VCS metadata, dependency/build directories, and oversized files before
* upload — `.git` alone is often larger than the working tree — so payloads
* stay small and the upload matches what the analyzer actually needs. Produces
* an order-aligned `manifest` of webkitRelativePaths (the server keys on this,
* not the multipart filename, which browsers rewrite).
*/
/** Directory names excluded anywhere in a file's path. */
export const EXCLUDED_DIRS = new Set([
'.git',
'.hg',
'.svn',
'node_modules',
'vendor',
'.venv',
'__pycache__',
'target',
'dist',
'build',
'out',
'.next',
'.nuxt',
'.cache',
'coverage',
'.idea',
'.gitnexus',
]);
/** Per-file size cap; matches the server's per-file limit. */
export const MAX_FILE_BYTES = 25 * 1024 * 1024;
export interface FilterResult {
files: File[];
manifest: string[];
droppedCount: number;
totalBytes: number;
}
type FileLike = Pick<File, 'name' | 'size'> & { webkitRelativePath?: string };
/**
* Filter a webkitdirectory `FileList` (or array) into the files to upload plus
* their relative-path manifest.
*/
export function filterRepoFiles(input: ArrayLike<FileLike>): FilterResult {
const files: File[] = [];
const manifest: string[] = [];
let droppedCount = 0;
let totalBytes = 0;
for (let i = 0; i < input.length; i++) {
const f = input[i];
const rel =
f.webkitRelativePath && f.webkitRelativePath.length > 0 ? f.webkitRelativePath : f.name;
const segments = rel.split('/');
if (segments.some((s) => EXCLUDED_DIRS.has(s))) {
droppedCount++;
continue;
}
if (f.size > MAX_FILE_BYTES) {
droppedCount++;
continue;
}
files.push(f as File);
manifest.push(rel);
totalBytes += f.size;
}
return { files, manifest, droppedCount, totalBytes };
}
+1
View File
@@ -13,6 +13,7 @@
"timeout": "The server took too long to respond. Try again in a moment.",
"rateLimited": "Too many requests. Try again in {{seconds}}s.",
"notFound": "The requested repository or resource was not found.",
"originBlocked": "This action isn't available from the hosted UI. Open GitNexus from the server's own address (e.g. http://localhost:4747) to continue.",
"client": "Request failed: {{message}}",
"server": "Server error: {{message}}"
}
+7 -2
View File
@@ -61,7 +61,12 @@
"gitlabRepositoryUrl": "GitLab Repository URL",
"gitlabSupported": "Supports GitLab.com and self-hosted GitLab instances.",
"localFolderPath": "Local Folder Path",
"browseForFolder": "Browse for folder",
"hideBackground": "Hide (analysis continues in background)"
"hideBackground": "Hide (analysis continues in background)",
"upload": {
"button": "Upload a folder",
"uploading": "Uploading…",
"selected": "{{fileCount}} files ready ({{dropped}} skipped: .git, node_modules, build output)",
"empty": "No analyzable files found in that folder."
}
}
}
@@ -13,6 +13,7 @@
"timeout": "服务器响应超时,请稍后重试。",
"rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。",
"notFound": "未找到请求的仓库或资源。",
"originBlocked": "此操作无法从托管界面执行。请通过服务器自身地址(例如 http://localhost:4747)打开 GitNexus 后再继续。",
"client": "请求失败:{{message}}",
"server": "服务器错误:{{message}}"
}
@@ -61,7 +61,12 @@
"gitlabRepositoryUrl": "GitLab 仓库 URL",
"gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。",
"localFolderPath": "本地文件夹路径",
"browseForFolder": "浏览文件夹",
"hideBackground": "隐藏(分析继续在后台进行)"
"hideBackground": "隐藏(分析继续在后台进行)",
"upload": {
"button": "上传文件夹",
"uploading": "上传中…",
"selected": "已准备 {{fileCount}} 个文件(已跳过 {{dropped}} 个:.git、node_modules、构建产物)",
"empty": "该文件夹中未找到可分析的文件。"
}
}
}
+50 -10
View File
@@ -79,7 +79,11 @@ export class BackendError extends Error {
| 'client'
| 'not_found'
| 'timeout'
| 'rate_limited',
| 'rate_limited'
// The write-route same-host Origin guard rejected this request (HTTP 403
// with `{ code: 'origin_not_allowed' }`). Distinct from a generic `client`
// 403 so the UI can show actionable "open the local UI" guidance.
| 'origin_blocked',
/**
* Milliseconds until the caller should retry. Populated for rate-limited
* responses (HTTP 429) from the server's `Retry-After` header. `undefined`
@@ -283,12 +287,11 @@ const fetchWithTimeout = async (
): Promise<Response> => {
// Merge the external caller signal (if any) with an
// `AbortSignal.timeout()` so a timer-fired abort produces a
// `DOMException` with `name === 'TimeoutError'` — which
// `resilientFetch` correctly classifies as terminal-network (no
// retry, no breaker hit). A manual `AbortController.abort()` would
// produce `name === 'AbortError'` and route through the
// retryable-network branch, which mis-penalizes the breaker for
// user-side network slowness.
// `DOMException` with `name === 'TimeoutError'`. Both shapes are
// breaker-safe: `resilientFetch` classifies TimeoutError AND a manual
// `AbortController.abort()`'s AbortError as terminal-network (no
// retry, breaker-neutral via recordNeutral), so caller-driven
// cancellation never penalizes the breaker.
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const externalSignal = init.signal;
const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
@@ -362,6 +365,7 @@ const assertOk = async (response: Response): Promise<void> => {
if (response.ok) return;
let message = response.statusText;
let bodyCode: string | undefined;
try {
const body = await response.json();
if (body && typeof body.error === 'string') {
@@ -369,6 +373,9 @@ const assertOk = async (response: Response): Promise<void> => {
} else if (body && typeof body.message === 'string') {
message = body.message;
}
if (body && typeof body.code === 'string') {
bodyCode = body.code;
}
} catch {
// Response body was not JSON
}
@@ -378,9 +385,13 @@ const assertOk = async (response: Response): Promise<void> => {
? 'not_found'
: response.status === 429
? 'rate_limited'
: response.status >= 400 && response.status < 500
? 'client'
: 'server';
: // The write-route Origin guard returns 403 with this discriminator;
// surface it as a distinct code so the UI can give actionable guidance.
bodyCode === 'origin_not_allowed'
? 'origin_blocked'
: response.status >= 400 && response.status < 500
? 'client'
: 'server';
// Retry-After is the standard HTTP signal for when the client may try again.
// express-rate-limit emits it on 429 with seconds (integer) or HTTP-date.
@@ -755,6 +766,35 @@ export const fetchClusterDetail = async (repo: string, name: string): Promise<un
return response.json();
};
// ── Upload API ─────────────────────────────────────────────────────────────
/**
* Upload a folder (selected via `<input webkitdirectory>`) and start analysis.
* Sends the file blobs plus a JSON `manifest` of their relative paths — the
* multipart filename can't carry the path (browsers strip separators), so the
* manifest is the source of truth. Routed through fetchWithTimeout (the shared,
* origin-validated request path) rather than a raw XHR; returns the analysis
* jobId, which the caller drives through the normal SSE flow.
*/
export const uploadFolder = async (
files: File[],
manifest: string[],
signal?: AbortSignal,
): Promise<{ jobId: string; status: string }> => {
const form = new FormData();
// Manifest MUST precede the file parts (the server enforces this).
form.append('manifest', JSON.stringify(manifest));
for (const f of files) form.append('files', f);
const response = await fetchWithTimeout(
`${_backendUrl}/api/analyze/upload`,
{ method: 'POST', body: form, signal },
5 * 60_000, // up to 5 min for large repos
);
await assertOk(response);
return response.json() as Promise<{ jobId: string; status: string }>;
};
// ── Analyze API ────────────────────────────────────────────────────────────
/** Start a server-side analysis job. */
@@ -13,7 +13,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getBreaker } from 'gitnexus-shared';
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
import { fetchRepos, setBackendUrl, startAnalyze } from '../../src/services/backend-client';
import {
deleteRepo,
fetchRepos,
setBackendUrl,
startAnalyze,
} from '../../src/services/backend-client';
const BASE = 'http://localhost:4747';
@@ -89,6 +94,40 @@ describe('backend-client retry budget (method-aware)', () => {
expect(getBreaker(bKey).getConsecutiveFailures()).toBe(0);
});
it('maps an origin-blocked 403 to BackendError code "origin_blocked"', async () => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
error: 'This endpoint is restricted to same-host origins',
code: 'origin_not_allowed',
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
await expect(deleteRepo('my-repo')).rejects.toMatchObject({
status: 403,
code: 'origin_blocked',
});
// 403 is a terminal client error — never retried.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('maps a generic 403 (no recognized code) to BackendError code "client" (back-compat)', async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ error: 'forbidden' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await expect(deleteRepo('my-repo')).rejects.toMatchObject({ status: 403, code: 'client' });
});
it('breaker not incremented when timeout fires (TimeoutError, not AbortError)', async () => {
// Reject directly with a TimeoutError DOMException, mimicking what
// `fetch` produces when its `AbortSignal.timeout()`-wired signal
@@ -0,0 +1,218 @@
/**
* Stale-request guards in RepoAnalyzer (PR #1850 review 4470339833).
*
* An analyze request (folder upload or URL analyze) that is still in flight
* when the user switches modes, cancels, or unmounts must not drive state
* when it later settles: no SSE stream, no phase/error flip — and a
* stale-but-created server job gets a fire-and-forget cancel so the single
* analyze slot is freed.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
import { i18nReady } from '../../src/i18n';
import {
cancelAnalyze,
startAnalyze,
streamAnalyzeProgress,
uploadFolder,
} from '../../src/services/backend-client';
vi.mock('../../src/services/backend-client', () => ({
startAnalyze: vi.fn(),
cancelAnalyze: vi.fn(),
streamAnalyzeProgress: vi.fn(),
uploadFolder: vi.fn(),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (err: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const JOB = { jobId: 'job-1', status: 'queued' };
/** Gate uploadFolder on a deferred promise and expose the signal it received. */
function mockUploadWith(d: { promise: Promise<typeof JOB> }) {
let captured: AbortSignal | undefined;
vi.mocked(uploadFolder).mockImplementation((_files, _manifest, signal) => {
captured = signal;
return d.promise;
});
return { signal: () => captured };
}
/** Render, switch to Local Folder mode, and fire a folder selection. */
function startUpload() {
const view = render(<RepoAnalyzer variant="onboarding" onComplete={vi.fn()} />);
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
fireEvent.change(screen.getByTestId('folder-upload-input'), {
target: { files: [new File(['x'], 'a.ts')] },
});
return view;
}
beforeEach(async () => {
await i18nReady;
vi.clearAllMocks();
vi.mocked(cancelAnalyze).mockResolvedValue(undefined as never);
vi.mocked(streamAnalyzeProgress).mockImplementation(() => new AbortController());
});
describe('folder upload', () => {
it('a mode switch mid-upload makes the resolution inert and cancels the job', async () => {
const d = deferred<typeof JOB>();
const upload = mockUploadWith(d);
startUpload();
fireEvent.click(screen.getByRole('tab', { name: 'GitHub URL' }));
// The wire abort happened at mode-switch time, not at resolution time.
expect(upload.signal()?.aborted).toBe(true);
await act(async () => {
d.resolve(JOB);
});
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
expect(cancelAnalyze).toHaveBeenCalledWith('job-1');
// The GitHub form is clean and submittable (phase back to 'input').
expect(screen.getByRole('textbox')).toBeEnabled();
expect(screen.queryByTestId('upload-progress')).not.toBeInTheDocument();
});
it.each([
['BackendError shape', new Error('Request aborted')],
['raw AbortError shape', new DOMException('The operation was aborted.', 'AbortError')],
])('an aborted rejection is silent — %s', async (_label, err) => {
const d = deferred<typeof JOB>();
vi.mocked(uploadFolder).mockReturnValue(d.promise);
startUpload();
fireEvent.click(screen.getByRole('tab', { name: 'GitHub URL' }));
await act(async () => {
d.reject(err);
});
expect(screen.queryByText('Request aborted')).not.toBeInTheDocument();
expect(screen.queryByText('The operation was aborted.')).not.toBeInTheDocument();
expect(screen.getByRole('textbox')).toBeEnabled();
});
it('a same-tab click does not abort the in-flight upload', async () => {
const d = deferred<typeof JOB>();
const upload = mockUploadWith(d);
startUpload();
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
expect(upload.signal()?.aborted).toBe(false);
await act(async () => {
d.resolve(JOB);
});
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
expect(cancelAnalyze).not.toHaveBeenCalled();
});
it('an unmount mid-upload makes the resolution inert', async () => {
const d = deferred<typeof JOB>();
const upload = mockUploadWith(d);
const { unmount } = startUpload();
unmount();
expect(upload.signal()?.aborted).toBe(true);
await act(async () => {
d.resolve(JOB);
});
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
});
it('the happy path still tracks the job', async () => {
const d = deferred<typeof JOB>();
vi.mocked(uploadFolder).mockReturnValue(d.promise);
startUpload();
await act(async () => {
d.resolve(JOB);
});
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
expect(vi.mocked(streamAnalyzeProgress).mock.calls[0][0]).toBe('job-1');
expect(cancelAnalyze).not.toHaveBeenCalled();
});
it('a genuine error still surfaces', async () => {
const d = deferred<typeof JOB>();
vi.mocked(uploadFolder).mockReturnValue(d.promise);
startUpload();
await act(async () => {
d.reject(new Error('upload exploded'));
});
expect(screen.getByText('upload exploded')).toBeInTheDocument();
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
});
});
describe('URL analyze', () => {
function startGithubAnalyze() {
render(<RepoAnalyzer variant="onboarding" onComplete={vi.fn()} />);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://github.com/owner/repo' },
});
fireEvent.click(screen.getByRole('button', { name: /Analyze Repository/ }));
}
it('a mode switch mid-analyze makes the resolution inert without cancelling', async () => {
const d = deferred<typeof JOB>();
vi.mocked(startAnalyze).mockReturnValue(d.promise);
startGithubAnalyze();
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
await act(async () => {
d.resolve({ jobId: 'job-2', status: 'queued' });
});
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
// No cancel on the URL path: the jobId may be dedup-aliased to a job
// another session owns, so cancelling could kill a live analysis.
expect(cancelAnalyze).not.toHaveBeenCalled();
});
it('a stale rejection is silent', async () => {
const d = deferred<typeof JOB>();
vi.mocked(startAnalyze).mockReturnValue(d.promise);
startGithubAnalyze();
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
await act(async () => {
d.reject(new Error('analyze exploded'));
});
expect(screen.queryByText('analyze exploded')).not.toBeInTheDocument();
expect(screen.getByTestId('upload-folder')).toBeEnabled();
});
it('the happy path still tracks the job', async () => {
const d = deferred<typeof JOB>();
vi.mocked(startAnalyze).mockReturnValue(d.promise);
startGithubAnalyze();
await act(async () => {
d.resolve({ jobId: 'job-3', status: 'queued' });
});
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
expect(vi.mocked(streamAnalyzeProgress).mock.calls[0][0]).toBe('job-3');
expect(cancelAnalyze).not.toHaveBeenCalled();
});
});
+20
View File
@@ -13,6 +13,26 @@ node_modules/
vendor/**/node_modules
vendor/**/build
# ── Lean publish (FUTURE optimization — NOT done here) ─────────────────────────
# Once the build-tree-sitter-prebuilds workflow has committed 6/6 prebuilds for
# EVERY vendored grammar (c, dart, proto, kotlin, swift), the ~50 MB of generated
# source (parser.c etc.) can be dropped from the tarball — node-gyp-build never
# needs the source when a prebuild matches.
#
# IMPORTANT: this CANNOT be done from this file. package.json's `files: ["vendor"]`
# allow-list OVERRIDES .npmignore for the vendor/ subtree (verified: an active
# `vendor/**/src/parser.c` line here does NOT exclude it from `npm pack`). To slim
# the tarball, narrow the `files` field instead — replace the blanket "vendor"
# with the non-source subpaths only (vendor/**/prebuilds/**,
# vendor/**/bindings/node/index.*, vendor/**/src/node-types.json,
# vendor/**/package.json, vendor/**/LICENSE, vendor/**/README.md).
#
# Whatever the mechanism, the prepack guard
# (scripts/assert-publish-grammar-coverage.cjs, also `npm run
# assert-publish-coverage`) inspects the EFFECTIVE `npm pack` file list and FAILS
# the publish whenever a grammar with <6 prebuilds loses a source-build input — so
# the slim can never silently ship a dead grammar. Do not bypass it.
# Package lock (consumers use their own)
package-lock.json
+94
View File
@@ -4,6 +4,100 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
## [1.6.7] - 2026-06-09
### Added
- **Toolchain-free tree-sitter install** — the `c`, `dart`, `proto`, `kotlin`, and `swift` grammars now ship vendored native prebuilds (six platform/arch each — linux/darwin/win32 × x64/arm64, every `.node` load-and-parse verified with committed `SHA256SUMS` and SLSA build provenance), so a fresh install no longer requires a C/C++ toolchain; `kotlin` moved off its `optionalDependency` into the vendored path, `dart`/`proto` keep a source-build fallback when no prebuild matches, and a registry-parameterized CI workflow builds, load-validates, and vendors the binaries (#2113, #2125, #2110)
- **`gitnexus uninstall`** — reverses `gitnexus setup` target-by-target, surgically removing GitNexus MCP server entries (Cursor, Claude Code, Antigravity, OpenCode, Codex), installed skill directories, and Claude Code / Antigravity hook entries with their bundled scripts; idempotent, JSONC-preserving, dry-run by default with `--force` to apply (#2062, #2060)
- **MCP `list_repos` pagination** — bounded `limit`/`offset` paging so clients can reliably enumerate every indexed repository instead of having the unpaginated array truncated by LLM token limits; the result is now a `{ repositories, pagination }` object (page until `pagination.hasMore` is false), with deterministic `(lower-cased name, path)` ordering (#2120, #2119)
- **C++ inheritance-lattice member lookup** — receiver members now resolve through the inheritance lattice with dominance hiding, ambiguous-base suppression, virtual-diamond deduplication, and overload ranking, and class-scope `using Base::member` declarations are no longer mistaken for namespace imports (#2077, #1891)
- **Taint/PDG substrate (M0)** — foundational graph schema and pipeline seams for reliable taint analysis on a PDG-expandable substrate: the `BasicBlock` node label and `CFG` / `REACHING_DEF` / `TAINTED` / `SANITIZES` / `TAINT_PATH` relationship types (round-tripped through the bulk-COPY path), a phase-registry seam (`registerPhase` / `enabledWhen`) generalising the graph-phase opt-in guard, and a per-language source/sink/sanitizer config registry. All additive and inert — no phase emits the new nodes/edges yet and a default `analyze` run is byte-identical to before (#2092, #2080)
### Fixed
- **Optional grammars lazy-loaded so `analyze` never crashes when one is missing** — the swift/dart/kotlin `query.ts` modules no longer statically import their tree-sitter binding at module load, so a missing optional grammar can no longer abort `gitnexus analyze` (or the MCP server, `doctor`, and `.githooks` auto-reindex) with `ERR_MODULE_NOT_FOUND` regardless of the repo's actual languages; grammars now resolve lazily at first use inside the worker, `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` is honored at runtime, the scope-resolution phase excludes unavailable-language files, and skip diagnostics/precheck globs were corrected (#2101, #2091, #2093)
- **`tree-sitter-kotlin` optional-grammar install** — install now fails soft when no C/C++ toolchain is present, emitting one clear warning and always exiting 0 (mirroring the Swift/Dart/Proto probes) instead of breaking `gitnexus` install; optional-grammar/toolchain docs corrected to include Kotlin (#2110, #2107)
- **CLI image FTS keyword search** — the full-text-search extension is now baked into the CLI Docker image so a containerized `serve` does offline keyword search instead of silently degrading to vector-only (#2108)
### Changed
- **Tree-sitter prebuild CI matrix greened and made re-run-safe** — dropped the broken `-t 22` flag from the `prebuildify` invocation that crashed every matrix job (`v.indexOf is not a function`; N-API prebuilds are Node-version-agnostic, so no target is needed) (#2121), cleared npm-bundled `prebuilds/` before prebuildify so the host tuple is detected (not a stray `win32-x64`) and source-built the `tree-sitter` runtime peer on `linux-arm64` where upstream ships no prebuild (#2122), and switched the vendor-prebuilds push to `git push --force` so re-running a workflow no longer fails with a stale-lease rejection (#2123)
### Performance
- **MCP `query` enrichment batched** — the `query` tool now batches its per-symbol enrichment lookups (3N sequential pool round-trips collapsed to 2–3 `WHERE n.id IN $nodeIds` queries), cutting N+1 round-trips with byte-identical output (#2108)
### Chore / Dependencies
- **`@ladybugdb/core` bumped 0.17.0 → 0.17.1 in /gitnexus** (#2098)
- **Claude plugin manifests synced to the release version** — bumped `plugin.json` and the `gitnexus` `marketplace.json` entry to match the published npm version (stale `1.3.x` manifests had blocked marketplace updates), added a Vitest guard asserting all three manifests advertise one version, and documented the sync step in `CONTRIBUTING.md` (#2090)
## [1.6.6] - 2026-06-08
### Added
- **Scope-resolution (RFC #909) migrations completed across the language matrix** — Rust (#1639), JavaScript (#1640), Ruby (#1831), Swift (#937, #1948), Vue SFC (#940, #1950), Dart (#939, #1970), COBOL (#941, #1835, #1842), and Kotlin (#1727, #1746, #1782) now run on the registry-primary path; Java reached 100% scope-resolution parity and joined `MIGRATED_LANGUAGES` (#1805); per-language progress reporting added to the scope-resolution phase (#1813)
- **HTTP route & consumer contract extraction (group mode)** — Spring interface routes attributed to controllers (#1743); named/positional Java Spring route args (#1834); Kotlin Spring HTTP route, consumer, and WebClient long-form extraction (#1849, #1855, #1884); Java HTTP consumer contracts (#1872); OpenFeign `@RequestLine` consumer contracts incl. plain interfaces without `@FeignClient` (#1904, #1917); FastAPI `include_router(prefix=...)` cross-file routes (#1877); indirect call patterns via FastAPI `Depends()` and frontend HTTP consumers (#1852); gRPC consumer FQN derivation from Java imports for client-jar consumers (#1889)
- **C++ overload & template resolution** — operator-call resolution (#1754), template partial ordering (#1885), user-defined conversion ranking (#1829), nullptr/ellipsis pointer conversion ranks (#1708), SFINAE filter (#1623), expanded `type_traits` constraint registry (#1648), structured resolver-suppression outcomes (#1785), function-type ADL entities (#1822), and a parameter-type class sidecar (#1642)
- **Go enhancements** — structural interface implementation inference (#1966) and a `builtInNames` set for the Go language provider (#1886)
- **Self-healing worker pool** — automatic worker replacement plus deferred-resolution observability and verbose progress logging (#1741, #1773, #1947)
- **`.gitnexusrc` config file and `gitnexus analyze --default-branch`** (#243, #1996)
- **CLI / MCP impact ergonomics** — `--uid/--file/--kind` disambiguation flags (#1907, #1914), `limit/offset/summaryOnly` pagination on the impact tool (#1818), and a per-symbol `processes` field on `byDepth` items (#1867)
- **`gitnexus analyze --repair-fts`** — enforces FTS verification with hardened repair safeguards (#1720)
- **Web viewer** — Tree View and Circles View (#1799), GitLab repository URLs (#1565), `GITNEXUS_BACKEND_URL` env var for Docker deployments (#1286), and web + CLI internationalization (#1748)
- **Wiki** — local Claude/Codex providers (#1769), an opencode local provider (#2039), and `gitnexus wiki --lang <lang>` for multilanguage wiki generation (#1613)
- **`detect-changes` git-worktree support** (#1654)
- **DeepSeek V4 API support** (#1594)
- **Devcontainer for the Claude / Codex / Cursor CLIs** (#1875) and antigravity integration setup + hook adapter (#1730)
- **Object-literal methods linked to exported bindings** (#1718)
- **`eval-server --host`** for a user-configured bind IP (#1667)
- **PR reviewer swarm agents** (#1851)
- **tree-sitter node-type/field validation gate** — validates against the grammar and removes dead literal handling (#1937)
### Fixed
- **Parsing-layer coverage gaps closed across the language matrix** (umbrella #1919) — remaining open gaps (#2072) plus Java F35/F38/F41 (#1928, #2045), PHP F53/F54/F55 (#1931, #1989), COBOL F17–F23 (#1925, #1959), Rust F66/F68/F71/F72 (#1934, #1974), Python F57/F58/F61 (#1932, #1964), JS/TS F44/F83/F85/F86/F87 (#1929, #1968), and Ruby F62 (#1933, #1972)
- **Fully-qualified nested-type identity for C++ and Ruby** — distinct nodes for union-, anonymous-namespace-, and same-tail-nested types (#1978, #1981, #2004, #2005); cross-namespace same-tail inheritance bases resolved (#1993, #2005); Ruby same-tail nested mixin modules qualified with `IMPLEMENTS` routed by scope (#1991, #2006); shared codec for `__heritage__`/`__property__` markers (#1994, #2007); graph nodes materialized for scoped class/module/impl declarations (#1975, #1977); generic Rust inherent-impl methods owned through the mod-qualified `Impl` node (#1992, #2003)
- **C# resolution & memory** — global-namespace `typeBindings` O(files²) OOM eliminated (#1871, #1954) and namespace-siblings OOM with worker-path re-parse removed (#1905); qualified/alias constructor names, `:base`/`:this` initializers, and generic type-arg stripping (#2046); primary-base receiver type normalization (#2036); spurious `IMPORTS` edges from ungated `using` resolution stopped (#1881, #1908)
- **C++ dependent-base and member lookup** — resolution across nested/inline namespaces (#1634, #1814), base-specifier qualifier threading (#1815, #1819), call-site types threaded into qualified member lookup (#1632, #1810), variadic pack dependent lookup (#1909), uninitialized multi-declarators (#1965), and typedef-enum / anonymous-struct declarations (#1941)
- **Kotlin type resolution** — smart-cast refinement for `when/is` and `if/is` (#1758, #1774), overload target-id by parameter types (#1761, #1777), cross-file iterable return propagation (#1759, #1775), method-chain fixpoint receiver types (#1760, #1776), virtual dispatch via constructor type override (#1762, #1778), interface default-method dispatch via implements-split MRO (#1763, #1779), and default-parameter arity detection (#2034)
- **Go declarations** — multi-name declaration capture (#2032), fixed-array parameter binding normalization (#1988), and generic composite-literal constructor inference F33 (#1976)
- **Rust / PHP / Vue / Java parsing** — Rust `struct_expression` name pattern split (#2051); PHP import decomposition, namespace-less `.phtml` module scopes, and Blade-template exclusion (#1801, #1790, #1989); Vue JSDoc, dual-script merge, and lang plumbing F89/F90/F92 (#1936, #2050); Java inherited `RequestMapping` prefix deduplication (#2057) and same-module type resolution for duplicate FQNs (#1712)
- **TypeScript** — HOC pattern false positives fixed with `export default` HOC support (#1943) and suffix-index reuse in the scope resolver (#1840)
- **Inheritance on the worker path** — all languages' inheritance migrated to scope-resolution in worker mode (#1951, #1956); centralized heritage supertype matching (#1921, #1922, #1940); `File->Member` `DEFINES` edges skipped for class members (#1949); phantom `Function` defs for array-method callbacks no longer emitted (#1906)
- **MCP** — sibling-clone repo-ID collisions prevented and generated MCP tool names corrected (#2067); orphan processes avoided by handling stdin close/end and the startup race (#2049); duplicate-name repo resolution disambiguated for worktrees (#1753); Windows setup fallback when global `gitnexus` resolves to a non-spawnable shim (#1694)
- **Worker pool** — resilient zero-copy ingestion worker pool prevents analyze hangs on TS-root-scale loads (#1693); cache-hit native workers no longer abort (#1751, #1833); worker-pool docs drift corrected and worker-side stack surfaced on crash (#2068, #2070)
- **LadybugDB** — FTS loaded in the Windows read pool (#2040) and probed-then-loaded on Windows (#1690, #1692); non-ASCII KuzuDB paths resolved on Windows (#1811, #1817); WAL corruption detected in schema init with recovery surfaced (#1647, #1650); WAL checkpoint-threshold control (#1772); init lock skipped for read-only opens (#1783, #1784); `serve` kept stable when sidecars are missing (#1747)
- **Server / API** — `gitnexus serve` startup restored under Express 5 (#1749); `/api/graph`, `/api/search`, `/api/grep` opened read-only (#1686); native read-only enforcement and prepared statements for Cypher query paths (#1655); `eval-server` localhost binding left to the OS (#1722)
- **Embeddings** — local ONNX runtime guarded on macOS Intel before the transformers.js import (#1987)
- **Web agent** — Nexus AI agent system prompt aligned with registered tools (#1984) and the agent stopped cleanly on user Stop (#1820)
- **Group / contracts** — HTTP graph and source contracts unioned (#1709); `httpx` `AsyncClient` alias imports detected (#1687); Node gRPC `loadPackageDefinition` gate no longer matches every member call (#1916); manifest/workspace extraction moved before `closeLbug` (#1802, #1807)
- **Hooks / install** — `gitnexus` resolved on `PATH` via a pure-Node, all-OS scan (#1938, #1980); offline-first extension installs (#1161); actionable error and docs for the `pnpm dlx`/`pnpx` native-load crash (#307, #1967); `onnxruntime-common` declared as a runtime dependency (#2074); vendored grammars materialized to fix Windows EPERM (#1728, #1729)
- **CLI** — missing LadybugDB native binary detected at startup with actionable guidance (#835, #1837); `--no-stats` applied to the keep-marker stats line (#1706, #1765); skipped large-file paths surfaced by default (#1659, #1661); build.js skipped when running outside the monorepo (#1795, #1816); auto-heap raised to 16 GB with tightened cross-platform OOM guidance for UE5-scale repos (#1652)
- **Wiki** — hidden 60s default timeout removed with timeout/retry flag validation and surfaced timeout errors (#1651); budget-aware grouping to prevent context overflow on large repos (#627, #1832)
- **`detect-changes`** — `resolveWorktreeCwd` guarded against overriding a separately-indexed worktree (#1691)
- **Windows reliability** — `windowsHide:true` passed to every `child_process` spawn-family call (#1794)
### Changed
- **Legacy resolution deletion (Ring 4)** — removed the legacy call-resolution DAG + heritage processor (RING4-1, #942, #2023), the legacy resolution-context + tiered-lookup plumbing (RING4-2, #943, #2033), and the shadow-mode parity harness (RING4-3, #944, #2071)
- **CONTRIBUTING** — clarified local development setup (#2024)
- **Tests / CI** — cli-e2e made read-only and eval-server tests hardened under load (#2000, #1786, #1838, #1688); parity shards consolidated and the cross-platform matrix narrowed (#1798); devcontainer smoke build hardened against Docker Hub flakes (#1969); gitleaks stabilized (#2027)
### Performance
- **Linux-kernel-scale analysis overhaul** — worker-pool parse, finalize O(n²), and the scope-resolution memory wall (#1983, #2038)
- **Scope-capture linearized across all languages (O(n²)→O(n))** plus Python import-resolution linearization (#1918), the Go-specific re-walk fix (#1848, #1915), and owner-keyed lookup for Step 2 member resolution (#1657)
- **C++ ADL candidates indexed once instead of per-site rescans** (#1990)
- **Inert local value symbols pruned** during ingestion (#2065)
### Chore / Dependencies
- `@ladybugdb/core` bump in /gitnexus (#2056)
- Routine dependency bumps across /gitnexus, /gitnexus-web, /eval, and GitHub Actions — incl. `hono`, `vitest`, `@vitest/coverage-v8`, `tsx`, `lru-cache`, `express`/`@types/express`, `express-rate-limit`, `qs`, `node-addon-api`, `brace-expansion`, `langchain`, `i18next`, `dompurify`, `lucide-react`, `axios`, `zod`, `@langchain/langgraph`, `@vercel/node`, `langsmith`, `aiohttp`, `idna`, and the `docker/*` / `github/codeql-action` / `release-drafter` / `dependency-review-action` actions (#2056, #2044, #2043, #2042, #2016, #2015, #2013, #2012, #2011, #2010, #2009, #2008, #2018, #2019, #2017, #2020, #1986, #1911, #1864, #1863, #1861, #1860, #1866, #1844, #1845, #1826, #1825, #1824, #1791, #1789, #1768, #1767, #1739, #1740, #1738, #1736, #1735, #1734, #1731, #1713, #1698, #1697, #1696, #1689, #1604, #1552, #1464, #872)
- **Security** — `@vercel/node` upgraded in /gitnexus-web with transitive advisories remediated (#1705)
## [1.6.5] - 2026-05-16
### Added
+37 -4
View File
@@ -34,7 +34,7 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`.
### Editor Support
@@ -126,7 +126,7 @@ Your AI agent gets these tools automatically:
| Tool | What It Does | `repo` Param |
| ---------------- | ---------------------------------------------------------------- | ------------ |
| `list_repos` | Discover all indexed repositories | — |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
@@ -158,7 +158,8 @@ Your AI agent gets these tools automatically:
## CLI Commands
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus setup # Configure MCP for detected editors (one-time; use -c to select)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
@@ -196,6 +197,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
gitnexus group status <name> # Check staleness of repos in a group
```
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
## Remote Embeddings
Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint instead of the local model:
@@ -400,7 +403,7 @@ Values above **32768 KB (32 MB)** are clamped to the tree-sitter parser ceiling;
### Analyze reports a worker timeout
Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and falls back to the sequential parser when needed. If a large repository needs more time per worker job, use either:
Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and quarantines a file that repeatedly crashes its worker (respawning the slot so the pool keeps going). If a large repository needs more time per worker job, use either:
```bash
# CLI flag, in seconds
@@ -423,6 +426,36 @@ 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. |
### Graph cleanup tuning
After scope resolution, analyze prunes inert block-local value symbols (a function-local `const`/`let`/`var` that ends up with only its structural `File→DEFINES` edge) to keep the graph focused on cross-symbol relationships. Module/file-scope symbols, class members, and any local with a real edge are always kept.
| Variable | Default | Effect |
| ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` | unset | Set to `1`/`true` to keep inert block-local value symbols instead of pruning them. |
Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var.
### Hook augmentation/notifications are silently skipped
The Claude Code / Antigravity hooks intentionally stay **silent** on normal skip
paths so strict hook runners (e.g. Codex `PreToolUse`) never see unexpected
output. A search may not be augmented — or a stale-index reminder may not appear
on stderr — when the GitNexus MCP server owns the repo DB, when the DB-lock probe
times out and fails closed, or when the index is already current.
To see why a hook skipped, set `GITNEXUS_DEBUG=1` and re-run the action — the hook
writes the reason (e.g. `[GitNexus] augment skipped: MCP server owns DB`) and the
stale-index hint to its stderr:
```bash
GITNEXUS_DEBUG=1 <your command> # surfaces hook skip/diagnostic reasons on stderr
```
Only `GITNEXUS_DEBUG=1` and `GITNEXUS_DEBUG=true` enable diagnostics; every other
value (including `0` and `false`) is treated as off. Diagnostics go to stderr
only — the hook's structured stdout (the JSON the agent consumes) is unaffected.
## Privacy
- All processing happens locally on your machine
+57
View File
@@ -0,0 +1,57 @@
{
"straight-line": {
"fingerprint": "792229965a726d2c6b527f9ee65440a2b3023839ee71cb51522fc30e2f2cb454",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"disk_bytes_large_max": 1309481,
"_note": "#2081 M1 / #2082 M2: ONE function, N coalescing statements (extendBlock text accumulation + per-statement fact harvest). Runs at 2000->8000. M2 REWROTE the old 'output is constant 4 blocks' note: statement facts make disk/heap LINEAR in N (a free gate on the harvest payload); TIME still guards the concat path (array-join ~1.0; a genuine O(n^2) re-join accumulation is ~3.8). M2 adds rd_scaling_budget (measured ~0.74) and disk_bytes_large_max -- an ABSOLUTE ceiling ~1.35x the measured indexed-encoding bytes (969,986 at N=8000, ~121 B/stmt); a named-record encoding regression (~4x facts bytes) blows it. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change (the canon now includes statements+bindings)."
},
"many-functions": {
"fingerprint": "d881f60e77f0262bdc1b5c7049aa4acf5071e0eabc536476be293c3a133e626e",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: N small branchy functions (collect walk + per-function build + per-function solve). Time ~1.0, disk ~1.01, heap ~1.0, rd ~0.86 (solver is per-function; N functions scale linearly). M3 U1 re-fingerprinted: taint sites join StatementFacts (a()/b() call sites); disk_large 2565641->2721641 (+6.1% measured site-harvest cost at N=2000)."
},
"branchy": {
"fingerprint": "936765bba5c3f8fc7058737c48351e03e4e1da7fed448467e8fcc8a0fb7786ce",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (noisiest scenario; budget 1.8 absorbs noise, catches ~4.0 quadratic), disk ~1.03, heap ~1.0, rd ~0.7. M3 U1 re-fingerprinted (s{i}() call sites); disk_large 908964->993854 (+9.3%)."
},
"dense-bindings": {
"fingerprint": "e4d7eb3c7e8b3772423af25cef391e0e6b68067b554819e81b543439a487403f",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 10.0,
"_note": "#2082 M2: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The overlay design measures rd ~5.2 normalized: the OUT spine copy on genning blocks is O(V) per block, which is quadratic when V scales with B (bounded in prod by maxFunctionLines; real functions have V~10-40). Budget 10 deliberately tolerates that known shape and exists to catch the repo's recurring per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16). If rd drops well below 5, tighten."
},
"fact-fanout": {
"fingerprint": "83a8243a8aff117f69aeecb39d02a483e6cca70439d75f63e433f4e4ac85578f",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 3.0,
"facts_large_max": 16000,
"_note": "#2082 M2 / #2083 M3 U1: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically. M3 U1 re-fingerprinted (u{i}(x) call sites); disk_large 996737->1107627 (+11.1%)."
},
"taint-dense": {
"fingerprint": "218a1a0c7e092550c233607c67daa401543a25bf8d3f122899d30cd9c30c3a89",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"disk_bytes_large_max": 3150000,
"taint_findings_per_fn_pin": 8,
"taint_scaling_budget": 2.0,
"taint_reason_bytes_large_max": 198000,
"taint_zero_match_budget": 0.5,
"_note": "#2083 M3 U7 (R10): N functions, each with 12 req.body sources + a 4-hop chain + 13 eval sinks (13 deduped findings/fn) at 125->500 fns; the zero-match control (inp.payload/evalish) keeps the identical CFG shape with zero model hits. BOUNDEDNESS pin: kept findings/function == 8 (the scenario cap) at BOTH sizes -- above means the cap was lost, below means detection regressed; total findings grow linearly with N by design. disk_bytes_large_max is the LOAD-BEARING site-harvest absolute ceiling (densest sites of the suite; measured 2335772 at N=500, ceiling ~1.35x). taint_reason_bytes_large_max caps the persisted TAINTED reason bytes (measured 146827 = ~37 B/finding, ceiling ~1.35x; blows on hop-encoding bloat or cap loss). taint_zero_match_budget 0.5 vs measured 0.15: the zero-match pass (match gate only, no solver) must stay a small fraction of the match-dense pass. taint scaling measured ~0.93 (per-function work is N-linear); time/disk/heap/rd ratios all ~1.0."
}
}
+572
View File
@@ -0,0 +1,572 @@
/**
* Build-free CFG-construction measurement harness (#2081 M1).
*
* Times `collectFunctionCfgs` (the per-function CFG builder the parse worker
* runs on a `--pdg` run) on synthetic TS sources at two sizes, in three
* scenarios that each stress a distinct cost dimension:
* - `straight-line`: ONE function with N coalescing statements — stresses the
* basic-block text accumulation (the `extendBlock` path);
* - `many-functions`: N small branchy functions — stresses the collect walk +
* per-function build + the tree-sitter `namedChildren` accesses;
* - `branchy`: ONE function with N sequential `if`s — stresses block/edge
* growth within a single CFG.
*
* For each scenario it reports three scaling ratios at small→large
* (`(metric_large/metric_small)/(N_large/N_small)`: ~1.0 is linear, ~4.0 is the
* O(n²) shape the M1 perf review flagged for `extendBlock`'s concat chain):
* - TIME — wall-clock of `collectFunctionCfgs` (median of reps);
* - DISK — utf8 byte size of the serialized `cfgSideChannel` (what a `--pdg`
* run writes onto every ParsedFile shard);
* - MEMORY — retained JS heap of the `cfgSideChannel` payload, by the
* release-delta method (heap held minus heap after dropping it). Requires
* `node --expose-gc`; without it the heap metric is null and its gate skips.
* It also computes an order-independent sha256 fingerprint over the emitted
* blocks/edges of a fixed-size source — the correctness gate that a structural
* speedup must leave behavior-identical.
*
* Build-free: imports the `.ts` hotpaths through tsx
* (`node --expose-gc --import tsx bench/cfg/measure.mjs`). Parsing happens ONCE
* per size and the tree is reused across reps so the time measurement isolates
* CFG build cost, not tree-sitter parse time. `maxFunctionLines` is 0 (no cap)
* here on purpose — the bench measures the algorithm; the production default cap
* is a separate safety net (and would otherwise skip the large straight-line fn).
*
* Without args: prints one JSON object per scenario.
* With `--check`: asserts each scenario's fingerprint == its committed baseline
* (baselines.json) AND each of the time / disk / heap ratios is below its
* recorded budget; exits non-zero on any drift/regression.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.ts';
import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.ts';
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../../src/core/ingestion/cfg/emit.ts';
import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.ts';
import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts';
import { buildTaintImportIndex, matchFunctionSites } from '../../src/core/ingestion/taint/match.ts';
import { TS_JS_TAINT_MODEL } from '../../src/core/ingestion/taint/typescript-model.ts';
import {
computeTaintFlows,
DEFAULT_PDG_MAX_TAINT_HOPS,
} from '../../src/core/ingestion/taint/propagate.ts';
import { encodeTaintPath } from '../../src/core/ingestion/taint/path-codec.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const visitor = createTypeScriptCfgVisitor();
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
// Large synthetic sources exceed tree-sitter's default read buffer; size it
// from the content exactly as the parse worker does (getTreeSitterBufferSize).
const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
// ---- synthetic generators (one cost dimension each) ----
const SCENARIOS = [
{
name: 'straight-line',
// One function, N coalescing simple statements → all fold into one basic
// block whose text is accumulated statement-by-statement (extendBlock).
// Uses LARGER sizes than the other scenarios: this scenario's only cost
// dimension is text accumulation (output size is constant — 4 blocks at any
// N — so the disk/heap ratios can't see it), so the TIME ratio is the sole
// guard against an extendBlock O(n²)-concat re-regression. At small N a
// quadratic is masked by V8 cons-strings + the linear tree-walk and slips
// under the budget; these larger sizes make a real quadratic separate
// cleanly (verified: a `+=` regression here exceeds the budget, the
// array-join impl stays ~1).
small: 2000,
large: 8000,
gen: (n) => {
let s = 'function f() {\n';
for (let i = 0; i < n; i++) s += ` let v${i} = ${i} + 1;\n`;
return s + ' return v0;\n}\n';
},
},
{
name: 'many-functions',
// N independent small functions with a branch + return → stresses the
// tree walk in collectFunctionCfgs and the per-function build.
gen: (n) => {
let s = '';
for (let i = 0; i < n; i++) {
s += `function f${i}(x: number) { if (x > ${i}) { a(); } else { b(); } return x + ${i}; }\n`;
}
return s;
},
},
{
name: 'branchy',
// One function, N sequential `if`s → N condition blocks + 2N+ edges in a
// single CFG; stresses block/edge growth and namedChildren on the body.
gen: (n) => {
let s = 'function f(x: number) {\n';
for (let i = 0; i < n; i++) s += ` if (x > ${i}) { s${i}(); }\n`;
return s + '}\n';
},
},
{
name: 'dense-bindings',
// #2082 M2: N bindings live across ~N blocks inside one loop — bindings ×
// blocks scale JOINTLY, the discriminator for solver-lattice quadratics.
// The overlay design (KTD2: sets shared by reference, OUT spine-copied
// only on gen) is expected to scale ~linearly-with-a-spine-copy here
// (normalized ratio low single digits); the regression this scenario
// exists to catch is the repo's recurring per-item-rescan shape — a
// per-use scan over all defs (O(n³) here) blows the ratio past ~16.
// rd time is the gated metric (rd_scaling_budget).
rdMaxFacts: 0, // measure the algorithm, not the cap
gen: (n) => {
let s = 'function f(c: number) {\n';
for (let i = 0; i < n; i++) s += ` let v${i} = ${i};\n`;
s += ' while (c > 0) {\n';
for (let i = 0; i < n; i++) s += ` if (c > ${i}) { v${i} = v${(i + 1) % n} + 1; }\n`;
return s + ' c = c - 1;\n }\n return v0;\n}\n';
},
},
{
name: 'fact-fanout',
// #2082 M2: N parallel case-arm defs of one variable + N later uses —
// facts are O(defs×uses) BY SPEC, so a linearity ratio gate is the wrong
// shape. The gate here is BOUNDEDNESS: with the production fact limit
// engaged, the materialized fact count stays FLAT (== limit) as N grows
// past it (facts_large_max), and rd time stays bounded. An unbounded
// materialization regression (losing the maxFacts early-stop) shows as
// facts_large exploding quadratically.
rdMaxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
gen: (n) => {
let s = 'function f(c: number) {\n let x = 0;\n switch (c) {\n';
for (let i = 0; i < n; i++) s += ` case ${i}: x = ${i}; break;\n`;
s += ' }\n';
for (let i = 0; i < n; i++) s += ` u${i}(x);\n`;
return s + '}\n';
},
},
{
name: 'taint-dense',
// #2083 M3 U7 (R10): N functions, EACH source/sink-dense — 12 matched
// `req.body` source statements + a 4-hop chained reassignment + 13 `eval`
// sinks per function (13 deduped findings/fn, ABOVE the scenario cap of 8
// so the cap binds). Functions scale with N, so total findings grow
// linearly BY DESIGN; the boundedness gate is the per-function pin: kept
// findings/function stays EXACTLY at the cap as N grows (a cap loss shows
// as 13). This scenario's sites are the densest of the suite, so its
// ABSOLUTE disk_bytes_large_max is the load-bearing site-harvest ceiling
// (the M2 straight-line carrier has no call sites), and the summed
// encoded TAINTED reason bytes get their own absolute ceiling
// (taint_reason_bytes_large_max). The zero-match control (genZero) keeps
// the identical statement/CFG shape with names OUTSIDE the model
// (inp.payload / evalish) — the match-gate must make unmatched functions
// cost ~nothing (no solver call), gated as zero-time/dense-time ratio.
small: 125,
large: 500, // 4x, like the global sizes — per-fn bodies are ~30 lines
taint: { cap: 8 },
gen: (n) => genTaintFunctions(n, false),
genZero: (n) => genTaintFunctions(n, true),
},
];
// taint-dense generator: `zero` swaps every model-matched name for an
// unmatched one without changing statement count, def/use shape, or CFG.
const TAINT_SOURCES_PER_FN = 12;
const TAINT_CHAIN_HOPS = 4;
function genTaintFunctions(n, zero) {
const recv = zero ? 'inp' : 'req';
const prop = zero ? 'payload' : 'body';
const sink = zero ? 'evalish' : 'eval';
let s = '';
for (let i = 0; i < n; i++) {
s += `function f${i}(${recv}) {\n`;
for (let j = 0; j < TAINT_SOURCES_PER_FN; j++) s += ` const s${j} = ${recv}.${prop};\n`;
s += ` let c0 = s0 + '!';\n`;
for (let h = 1; h < TAINT_CHAIN_HOPS; h++) s += ` const c${h} = c${h - 1} + '!';\n`;
for (let j = 0; j < TAINT_SOURCES_PER_FN; j++) s += ` ${sink}(s${j});\n`;
s += ` ${sink}(c${TAINT_CHAIN_HOPS - 1});\n`;
s += '}\n';
}
return s;
}
const SMALL = 500;
const LARGE = 2000; // 4× — O(n) ⇒ ratio ~1, O(n²) ⇒ ratio ~4
const REPS = 15; // median over more reps → stabler time signal at small absolute ms
const FP_SIZE = 15; // fixed size for the behavior fingerprint
const NO_CAP = 0; // measure the algorithm, not the production safety cap
// ---- timing ----
function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function measureCollect(src, file, reps) {
const root = parse(src).rootNode; // parse ONCE; reuse across reps
collectFunctionCfgs(root, visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted)
const samples = [];
let out;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
out = collectFunctionCfgs(root, visitor, file, NO_CAP);
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return {
ms: median(samples),
cfgs: out.cfgs,
blockCount: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
// DISK growth: utf8 byte size of the serialized cfgSideChannel — exactly
// what a --pdg run writes onto every ParsedFile shard in the durable store
// + parse cache (the field is plain JSON, so this is the on-disk delta).
// Should scale linearly with source covered; a super-linear ratio means the
// CFG duplicates text and bloats warm-cache shards at scale.
diskBytes: Buffer.byteLength(JSON.stringify(out.cfgs), 'utf8'),
};
}
// ---- reaching-defs solve cost (#2082 M2) ----
// Times computeReachingDefs over a scenario's collected CFGs (the exact work
// the scope-resolution emit loop adds per file on a --pdg run). `maxFacts`
// mirrors the per-scenario production posture: 0 (unlimited) measures the
// algorithm; the production default exercises the boundedness contract.
function measureReachingDefs(cfgs, reps, maxFacts) {
for (const c of cfgs) computeReachingDefs(c, { maxFacts }); // warm JIT
const samples = [];
let facts = 0;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
facts = 0;
for (const c of cfgs) facts += computeReachingDefs(c, { maxFacts }).facts.length;
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return { ms: median(samples), facts };
}
// ---- taint pass cost (#2083 M3 U7) ----
// Times the EXACT per-function sequence the in-phase emit driver runs on a
// --pdg run for a taint-modeled language: match sites → zero-match fast path
// → computeReachingDefs → computeTaintFlows. `cap` is the scenario's
// maxFindingsPerFunction (deliberately small so the cap BINDS on the dense
// generator). Also sums the encoded TAINTED `reason` bytes for the kept
// findings — the persisted-taint disk posture (R10).
function measureTaint(cfgs, reps, cap) {
const importIndex = buildTaintImportIndex([]); // bench callees are globals
const pass = () => {
let analyzed = 0;
let kept = 0;
let dropped = 0;
let reasonBytes = 0;
for (const c of cfgs) {
const matches = matchFunctionSites(c, TS_JS_TAINT_MODEL, importIndex);
if (!matches.hasSource || !matches.hasSink) continue;
const du = computeReachingDefs(c, {
maxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
});
const flows = computeTaintFlows(c, du, matches, {
maxFindingsPerFunction: cap,
maxHops: DEFAULT_PDG_MAX_TAINT_HOPS,
});
if (flows.status !== 'computed') continue;
analyzed++;
kept += flows.findings.length;
dropped += flows.droppedFindings;
for (const f of flows.findings) {
// All structural chars + identifier names are single-byte ASCII, so
// string length IS the byte length (path-codec discipline).
reasonBytes += encodeTaintPath(
f.hops.map((h) => ({ name: h.name, line: h.point.line, viaCall: h.viaCall })),
{ truncated: f.hopsTruncated === true, kind: f.sinkKind },
).reason.length;
}
}
return { analyzed, kept, dropped, reasonBytes };
};
pass(); // warm JIT (uncounted)
const samples = [];
let out;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
out = pass();
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return { ms: median(samples), ...out };
}
// ---- memory growth: retained heap of the cfgSideChannel payload ----
// Needs `node --expose-gc` to force collection for a clean delta; without it the
// heap metric is reported as null and its --check gate is skipped (so a local
// run without the flag still works).
const GC = typeof global.gc === 'function' ? () => (global.gc(), global.gc()) : null;
function retainedHeapBytes(src, file) {
if (!GC) return null;
// Retained-size-by-RELEASE: measure the heap with the CFGs held, drop them,
// GC, measure again. The drop isolates exactly the JS heap the cfgSideChannel
// payload retains (the extra RAM a --pdg run carries per file until the shard
// is flushed) — robust to pre-existing garbage, which is constant across both
// measurements. The parse tree is a temporary (its native memory isn't on the
// JS heap); block text strings are fresh copies, so they count here.
let cfgs = collectFunctionCfgs(parse(src).rootNode, visitor, file, NO_CAP).cfgs;
GC();
const withCfgs = process.memoryUsage().heapUsed;
if (cfgs.length < 0) throw new Error('unreachable'); // keep cfgs live past withCfgs
cfgs = null;
GC();
const withoutCfgs = process.memoryUsage().heapUsed;
return Math.max(0, withCfgs - withoutCfgs);
}
// ---- correctness fingerprint (order-independent over blocks + edges) ----
function canonicalizeCfg(cfg) {
const blocks = cfg.blocks
.map(
(b) =>
`B|${b.index}|${b.startLine}-${b.endLine}|${b.kind}|${b.text}|` +
// #2082 M2: statement facts join the canon so harvest drift (lost
// defs/uses, changed binding resolution) trips the fingerprint gate.
JSON.stringify(b.statements ?? null),
)
.sort();
const edges = cfg.edges.map((e) => `E|${e.from}->${e.to}|${e.kind}`).sort();
const bindings = JSON.stringify(cfg.bindings ?? null);
return `${cfg.functionStartLine}:${cfg.functionStartColumn}\n${bindings}\n${blocks.join('\n')}\n${edges.join('\n')}`;
}
function fingerprint(scenario) {
const out = collectFunctionCfgs(parse(scenario.gen(FP_SIZE)).rootNode, visitor, 'fp.ts', NO_CAP);
const canon = out.cfgs.map(canonicalizeCfg).sort().join('\n====\n');
return {
fingerprint: crypto.createHash('sha256').update(canon).digest('hex'),
fp_cfgs: out.cfgs.length,
fp_blocks: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
fp_edges: out.cfgs.reduce((a, c) => a + c.edges.length, 0),
};
}
function measureScenario(scenario) {
// Per-scenario sizes (straight-line needs larger N to separate a concat
// quadratic from noise — see its comment); the rest default to the globals.
const nSmall = scenario.small ?? SMALL;
const nLarge = scenario.large ?? LARGE;
const small = measureCollect(scenario.gen(nSmall), `${scenario.name}.ts`, REPS);
const large = measureCollect(scenario.gen(nLarge), `${scenario.name}.ts`, REPS);
const sizeRatio = nLarge / nSmall;
const scalingRatio = small.ms > 0 ? large.ms / small.ms / sizeRatio : 0;
const diskRatio = small.diskBytes > 0 ? large.diskBytes / small.diskBytes / sizeRatio : 0;
// Memory growth (only when --expose-gc gave us a forced GC).
const heapSmall = retainedHeapBytes(scenario.gen(nSmall), `${scenario.name}.ts`);
const heapLarge = retainedHeapBytes(scenario.gen(nLarge), `${scenario.name}.ts`);
const heapRatio =
heapSmall !== null && heapLarge !== null && heapSmall > 0
? heapLarge / heapSmall / sizeRatio
: null;
// #2082 M2: reaching-defs solve cost over the same CFGs.
const rdMaxFacts = scenario.rdMaxFacts ?? 0;
const rdSmall = measureReachingDefs(small.cfgs, REPS, rdMaxFacts);
const rdLarge = measureReachingDefs(large.cfgs, REPS, rdMaxFacts);
// Clamp the denominator: a 0.000ms small-N median would otherwise yield
// ratio 0 and the gate would self-disable exactly when the solver is fast.
const rdRatio = rdLarge.ms / Math.max(rdSmall.ms, 0.001) / sizeRatio;
// #2083 M3 U7: taint pass cost + boundedness on taint-bearing scenarios.
let taintMetrics = {};
if (scenario.taint !== undefined) {
const cap = scenario.taint.cap;
const tSmall = measureTaint(small.cfgs, REPS, cap);
const tLarge = measureTaint(large.cfgs, REPS, cap);
const tRatio = tLarge.ms / Math.max(tSmall.ms, 0.001) / sizeRatio;
// Zero-match control: identical CFG shape, no model hits — measures the
// match-gate overhead unmatched functions pay on a real --pdg repo.
const zeroCfgs = collectFunctionCfgs(
parse(scenario.genZero(nLarge)).rootNode,
visitor,
`${scenario.name}-zero.ts`,
NO_CAP,
).cfgs;
const tZero = measureTaint(zeroCfgs, REPS, cap);
taintMetrics = {
taint_ms_small: Number(tSmall.ms.toFixed(3)),
taint_ms_large: Number(tLarge.ms.toFixed(3)),
taint_scaling_ratio: Number(tRatio.toFixed(3)),
// Boundedness: kept findings PER ANALYZED FUNCTION (total findings grow
// linearly with N by design — the per-function pin is the cap gate).
taint_findings_per_fn_small: tSmall.analyzed > 0 ? tSmall.kept / tSmall.analyzed : 0,
taint_findings_per_fn_large: tLarge.analyzed > 0 ? tLarge.kept / tLarge.analyzed : 0,
taint_dropped_large: tLarge.dropped,
taint_reason_bytes_large: tLarge.reasonBytes,
taint_zero_ms_large: Number(tZero.ms.toFixed(3)),
taint_zero_findings: tZero.kept + tZero.dropped,
taint_zero_match_ratio: Number((tZero.ms / Math.max(tLarge.ms, 0.001)).toFixed(3)),
};
}
return {
...taintMetrics,
scenario: scenario.name,
elapsed_ms_small: Number(small.ms.toFixed(3)),
elapsed_ms_large: Number(large.ms.toFixed(3)),
scaling_ratio: Number(scalingRatio.toFixed(3)),
disk_bytes_small: small.diskBytes,
disk_bytes_large: large.diskBytes,
disk_bytes_ratio: Number(diskRatio.toFixed(3)),
heap_bytes_small: heapSmall,
heap_bytes_large: heapLarge,
heap_ratio: heapRatio === null ? null : Number(heapRatio.toFixed(3)),
blocks_small: small.blockCount,
blocks_large: large.blockCount,
rd_ms_small: Number(rdSmall.ms.toFixed(3)),
rd_ms_large: Number(rdLarge.ms.toFixed(3)),
rd_scaling_ratio: Number(rdRatio.toFixed(3)),
facts_small: rdSmall.facts,
facts_large: rdLarge.facts,
...fingerprint(scenario),
};
}
// ---- run ----
const CHECK = process.argv.includes('--check');
// The retained-heap budget is a primary regression detector, but it can only be
// measured with a forced GC. Rather than let `--check` silently PASS with the
// heap gate skipped (a green no-op if someone drops --expose-gc), fail loudly.
if (CHECK && !GC) {
process.stderr.write(
'[cfg --check] FAIL: retained-heap gate requires --expose-gc. ' +
'Run: node --expose-gc --import tsx bench/cfg/measure.mjs --check\n',
);
process.exit(1);
}
const results = SCENARIOS.map(measureScenario);
if (!CHECK) {
for (const r of results) process.stdout.write(JSON.stringify(r) + '\n');
} else {
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
const failures = [];
for (const r of results) {
const base = baselines[r.scenario];
if (base === undefined) {
failures.push(`${r.scenario}: no baseline recorded`);
continue;
}
if (r.fingerprint !== base.fingerprint) {
failures.push(
`${r.scenario}: CFG fingerprint drift (got ${r.fingerprint}, expected ${base.fingerprint})`,
);
}
if (r.scaling_ratio >= base.scaling_budget) {
failures.push(
`${r.scenario}: scaling ratio ${r.scaling_ratio} >= budget ${base.scaling_budget} ` +
`(${SMALL}->${LARGE} stmts/fns, ms ${r.elapsed_ms_small}->${r.elapsed_ms_large})`,
);
}
if (base.disk_bytes_budget !== undefined && r.disk_bytes_ratio >= base.disk_bytes_budget) {
failures.push(
`${r.scenario}: cfgSideChannel disk-bytes ratio ${r.disk_bytes_ratio} >= budget ` +
`${base.disk_bytes_budget} (bytes ${r.disk_bytes_small}->${r.disk_bytes_large})`,
);
}
// #2082 M2 gates — rd solve-time scaling, fact-count boundedness, and an
// ABSOLUTE side-channel size ceiling (a ratio gate is blind to a
// constant-factor encoding bloat like named records vs indexed facts).
if (base.rd_scaling_budget !== undefined && r.rd_scaling_ratio >= base.rd_scaling_budget) {
failures.push(
`${r.scenario}: reaching-defs scaling ratio ${r.rd_scaling_ratio} >= budget ` +
`${base.rd_scaling_budget} (ms ${r.rd_ms_small}->${r.rd_ms_large})`,
);
}
if (base.facts_large_max !== undefined && r.facts_large > base.facts_large_max) {
failures.push(
`${r.scenario}: fact materialization ${r.facts_large} > bound ${base.facts_large_max} ` +
`(the maxFacts early-stop is the boundedness contract)`,
);
}
if (base.disk_bytes_large_max !== undefined && r.disk_bytes_large > base.disk_bytes_large_max) {
failures.push(
`${r.scenario}: cfgSideChannel absolute size ${r.disk_bytes_large} > ceiling ` +
`${base.disk_bytes_large_max} bytes (constant-factor encoding bloat)`,
);
}
// #2083 M3 U7 gates — taint boundedness (per-function findings pinned at
// the cap as N grows), an ABSOLUTE ceiling on persisted TAINTED reason
// bytes, taint solve-time scaling, and the zero-match fast path staying
// ~free relative to the match-dense pass.
if (base.taint_findings_per_fn_pin !== undefined) {
for (const side of ['small', 'large']) {
const perFn = r[`taint_findings_per_fn_${side}`];
if (perFn !== base.taint_findings_per_fn_pin) {
failures.push(
`${r.scenario}: taint findings/function (${side}) ${perFn} != pin ` +
`${base.taint_findings_per_fn_pin} (cap must BIND exactly: above = cap lost, ` +
`below = detection regressed)`,
);
}
}
if (r.taint_zero_findings !== 0) {
failures.push(
`${r.scenario}: zero-match control produced ${r.taint_zero_findings} findings ` +
`(the control must not match the model — generator drift)`,
);
}
}
if (
base.taint_reason_bytes_large_max !== undefined &&
r.taint_reason_bytes_large > base.taint_reason_bytes_large_max
) {
failures.push(
`${r.scenario}: persisted TAINTED reason bytes ${r.taint_reason_bytes_large} > ceiling ` +
`${base.taint_reason_bytes_large_max} (hop-encoding bloat or cap loss)`,
);
}
if (
base.taint_scaling_budget !== undefined &&
r.taint_scaling_ratio >= base.taint_scaling_budget
) {
failures.push(
`${r.scenario}: taint scaling ratio ${r.taint_scaling_ratio} >= budget ` +
`${base.taint_scaling_budget} (ms ${r.taint_ms_small}->${r.taint_ms_large})`,
);
}
if (
base.taint_zero_match_budget !== undefined &&
r.taint_zero_match_ratio >= base.taint_zero_match_budget
) {
failures.push(
`${r.scenario}: zero-match taint time is ${r.taint_zero_match_ratio} of the match-dense ` +
`pass, >= budget ${base.taint_zero_match_budget} (the match gate must keep unmatched ` +
`functions ~free — no solver call)`,
);
}
// Heap gate only when measured (--expose-gc present) AND a budget exists.
if (
base.heap_budget !== undefined &&
r.heap_ratio !== null &&
r.heap_ratio >= base.heap_budget
) {
failures.push(
`${r.scenario}: retained-heap ratio ${r.heap_ratio} >= budget ${base.heap_budget} ` +
`(heap ${r.heap_bytes_small}->${r.heap_bytes_large})`,
);
}
process.stdout.write(JSON.stringify(r) + '\n');
}
if (failures.length > 0) {
for (const f of failures) process.stderr.write(`[cfg --check] FAIL: ${f}\n`);
process.exit(1);
}
process.stderr.write(`[cfg --check] PASS (${results.length} scenarios)\n`);
}
+27 -16
View File
@@ -70,10 +70,17 @@ this doc, run it under instrumentation:
```bash
# From the gitnexus/ subdir:
cd gitnexus
# Single-threaded baseline (sequential fallback):
npx vitest run test/integration/parse-impl-large-fixture.test.ts --reporter=verbose
# The worker pool is the sole parse path, so every run needs the dist worker
# (`npm run build`) and a pool size pinned via GITNEXUS_WORKER_POOL_SIZE.
# Worker-pool path (requires built dist/ — pre-built by `npm run build`):
# Single-worker-pool baseline (closest analog to the old single-threaded run —
# sequential parsing was removed, so a 1-worker pool is the floor):
npm run build && \
GITNEXUS_WORKER_POOL_SIZE=1 \
GITNEXUS_VERBOSE=1 \
npx vitest run test/integration/parse-impl-large-fixture.test.ts --reporter=verbose
# Multi-worker path:
npm run build && \
GITNEXUS_WORKER_POOL_SIZE=4 \
GITNEXUS_PARSE_CHUNK_CONCURRENCY=2 \
@@ -97,22 +104,26 @@ node --inspect=0 \
## Latest measurement
> _No measurement data has been collected yet — this file is the
> methodology + harness scaffold. The single recorded data point is the
> U6 wall-clock smoke baseline below; the worker-pool rows are
> placeholders for future bench-pass output._
> methodology + harness scaffold. The U6 smoke test confirms the
> worker-pool path stays well within its wall-clock budget, but every
> throughput/heap cell below is a `_TBD_` placeholder for a future
> bench-pass._
The U6 integration test (`gitnexus/test/integration/parse-impl-large-fixture.test.ts`)
was observed completing the synthetic fixture in **~6 seconds** under
the sequential path (`skipWorkers: true`) on the development machine,
well under the 30 s `Promise.race` wall-clock budget. That number is a
smoke baseline only — recorded here for reference, not as a regression
target.
runs the worker pool — the sole parse path now that sequential parsing
has been removed (disabling the pool on a repo with parseable files
raises a hard `WorkerPoolDisabledError`). It completes the synthetic
fixture well within the 30 s `Promise.race` wall-clock budget on the
development machine, but no worker-pool throughput/heap numbers have been
captured yet, so the rows below are all `_TBD_`. (An earlier ~6 s figure
recorded here was measured on the now-removed sequential path; it has
been dropped rather than relabelled as a worker-pool baseline, since the
two paths are not comparable.)
| Path | files/s | wall-clock | peak heap | chunks | quarantined |
| ------------------------------------------ | ------- | -------------------- | --------- | ------ | ----------- |
| Sequential (`skipWorkers: true`, U6 smoke) | _TBD_ | ~6 s _(observation)_ | _TBD_ | 17 | 0 |
| Worker pool, `--workers 4`, concurrency 2 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
| Worker pool, `--workers 1`, concurrency 1 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
| Path | files/s | wall-clock | peak heap | chunks | quarantined |
| ------------------------------------------------------------------------- | ------- | ---------- | --------- | ------ | ----------- |
| Worker pool, `--workers 1` (`GITNEXUS_WORKER_POOL_SIZE=1`), concurrency 1 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
| Worker pool, `--workers 4`, concurrency 2 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
**Hardware:** _TBD — record OS, CPU, RAM, Node version, gitnexus SHA at
the time of the bench-pass that populates the table above._
+21 -18
View File
@@ -11,27 +11,30 @@
"_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959."
},
"c": {
"fingerprint": "0de009bdbfe095f530fa87eb32bce6ab83092c904f26b3c8fe8d8ab587cf6dc9",
"fingerprint": "12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5",
"scaling_budget": 1.5,
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96."
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
"fingerprint": "6d6207ae1df3943c5fae28983e0c294e55225456e7cf39af1d46fda21b6787c4",
"fingerprint": "9b5b4393d158d76dcf1ef9807e0326462c45a5266310f0ae7894d017f3858219",
"scaling_budget": 1.5,
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
"_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae."
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5)."
},
"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": "701b4274643a9a5ee03a71e9fc0dede28a89524eedb976affc48df72bdd4adcc",
"scaling_budget": 1.5
"fingerprint": "2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9",
"scaling_budget": 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)."
},
"rust": {
"fingerprint": "ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82",
"scaling_budget": 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."
"_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) — 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 — @declaration.macro/@reference.macro + MacroRegistry → 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 — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
},
"php": {
"fingerprint": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb",
@@ -43,18 +46,18 @@
"fingerprint": "b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753",
"scaling_budget": 1.5,
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
},
"swift": {
"fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0",
"fingerprint": "180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998",
"scaling_budget": 1.5,
"_rebaselined": "#1956: swift-qualified-base fixture + heritage-bearing scale source (class: Base, Serviceable \u2014 extends + protocol conformance); linear (~1.03)."
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"dart": {
"fingerprint": "a9e882b537765e8fd0ddfcd33b38b253dd86fc5ddffa6e4bf5a85ed8ee615eaa",
"fingerprint": "94bf2c26e1ba96f4211634aa572c0a989b503e717e75dfc5df04f66c417de80f",
"scaling_budget": 1.5,
"_added": "#939: dart added to the scope-capture bench with the registry-primary migration. Heritage-bearing scale source (Entity extends Base implements Marker) gates the @reference.inherits synth + the postfix-chain reference walk at scale. emitDartScopeCaptures threads tree-sitter captured nodes (no findNodeAtRange root-walk), so it is linear (~1.0).",
"_rebaselined": "#1970 review + tri-review follow-ups: constructor-call retag, cascade calls, built-in suppression, enum scope, #1926 F24/F25, named-ctor dedup (crash fix), container-name binding suppression; heritage file-affinity resolution. Fixtures: member-call-contexts, constructor-body, named-constructor-body, heritage-name-collision, construct-cascade."
"_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": "9b29cafe32873b4902bda311bd089ffc04efe08f13557b966d29544be514080a",
@@ -65,8 +68,8 @@
"typescript": {
"fingerprint": "3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9",
"scaling_budget": 1.5,
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.",
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected."
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.",
"_note": "#1968: F44, F85, F87 — fingerprint drift expected."
},
"javascript": {
"fingerprint": "d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8",
@@ -75,9 +78,9 @@
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged."
},
"kotlin": {
"fingerprint": "a16400622892183581b8f5f8fa01f07842d19b8cf49ed021df52bd17009d749f",
"fingerprint": "90aa832978d9744e50058e77a04748390a7e34e36b309f6c1d178eb07280b7ea",
"scaling_budget": 1.5,
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
"_rebaselined": "#1956 synth-widening: + kotlin-qualified-base fixture; synthesizeKotlinInheritanceReferences now handles the explicit_delegation form (class F : Iface by d -> Iface), matching the #1940 legacy leg, at parity. Linear (~0.87). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1930 F45: default parameters now emit optional-arity metadata; capture fingerprint changes, scaling remains linear."
"_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."
}
}
@@ -24,7 +24,10 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.cjs');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
const {
hasGitNexusDbLockedByGitNexusServer,
resolveUnixGuardTimeout,
} = require('./hook-db-lock-probe.cjs');
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
function readInput() {
@@ -91,10 +94,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
}
/**
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
* hook runners validate hook output, so normal, non-error skip paths must stay
* silent unless the operator explicitly asks for diagnostics via GITNEXUS_DEBUG.
* See issue #1913.
*/
function isDebugEnabled() {
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
}
function extractAugmentContext(stderr) {
const output = (stderr || '').trim();
const marker = output.indexOf('[GitNexus]');
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
const debug = isDebugEnabled();
if (debug && output.length > 0) {
// Emit the FULL discarded prefix (everything before the marker, or all of
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
@@ -188,10 +201,73 @@ function resolveCliPath() {
return cliPath;
}
// Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
// at most one line per (short-lived) hook process, even if a future change
// runs the CLI more than once.
let unguardedCliWarned = false;
/**
* Unix orphan containment (#2163 follow-up): the augment CLI is the
* longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
* npx), so on Unix it gets the same SIGKILL-surviving coreutils `timeout`
* wrapper as the probe's lsof/ps. The wrapper budget is ceil(inner/1000)+1
* seconds — STRICTLY greater than the inner spawnSync timeout, so on the
* supervised path Node's SIGTERM always fires first and the existing
* error/status contract is untouched. Once the hook itself has been
* SIGKILLed (exactly the orphan case the wrapper exists for), the guard
* semantics differ per branch:
* - direct exec (the CLI is the guard's CHILD): `-k 1` TERM-first — a
* SIGTERM-immune CLI can hold the guard ~1s past the inner timeout
* before the `-k` SIGKILL escalation reaps it.
* - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
* budget expiry SIGKILLs the whole process group outright. TERM-first
* would kill only the obedient npx parent, making `timeout` reap it and
* return before the `-k` escalation ever fires, stranding a
* SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
* 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
* `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
* on this branch, both bounded by "no worse than pre-fix" (where the
* grandchild received no signal at all): the group-wide SIGKILL is
* coreutils semantics — a busybox `timeout` passes the self-test (it
* has `-k` and propagates exit status) but signals only its direct
* child, so a busybox guard cannot reach the grandchild; and on the
* SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
* guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
* and the guard exits before any KILL fires — so a SIGTERM-immune CLI
* grandchild still escapes in those two cases.
* If the sibling probe predates the resolveUnixGuardTimeout export (version
* skew), the adapter degrades to the unwrapped invocation instead of
* throwing. Windows is deliberately NOT wrapped — there is no coreutils
* timeout to resolve there and the resolver's self-test spawns /bin/sh — so
* on win32 (the npx.cmd path) and whenever the guard resolves to null (e.g.
* macOS without Homebrew coreutils — reported once under GITNEXUS_DEBUG)
* the argv stays byte-identical to the pre-wrap invocation.
*/
function runGitNexusCli(cliPath, args, cwd, timeout) {
const isWin = process.platform === 'win32';
// Version-skew guard (#2163 follow-up review): an older sibling probe
// without the resolveUnixGuardTimeout export must degrade to the unwrapped
// invocation — a TypeError here would be swallowed by the caller's catch
// and silently kill the augment.
const guard =
isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
// Diagnose the "stays unwrapped" Unix paths once per hook process: no
// usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
// coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
unguardedCliWarned = true;
process.stderr.write(
'[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
);
}
if (cliPath) {
return spawnSync(process.execPath, [cliPath, ...args], {
const [cmd, cmdArgs] = guard
? [
guard,
['-k', '1', String(Math.ceil(timeout / 1000) + 1), process.execPath, cliPath, ...args],
]
: [process.execPath, [cliPath, ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout,
cwd,
@@ -199,7 +275,27 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
windowsHide: true,
});
}
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
// A non-null guard implies non-Windows, so the wrapped arm can hardcode
// plain `npx`. The wrapped arm leads with `-s KILL` (NOT TERM-first like
// the direct branch above): the CLI here is a grandchild behind npx — see
// the docblock.
const [cmd, cmdArgs] = guard
? [
guard,
[
'-s',
'KILL',
'-k',
'1',
String(Math.ceil((timeout + 5000) / 1000) + 1),
'npx',
'-y',
'gitnexus',
...args,
],
]
: [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,
@@ -258,8 +354,14 @@ function buildAfterToolContext(input) {
if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) {
const hint = buildStaleIndexHint(gitNexusDir, cwd);
if (hint) {
process.stderr.write(`${hint}\n`);
// The hint always reaches the agent via additionalContext (parts). Mirror
// it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict
// hook runners see no unexpected output on this normal path (#1913). The
// claude hook never mirrored this to stderr — this aligns the two adapters.
parts.push(hint);
if (isDebugEnabled()) {
process.stderr.write(`${hint}\n`);
}
}
}
}
@@ -268,14 +370,32 @@ function buildAfterToolContext(input) {
}
function runAugment(gitNexusDir, cwd, pattern) {
if (hasGitNexusServerOwner(gitNexusDir)) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
// as the augment, or concurrent sessions fan out unbounded probe
// subprocesses. The cheap guards (extractPattern, gitNexusDir lookup) run in
// buildAfterToolContext before this — moving the acquire any earlier would
// churn slot files on tool calls that never probe.
const release = acquireHookSlot(gitNexusDir);
if (!release) {
// Normal skip path: all per-repo hook slots are held by concurrent
// sessions. Stay silent for strict hook runners (issue #1913); surface
// the reason only under GITNEXUS_DEBUG.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
}
return '';
}
const release = acquireHookSlot(gitNexusDir);
if (!release) return '';
const cliPath = resolveCliPath();
try {
if (hasGitNexusServerOwner(gitNexusDir)) {
// Normal skip path: the MCP server owns the DB. Stay silent for strict
// hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
}
return '';
}
const cliPath = resolveCliPath();
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
return extractAugmentContext(child.stderr || '');
@@ -338,7 +458,7 @@ function main() {
const handler = handlers[input.hook_event_name || ''];
if (handler) handler(input);
} catch (err) {
if (process.env.GITNEXUS_DEBUG) {
if (isDebugEnabled()) {
console.error('GitNexus antigravity hook error:', (err.message || '').slice(0, 200));
}
}
+124 -12
View File
@@ -15,7 +15,10 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.cjs');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
const {
hasGitNexusDbLockedByGitNexusServer,
resolveUnixGuardTimeout,
} = require('./hook-db-lock-probe.cjs');
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
/**
@@ -110,10 +113,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
}
/**
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
* non-error skip paths must stay silent unless the operator explicitly asks
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
*/
function isDebugEnabled() {
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
}
function extractAugmentContext(stderr) {
const output = (stderr || '').trim();
const marker = output.indexOf('[GitNexus]');
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
const debug = isDebugEnabled();
if (debug && output.length > 0) {
// Emit the FULL discarded prefix (everything before the marker, or all of
// it when no marker is present) so suppressed diagnostics — KuzuDB lock
@@ -208,14 +221,76 @@ function resolveCliPath() {
return cliPath;
}
// Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
// at most one line per (short-lived) hook process, even if a future change
// runs the CLI more than once.
let unguardedCliWarned = false;
/**
* Spawn a gitnexus CLI command synchronously.
* Returns the stderr output (KuzuDB captures stdout at OS level).
*
* Unix orphan containment (#2163 follow-up): the augment CLI is the
* longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
* npx), so on Unix it gets the same SIGKILL-surviving coreutils `timeout`
* wrapper as the probe's lsof/ps. The wrapper budget is ceil(inner/1000)+1
* seconds — STRICTLY greater than the inner spawnSync timeout, so on the
* supervised path Node's SIGTERM always fires first and the existing
* error/status contract is untouched. Once the hook itself has been
* SIGKILLed (exactly the orphan case the wrapper exists for), the guard
* semantics differ per branch:
* - direct exec (the CLI is the guard's CHILD): `-k 1` TERM-first — a
* SIGTERM-immune CLI can hold the guard ~1s past the inner timeout
* before the `-k` SIGKILL escalation reaps it.
* - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
* budget expiry SIGKILLs the whole process group outright. TERM-first
* would kill only the obedient npx parent, making `timeout` reap it and
* return before the `-k` escalation ever fires, stranding a
* SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
* 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
* `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
* on this branch, both bounded by "no worse than pre-fix" (where the
* grandchild received no signal at all): the group-wide SIGKILL is
* coreutils semantics — a busybox `timeout` passes the self-test (it
* has `-k` and propagates exit status) but signals only its direct
* child, so a busybox guard cannot reach the grandchild; and on the
* SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
* guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
* and the guard exits before any KILL fires — so a SIGTERM-immune CLI
* grandchild still escapes in those two cases.
* If the sibling probe predates the resolveUnixGuardTimeout export (version
* skew), the adapter degrades to the unwrapped invocation instead of
* throwing. Windows is deliberately NOT wrapped — there is no coreutils
* timeout to resolve there and the resolver's self-test spawns /bin/sh — so
* on win32 (the npx.cmd path) and whenever the guard resolves to null (e.g.
* macOS without Homebrew coreutils — reported once under GITNEXUS_DEBUG)
* the argv stays byte-identical to the pre-wrap invocation.
*/
function runGitNexusCli(cliPath, args, cwd, timeout) {
const isWin = process.platform === 'win32';
// Version-skew guard (#2163 follow-up review): an older sibling probe
// without the resolveUnixGuardTimeout export must degrade to the unwrapped
// invocation — a TypeError here would be swallowed by the caller's catch
// and silently kill the augment.
const guard =
isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
// Diagnose the "stays unwrapped" Unix paths once per hook process: no
// usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
// coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
unguardedCliWarned = true;
process.stderr.write(
'[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
);
}
if (cliPath) {
return spawnSync(process.execPath, [cliPath, ...args], {
const [cmd, cmdArgs] = guard
? [
guard,
['-k', '1', String(Math.ceil(timeout / 1000) + 1), process.execPath, cliPath, ...args],
]
: [process.execPath, [cliPath, ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout,
cwd,
@@ -223,8 +298,27 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
windowsHide: true,
});
}
// On Windows, invoke npx.cmd directly (no shell needed)
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
// On Windows, invoke npx.cmd directly (no shell needed). A non-null guard
// implies non-Windows, so the wrapped arm can hardcode plain `npx`. The
// wrapped arm leads with `-s KILL` (NOT TERM-first like the direct branch
// above): the CLI here is a grandchild behind npx — see the docblock.
const [cmd, cmdArgs] = guard
? [
guard,
[
'-s',
'KILL',
'-k',
'1',
String(Math.ceil((timeout + 5000) / 1000) + 1),
'npx',
'-y',
'gitnexus',
...args,
],
]
: [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,
@@ -249,17 +343,35 @@ function handlePreToolUse(input) {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
if (hasGitNexusServerOwner(gitNexusDir)) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
// as the augment, or concurrent sessions fan out unbounded probe
// subprocesses. Keep the acquire right after the cheap guards above —
// moving it earlier would churn slot files on tool calls that never probe.
const release = acquireHookSlot(gitNexusDir);
if (!release) {
// Normal skip path: all per-repo hook slots are held by concurrent
// sessions. Stay silent for strict hook runners (issue #1913); surface
// the reason only when diagnostics are explicitly requested.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
}
return;
}
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
const cliPath = resolveCliPath();
let result = '';
try {
if (hasGitNexusServerOwner(gitNexusDir)) {
// Normal skip path: the MCP server owns the DB, so the CLI augment would
// contend on the lock. Stay silent for strict hook runners (issue #1913);
// surface the reason only when diagnostics are explicitly requested.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
}
return;
}
const cliPath = resolveCliPath();
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = extractAugmentContext(child.stderr || '');
@@ -361,7 +473,7 @@ function main() {
const handler = handlers[input.hook_event_name || ''];
if (handler) handler(input);
} catch (err) {
if (process.env.GITNEXUS_DEBUG) {
if (isDebugEnabled()) {
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
}
}
+155 -2
View File
@@ -11,6 +11,27 @@
*
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
*
* Unix subprocess containment contract (#2163):
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
* wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
* is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
* survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
* wrapper off deterministically; any other value is adopted only when it
* exists AND passes a one-shot `-k` exit-propagation self-test — otherwise
* resolution FALLS THROUGH to the built-in candidate list (first self-test
* pass wins), so no malformed value of any shape can silently disable
* orphan containment.
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
* A probe before that first query is therefore always false — a known,
* pre-existing race, not a bug in this probe.
* - resolveUnixGuardTimeout is exported so the hook adapters can wrap the
* `gitnexus augment` CLI child — the longest-lived hook subprocess (7s
* local / 12s npx inner budgets) — in the same guard; see runGitNexusCli
* in the adapters (#2163 follow-up).
*/
const fs = require('fs');
@@ -46,6 +67,95 @@ function resolveHookBinary(tool) {
return tool;
}
// Sentinel:
// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
// null = no usable wrapper (disabled, none found, or self-test failed)
let unixGuardTimeoutCache;
/**
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
* (#2163). Unix-only by contract: the probe's win32 dispatch returns before
* reaching it, and the exported callers (the adapters' runGitNexusCli,
* #2163 follow-up) must check the platform first — the self-test below
* spawns /bin/sh. The memoized result is module-wide, so probe and adapter
* share one lazy self-test per hook process.
*
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
* wrapper off; any other value is only a CANDIDATE — an existing file path
* is tried first, but it must pass the `-k` exit-propagation self-test to
* be adopted. On any failure (non-existent path, directory, non-executable
* file, wrapper without `-k` support, always-exit-0 stub, …) resolution
* falls through to the built-in candidates below, tried in order, first
* self-test pass wins. This is strictly stronger than the sibling
* GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only
* check existence): no bad env value of ANY shape can silently disable
* orphan containment.
*
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
* first reached, and the result is memoized. A candidate is adopted only
* when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 — i.e. it must RUN
* the wrapped command AND PROPAGATE its exit status. This rejects two
* failure shapes: wrappers without the coreutils `-k` flag — busybox <1.34,
* toybox, broken symlinks — which would exit with a usage error without
* ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed
* contract into fail-open (#1492 regression); and always-exit-0 stubs
* (/bin/true shapes), which would otherwise be adopted and "succeed" every
* wrapped spawn instantly without running it — a constant no-owner probe
* answer and, worse, a silently dead augment (status 0, empty stderr passes
* the adapters' success check with no context; #2163 follow-up review).
* Only when EVERY candidate fails does the probe fall back to the unwrapped
* status quo (memoized null). busybox ≥1.34 passes the test and is fully
* usable for everything THIS file spawns (lsof/ps are the guard's direct
* children) and for the adapters' direct-exec arm. The adapters' npx arm
* additionally relies on coreutils' process-GROUP signalling for its
* `-s KILL` grandchild reaping; busybox signals only its direct child, and
* this self-test deliberately does not probe that capability — see the
* adapter docblocks for the residual-gap statement.
*/
function passesGuardSelfTest(guard) {
try {
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], {
encoding: 'utf-8',
timeout: 3000,
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
});
return !selfTest.error && selfTest.status === 42;
} catch {
return false;
}
}
function resolveUnixGuardTimeout() {
if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
unixGuardTimeoutCache = null;
const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
const trimmed = fromEnv ? String(fromEnv).trim() : '';
if (trimmed === 'disabled') return unixGuardTimeoutCache;
const candidates = [];
if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
for (const builtin of [
'/usr/bin/timeout',
'/bin/timeout',
'/opt/homebrew/bin/gtimeout',
'/usr/local/bin/gtimeout',
]) {
try {
if (fs.existsSync(builtin)) candidates.push(builtin);
} catch {
/* ignore */
}
}
for (const candidate of candidates) {
if (passesGuardSelfTest(candidate)) {
unixGuardTimeoutCache = candidate;
break;
}
}
return unixGuardTimeoutCache;
}
function resolveWindowsPowerShellPath() {
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
@@ -188,20 +298,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
}
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
const guard = resolveUnixGuardTimeout();
const lsofPath = resolveHookBinary('lsof');
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
// The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
// SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
// SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
// fail-closed contract are untouched. The wrapper only matters once this
// hook process has been SIGKILLed and can no longer deliver that SIGTERM.
const [lsofCmd, lsofArgs] = guard
? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
: [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
const lsof = spawnSync(lsofCmd, lsofArgs, {
encoding: 'utf-8',
timeout: 1000,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
// Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
// result shapes, verified against coreutils 9.1:
// - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
// SELF-RAISES the signal, so spawnSync reports {status: null, signal}
// with no .error (spawnSync's own ETIMEDOUT was handled above). The
// same shape appears when this hook is frozen >2s (SIGSTOP, laptop
// suspend) and the guard expires while it sleeps. By construction, a
// guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
// is a budget/kill outcome.
// - 124: budget expired and the child exited after the plain SIGTERM.
// - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
// or a child SIGKILLed externally (e.g. the OOM killer).
if (guard && lsof.status === null && lsof.signal) return true;
if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
const psPath = resolveHookBinary('ps');
for (const pid of pids) {
if (Number(pid) === myPid) continue;
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
const [psCmd, psArgs] = guard
? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
: [psPath, ['-p', pid, '-o', 'command=']];
const ps = spawnSync(psCmd, psArgs, {
encoding: 'utf-8',
timeout: 500,
stdio: ['ignore', 'pipe', 'ignore'],
@@ -211,6 +347,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
if (ps.error.code === 'ETIMEDOUT') return true;
continue;
}
// Same guard-mediated-death mapping as the lsof call above (signal-death
// from the -k escalation or a frozen hook; 124 budget expiry; 137 only
// for exit-code-propagating wrappers / external SIGKILL).
if (guard && ps.status === null && ps.signal) return true;
if (guard && (ps.status === 124 || ps.status === 137)) return true;
if (isGitNexusServerCommand(ps.stdout || '')) return true;
}
return false;
@@ -238,4 +379,16 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
module.exports = {
hasGitNexusDbLockedByGitNexusServer,
// #2163 follow-up: the hook adapters wrap the augment CLI in the same
// guard. Returns a self-tested wrapper path — the built-in candidates are
// always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
// exact string that passed the self-test. Same string is also the same
// RESOLUTION for absolute paths and for slashless names (PATH lookup is
// cwd-independent); a slash-containing RELATIVE override, however, is
// existsSync-checked and self-tested against this process's cwd while the
// adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
// a value can pass here yet ENOENT at the augment call site — set the
// override to an absolute path. Returns null when the wrapper is
// disabled/unavailable. Never call on win32 (see its JSDoc).
resolveUnixGuardTimeout,
};
+381 -196
View File
@@ -1,19 +1,20 @@
{
"name": "gitnexus",
"version": "1.6.5",
"version": "1.6.8-rc.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.5",
"version": "1.6.8-rc.26",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^4.1.0",
"@ladybugdb/core": "^0.16.1",
"@ladybugdb/core": "^0.17.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
"cli-progress": "^3.12.0",
"commander": "^14.0.3",
"cors": "^2.8.5",
@@ -26,14 +27,15 @@
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
"lru-cache": "^11.0.0",
"mnemonist": "^0.40.3",
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"onnxruntime-common": "^1.26.0",
"onnxruntime-node": "^1.24.0",
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"tree-sitter": "0.21.1",
"tree-sitter-c": "0.21.4",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "0.23.2",
"tree-sitter-go": "^0.23.0",
@@ -50,6 +52,7 @@
"gitnexus": "dist/cli/index.js"
},
"devDependencies": {
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
@@ -64,11 +67,6 @@
},
"engines": {
"node": ">=22.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"tree-sitter-kotlin": "^0.3.8"
}
},
"../gitnexus-shared": {
@@ -172,9 +170,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -189,9 +187,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -206,9 +204,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -223,9 +221,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -240,9 +238,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -257,9 +255,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -274,9 +272,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -291,9 +289,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -308,9 +306,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -325,9 +323,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -342,9 +340,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -359,9 +357,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -376,9 +374,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -393,9 +391,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -410,9 +408,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -427,9 +425,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -444,9 +442,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -461,9 +459,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -478,9 +476,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -495,9 +493,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -512,9 +510,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -529,9 +527,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -546,9 +544,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -563,9 +561,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -580,9 +578,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -597,9 +595,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -1159,27 +1157,28 @@
}
},
"node_modules/@ladybugdb/core": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.1.tgz",
"integrity": "sha512-qwuEcR8CVMKb6tNDaHtq7Ux8hT/XbPC0db+vwutX6JxNAejyx7YomHKPSy9XAKURhYK8mezZe3UN8rf+xpHOjQ==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.17.1.tgz",
"integrity": "sha512-K1bHnQrRy3bxkyrFHlxGqKUyIUS1LsRXKOSt14XGY/msBZHaDat/uBrlHiWpM4/24OtfOq/qwTqcTCXannnEjw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"apache-arrow": "^21.1.0",
"cmake-js": "^8.0.0",
"node-addon-api": "^6.0.0"
},
"optionalDependencies": {
"@ladybugdb/core-darwin-arm64": "0.16.1",
"@ladybugdb/core-darwin-x64": "0.16.1",
"@ladybugdb/core-linux-arm64": "0.16.1",
"@ladybugdb/core-linux-x64": "0.16.1",
"@ladybugdb/core-win32-x64": "0.16.1"
"@ladybugdb/core-darwin-arm64": "0.17.1",
"@ladybugdb/core-darwin-x64": "0.17.1",
"@ladybugdb/core-linux-arm64": "0.17.1",
"@ladybugdb/core-linux-x64": "0.17.1",
"@ladybugdb/core-win32-x64": "0.17.1"
}
},
"node_modules/@ladybugdb/core-darwin-arm64": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.16.1.tgz",
"integrity": "sha512-Nl+Cf70rD+HaC9IBHv+oeUwqX9plghXD7PN9tyMzMohRVPvcGEbqWPB6YcdJa8rR7qRqCCbmaNMDen5wg4rY2w==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.17.1.tgz",
"integrity": "sha512-JG/uzmolEh3wXJ/ME1EaTH5LTDQ9Cs+Q3Czul8pW2eWbWQZghQU3jjM++7ST7Bla5BX/WITqwPqPoC+sL+slfA==",
"cpu": [
"arm64"
],
@@ -1190,9 +1189,9 @@
]
},
"node_modules/@ladybugdb/core-darwin-x64": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.16.1.tgz",
"integrity": "sha512-4eAjfimAAQRSmDfUUkGrl9OhefxcW1ziA9tl0eljBlGoUseE7dL02+RSqjGohYMcQ+lzuHAq1QWb0XRlMA8YTQ==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.17.1.tgz",
"integrity": "sha512-Enjm+/V9/jpKmtzF2PB0muVkgpFUGHEvA7r16eJWxVRA/BeO8VPmngTKy9rf/4Yc6TWexjoHRug04BbTXEmerg==",
"cpu": [
"x64"
],
@@ -1203,9 +1202,9 @@
]
},
"node_modules/@ladybugdb/core-linux-arm64": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.1.tgz",
"integrity": "sha512-zkctksev+hsPFrNxHHdq4lYK5OWdLhWfRdQzjzkgDyaHayHU6yCL2fgD6uPGQ8TRQ6/2DxMErb4p3FzGW85Ubw==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.17.1.tgz",
"integrity": "sha512-P+xM9o4I3JAQtXpX19ZuLj9EeO2gppa+IdmAqhpI8tuhyA3/a85Eaxby1fXOjsbrnOAEyFJczUdyoDkhCPSyiw==",
"cpu": [
"arm64"
],
@@ -1216,9 +1215,9 @@
]
},
"node_modules/@ladybugdb/core-linux-x64": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.16.1.tgz",
"integrity": "sha512-5rAb9T5vif8WKhHwhobosu2/aiOwJkWb/ViybvUc5GFKunKl8VI6RmZQVeufT9zUzRktUwrxBrxblCxsnamXJw==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.17.1.tgz",
"integrity": "sha512-N2ujE0CrsToBpVBpou1iWwEkK7CgVxucnUNxteySrnDccZwICXFP5BlcFpKE0qq3Eqmqszh4ptR4GuSi6rKPGw==",
"cpu": [
"x64"
],
@@ -1229,9 +1228,9 @@
]
},
"node_modules/@ladybugdb/core-win32-x64": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.1.tgz",
"integrity": "sha512-ShOUTrIuZKQ63J95tcRJxKf1cvg8yi2FSYx9kMTSercc1FdQZPV+zxUN0myMq3MTWOl7xDxsVMmdp/t80O29UQ==",
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.17.1.tgz",
"integrity": "sha512-9i3xNfFAMqFRuQG3F1hOCWYGna6eTg8HJ/XYhWVDGkeFJNUV3IdneEiYttF5B2qAtQYUd4sAikScsImrMRw+6g==",
"cpu": [
"x64"
],
@@ -1675,6 +1674,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -1697,6 +1705,16 @@
"@types/node": "*"
}
},
"node_modules/@types/busboy": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1718,6 +1736,18 @@
"@types/node": "*"
}
},
"node_modules/@types/command-line-args": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz",
"integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==",
"license": "MIT"
},
"node_modules/@types/command-line-usage": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz",
"integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==",
"license": "MIT"
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -1792,9 +1822,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"version": "25.9.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@@ -2069,12 +2099,56 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/apache-arrow": {
"version": "21.1.0",
"resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz",
"integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.11",
"@types/command-line-args": "^5.2.3",
"@types/command-line-usage": "^5.0.4",
"@types/node": "^24.0.3",
"command-line-args": "^6.0.1",
"command-line-usage": "^7.0.1",
"flatbuffers": "^25.1.24",
"json-bignum": "^0.0.3",
"tslib": "^2.6.2"
},
"bin": {
"arrow2csv": "bin/arrow2csv.js"
}
},
"node_modules/apache-arrow/node_modules/@types/node": {
"version": "24.13.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.0.tgz",
"integrity": "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/apache-arrow/node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/array-back": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz",
"integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2151,6 +2225,17 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2199,6 +2284,37 @@
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk-template": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz",
"integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==",
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/chalk-template?sponsor=1"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -2281,6 +2397,44 @@
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/command-line-args": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.2.tgz",
"integrity": "sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==",
"license": "MIT",
"dependencies": {
"array-back": "^6.2.3",
"find-replace": "^5.0.2",
"lodash.camelcase": "^4.3.0",
"typical": "^7.3.0"
},
"engines": {
"node": ">=12.20"
},
"peerDependencies": {
"@75lb/nature": "latest"
},
"peerDependenciesMeta": {
"@75lb/nature": {
"optional": true
}
}
},
"node_modules/command-line-usage": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz",
"integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==",
"license": "MIT",
"dependencies": {
"array-back": "^6.2.2",
"chalk-template": "^0.4.0",
"table-layout": "^4.1.1",
"typical": "^7.3.0"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@@ -2558,9 +2712,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -2571,32 +2725,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": {
@@ -2819,6 +2973,23 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/find-replace": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz",
"integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"peerDependencies": {
"@75lb/nature": "latest"
},
"peerDependenciesMeta": {
"@75lb/nature": {
"optional": true
}
}
},
"node_modules/flatbuffers": {
"version": "25.9.23",
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
@@ -3057,7 +3228,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3285,9 +3455,19 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -3296,6 +3476,14 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-bignum": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz",
"integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -3587,6 +3775,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -4654,6 +4848,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -4693,7 +4895,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -4702,6 +4903,19 @@
"node": ">=8"
}
},
"node_modules/table-layout": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz",
"integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==",
"license": "MIT",
"dependencies": {
"array-back": "^6.2.2",
"wordwrapjs": "^5.1.0"
},
"engines": {
"node": ">=12.17"
}
},
"node_modules/tar": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
@@ -4794,25 +5008,6 @@
"node-gyp-build": "^4.8.0"
}
},
"node_modules/tree-sitter-c": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.21.4.tgz",
"integrity": "sha512-IahxFIhXiY15SUlrt2upBiKSBGdOaE1fjKLK1Ik5zxqGHf6T1rvr3IJrovbsE5sXhypx7Hnmf50gshsppaIihA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.1"
},
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-c-sharp": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/tree-sitter-c-sharp/-/tree-sitter-c-sharp-0.23.1.tgz",
@@ -4908,33 +5103,6 @@
}
}
},
"node_modules/tree-sitter-kotlin": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/tree-sitter-kotlin/-/tree-sitter-kotlin-0.3.8.tgz",
"integrity": "sha512-A4obq6bjzmYrA+F0JLLoheFPcofFkctNaZSpnDd+GPn1SfVZLY4/GG4C0cYVBTOShuPBGGAOPLM1JWLZQV4m1g==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^7.1.0",
"node-gyp-build": "^4.8.0"
},
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-kotlin/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true
},
"node_modules/tree-sitter-php": {
"version": "0.23.12",
"resolved": "https://registry.npmjs.org/tree-sitter-php/-/tree-sitter-php-0.23.12.tgz",
@@ -5035,8 +5203,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.22.4",
@@ -5114,6 +5281,15 @@
"node": ">=14.17"
}
},
"node_modules/typical": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz",
"integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
@@ -5366,6 +5542,15 @@
"node": ">=8"
}
},
"node_modules/wordwrapjs": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz",
"integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==",
"license": "MIT",
"engines": {
"node": ">=12.17"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+10 -11
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.5",
"version": "1.6.8-rc.26",
"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",
@@ -49,15 +49,17 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:cross-platform": "tsx scripts/run-cross-platform.ts",
"postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs && node scripts/build-tree-sitter-swift.cjs",
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/build.js"
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js"
},
"dependencies": {
"@huggingface/transformers": "^4.1.0",
"@ladybugdb/core": "^0.16.1",
"@ladybugdb/core": "^0.17.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
"cli-progress": "^3.12.0",
"commander": "^14.0.3",
"cors": "^2.8.5",
@@ -70,14 +72,15 @@
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
"lru-cache": "^11.0.0",
"mnemonist": "^0.40.3",
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"onnxruntime-common": "^1.26.0",
"onnxruntime-node": "^1.24.0",
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"tree-sitter": "0.21.1",
"tree-sitter-c": "0.21.4",
"tree-sitter-c-sharp": "0.23.1",
"tree-sitter-cpp": "0.23.2",
"tree-sitter-go": "^0.23.0",
@@ -90,12 +93,8 @@
"tree-sitter-typescript": "^0.23.2",
"uuid": "^14.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"tree-sitter-kotlin": "^0.3.8"
},
"devDependencies": {
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
@@ -0,0 +1,204 @@
#!/usr/bin/env node
/**
* Publish guard: every vendored tree-sitter grammar must ship a loadable binding.
*
* The npm tarball includes gitnexus/vendor/ (package.json `files`). A grammar is
* "covered" on a platform-arch tuple if EITHER a prebuild ships for it OR the
* grammar's full source-build set ships (so the install can source-build it,
* toolchain permitting). A future lean publish — dropping the ~50 MB of generated
* source to ship prebuilds only — is safe ONLY once every grammar has all six
* prebuilds; doing it while any grammar still lacks a prebuild would ship a
* grammar with NO loadable binding (neither prebuild nor buildable source) → that
* language is silently dead for users.
*
* HOW SOURCE INCLUSION IS DECIDED. The `files` allow-list OVERRIDES `.npmignore`
* for the vendored subtree (verified: an active "vendor/(star-star)/src/parser.c"
* in .npmignore does NOT drop it from `npm pack`). So `.npmignore` can never
* exclude vendored source — the ONLY lever is the `files` field. A broad `vendor`
* ships the whole subtree (source + prebuilds); a lean publish narrows `files` to
* non-source subpaths. This guard therefore reads `files` directly rather than
* shelling out to `npm pack` (which, in prepack, would re-enter this guard and,
* on npm versions that don't honor --ignore-scripts for prepare/prepack, run the
* full build — slow enough to time out and fragile).
*
* Wired via `prepack`, so it fails `npm pack` / `npm publish` if the invariant is
* violated.
*/
const fs = require('fs');
const path = require('path');
const TUPLES = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64',
];
// Source-build inputs (relative to vendor/<name>/) whose presence makes a grammar
// source-buildable. Per-grammar we only require the ones that exist on disk (e.g.
// tree-sitter-c has no external scanner.c).
const SOURCE_BUILD_REL = [
'binding.gyp',
'bindings/node/binding.cc',
'src/parser.c',
'src/scanner.c',
'src/tree_sitter/parser.h',
];
/**
* Does the package.json `files` allow-list ship the WHOLE vendor subtree (and
* therefore the vendored grammar source)? A bare `vendor` (optionally with a
* trailing slash or `/**`/`/*`) includes everything under vendor/. A lean publish
* replaces that with non-source subpaths, so this returns false and grammars must
* then rely on prebuilds.
*/
function filesShipsVendorSource(filesField) {
return (filesField || []).some((f) => {
const n = String(f)
.replace(/\\/g, '/')
.replace(/\/+$/, '')
.replace(/\/\*\*?$/, '');
return n === 'vendor';
});
}
/** The on-disk source-build inputs for a grammar (relative paths). */
function sourceBuildSet(grammarDir) {
return SOURCE_BUILD_REL.filter((rel) => fs.existsSync(path.join(grammarDir, rel)));
}
/** True when a grammar can be source-built from its vendored files (has gyp + parser). */
function isBuildableFromSource(grammarDir) {
const set = sourceBuildSet(grammarDir);
return set.includes('binding.gyp') && set.includes('src/parser.c');
}
/** Count platform-arch tuples with a committed prebuilt .node on disk. */
function countPrebuiltTuples(grammarDir) {
const pdir = path.join(grammarDir, 'prebuilds');
let n = 0;
for (const t of TUPLES) {
const td = path.join(pdir, t);
try {
if (fs.statSync(td).isDirectory() && fs.readdirSync(td).some((f) => f.endsWith('.node'))) {
n++;
}
} catch {
/* tuple dir absent — not covered */
}
}
return n;
}
/**
* Pure core (exported for tests). `grammars` is a list of
* `{ name, prebuilt: 0..6, shipsSource: boolean }`. Returns human-readable
* problem strings; an empty array means the pack is publish-safe.
*/
function findCoverageProblems({ grammars }) {
const problems = [];
for (const g of grammars) {
if (g.prebuilt < 6 && !g.shipsSource) {
const missing = 6 - g.prebuilt;
problems.push(
`${g.name}: ${g.prebuilt}/6 prebuilds and its vendored source is not shipped ` +
`(the package.json \`files\` field excludes it, or it is not buildable) — would ship ` +
`with no loadable binding on ${missing} platform-arch tuple(s).`,
);
}
}
return problems;
}
/**
* Stray local source-build outputs under `vendor/<name>/build/`. These would
* ship in the tarball (`files: ["vendor"]` overrides .gitignore/.npmignore) AND
* shadow the committed prebuilds — `node-gyp-build` resolves `build/Release`
* BEFORE `prebuilds/`, so a consumer on the publisher's platform would load the
* stray (possibly stale/wrong) binding instead of the curated prebuild. The
* build dir is gitignored and only appears if a maintainer source-built locally
* (e.g. on a no-prebuild platform); refuse to publish it. (#2144 review.)
*/
function findStrayBuildArtifacts(vendorDir) {
if (!fs.existsSync(vendorDir)) return [];
return fs
.readdirSync(vendorDir)
.filter((d) => /^tree-sitter-/.test(d))
.filter((d) => fs.existsSync(path.join(vendorDir, d, 'build')))
.map((d) => `vendor/${d}/build`);
}
function collectGrammars(vendorDir, shipsVendorSource) {
if (!fs.existsSync(vendorDir)) return [];
return fs
.readdirSync(vendorDir)
.filter((d) => /^tree-sitter-/.test(d))
.map((name) => {
const dir = path.join(vendorDir, name);
return {
name,
prebuilt: countPrebuiltTuples(dir),
// Source ships when `files` includes the vendor subtree AND the grammar
// actually carries a buildable source set on disk.
shipsSource: shipsVendorSource && isBuildableFromSource(dir),
};
});
}
function main() {
const gitnexusRoot = path.join(__dirname, '..');
const vendorDir = path.join(gitnexusRoot, 'vendor');
const pkg = JSON.parse(fs.readFileSync(path.join(gitnexusRoot, 'package.json'), 'utf8'));
const shipsVendorSource = filesShipsVendorSource(pkg.files);
const grammars = collectGrammars(vendorDir, shipsVendorSource);
if (grammars.length === 0) {
console.error(`[publish-guard] No vendored tree-sitter grammars found under ${vendorDir}.`);
process.exit(1);
}
const stray = findStrayBuildArtifacts(vendorDir);
if (stray.length > 0) {
console.error(
'[publish-guard] Refusing to publish — stray source-build output under vendor/ would\n' +
'ship and shadow the committed prebuilds (node-gyp-build loads build/Release before\n' +
'prebuilds/):',
);
for (const s of stray) console.error(` - ${s}`);
console.error('\nFix: remove it before packing, e.g. `rm -rf gitnexus/vendor/*/build`.');
process.exit(1);
}
const problems = findCoverageProblems({ grammars });
if (problems.length > 0) {
console.error('[publish-guard] Refusing to publish — a vendored grammar would ship unusable:');
for (const p of problems) console.error(` - ${p}`);
console.error(
'\nFix: either commit the missing prebuilds (run the build-tree-sitter-prebuilds\n' +
'workflow) or keep the vendored source in the package.json `files` field.',
);
process.exit(1);
}
const sourceShippers = grammars.filter((g) => g.shipsSource).length;
console.log(
`[publish-guard] OK — ${grammars.length} vendored grammar(s) covered ` +
`(${sourceShippers} shipping source, ${grammars.length - sourceShippers} prebuilds-only).`,
);
}
if (require.main === module) main();
module.exports = {
findCoverageProblems,
findStrayBuildArtifacts,
filesShipsVendorSource,
isBuildableFromSource,
sourceBuildSet,
countPrebuiltTuples,
collectGrammars,
TUPLES,
SOURCE_BUILD_REL,
};
@@ -0,0 +1,374 @@
#!/usr/bin/env node
// FTS evict→reload RSS repro (gitnexus-enterprise PR #222 / local U3).
//
// Settles ONE empirical question that no static read can answer: when a
// LadybugDB database that has `LOAD EXTENSION fts` applied is closed and a
// fresh one is opened + re-LOADed (the pool's evict→reload cycle), does the
// native FTS arena get reclaimed by `db.close()` — or is it stranded, so RSS
// climbs without bound over a long-lived MCP `serve` session?
//
// • PLATEAU across cycles → db.close() reclaims the FTS arena; the OSS pool's
// footprint is bounded by MAX_POOL_SIZE (~5 live arenas). No unbounded leak;
// the #222 worker-isolation rewrite (plan U4) is NOT justified for OSS.
// • MONOTONIC CLIMB → the FTS arena is stranded per reopen; the user's
// hypothesis holds and U4 (route FTS reads through a reclaimable worker) is
// justified.
//
// SCOPE OF THE VERDICT (read before citing it). A per-reload FTS-arena leak
// would be PROPORTIONAL to the index size. A small fixture therefore produces a
// small per-cycle increment that an absolute threshold can read as PLATEAU even
// when a production-scale graph would leak visibly. So:
// - `--rows` controls fixture size; run it LARGE (tens of thousands) before
// concluding "no leak". The default is deliberately not tiny.
// - The verdict (in fts-rss-verdict.mjs) keys on slope DECELERATION, not total
// delta, with a noise floor that scales with the working-set growth
// (peak−baseline) so sensitivity tracks fixture/arena size — NOT the pre-DB
// baseline RSS. A sustained sub-floor positive slope is INCONCLUSIVE (a slow
// creep RSS can't distinguish from noise), never a clean PLATEAU.
// - The PLATEAU verdict is only valid for the corpus size it was run at; the
// output states that size. The production-faithful confirmation is a
// `--via-pool` run against a real large analyzed repo over a long session.
//
// Two modes:
// (default) NATIVE — reproduces the native sequence doInitLbug()+closeOne()
// perform (open Database → new Connection → LOAD EXTENSION fts →
// QUERY_FTS_INDEX → close), against K self-built FTS fixtures, with no
// gitnexus build required. `--no-await-close` mirrors the pool's
// fire-and-forget close instead of awaiting (the production close shape).
// --via-pool <lbugPath> — drives the REAL gitnexus pool from compiled dist
// (initLbug → executeParameterized → closeLbug) against an existing analyzed
// repo, exercising the production path + the GITNEXUS_POOL_RSS_TRACE
// instrumentation. Probes ALL FTS indexes the repo has. Forces an explicit
// close+reinit each cycle. Run `node scripts/build.js` first so the dist
// reflects the current pool-adapter (incl. the RSS trace).
//
// Run with --expose-gc so RSS excludes V8-heap noise:
// node --expose-gc gitnexus/scripts/bench/fts-evict-reload-rss.mjs
// node --expose-gc gitnexus/scripts/bench/fts-evict-reload-rss.mjs --rows 40000 --cycles 30
// GITNEXUS_POOL_RSS_TRACE=1 node --expose-gc \
// gitnexus/scripts/bench/fts-evict-reload-rss.mjs --via-pool /path/to/repo/.gitnexus/lbug
//
// Flags by mode: --rows/--repos/--read-write/--no-await-close apply to NATIVE
// only; --cycles applies to both. VIA-POOL warns when a NATIVE-only flag is set.
//
// Memory benches are noisy. Default is 24 cycles; trust the TREND (slope /
// first-third vs last-third), never a single delta. A flat trend at a LARGE
// fixture is a real NEGATIVE result (no unbounded leak), not a failed run.
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
// Pure verdict classifier (median, slopeMbPerCycle, classifyVerdict) lives in a
// side-effect-free sibling module so it is unit-testable without loading the
// native addon or running this bench. See fts-rss-verdict.mjs.
import { classifyVerdict, median, slopeMbPerCycle } from './fts-rss-verdict.mjs';
const require = createRequire(import.meta.url);
const lbugModule = require('@ladybugdb/core');
const lbug = lbugModule.default ?? lbugModule;
const LBUG_MAX_DB_SIZE = 16 * 1024 * 1024 * 1024;
// ── args ──────────────────────────────────────────────────────────────────
function argVal(flag, dflt) {
const i = process.argv.indexOf(flag);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
}
const CYCLES = Math.max(6, parseInt(argVal('--cycles', '24'), 10) || 24);
const REPOS = Math.max(1, parseInt(argVal('--repos', '6'), 10) || 6); // >5 mirrors LRU thrash
// Fixture size. Default is large enough that a size-proportional leak would be
// visible across cycles; raise it further before trusting a PLATEAU verdict.
const ROWS = Math.max(100, parseInt(argVal('--rows', '8000'), 10) || 8000);
const VIA_POOL = argVal('--via-pool', null);
const READONLY = !process.argv.includes('--read-write');
const AWAIT_CLOSE = !process.argv.includes('--no-await-close');
if (VIA_POOL) {
// These flags are consumed only by NATIVE mode; warn rather than ignore
// silently so a VIA-POOL run is not misread as honoring them.
const ignored = ['--rows', '--repos', '--read-write', '--no-await-close'].filter((f) =>
process.argv.includes(f),
);
if (ignored.length) {
console.error(
`[fts-rss] NOTE: ${ignored.join(', ')} apply to NATIVE mode only; ignored in --via-pool.`,
);
}
}
if (typeof global.gc !== 'function') {
console.error(
'[fts-rss] WARNING: run with --expose-gc for clean RSS samples ' +
'(`node --expose-gc <thisfile>`). Continuing without forced GC — results are noisier.',
);
}
const gc = () => {
if (typeof global.gc === 'function') {
global.gc();
global.gc();
}
};
const rssMb = () => Math.round(process.memoryUsage().rss / (1024 * 1024));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ── fixture: a minimal FTS-bearing .lbug ────────────────────────────────────
const WORDS = [
'login auth session token user password validate verify credential',
'parse tree syntax node grammar lexer token ast traversal visitor',
'graph query cypher match relation node edge pattern aggregate index',
'memory pool buffer arena allocate reclaim evict cache resident heap',
'search rank score bm25 fts index stem porter keyword document corpus',
'worker fork process spawn kill reclaim isolate native binding addon',
];
function buildFixture(dir) {
fs.mkdirSync(dir, { recursive: true });
const dbPath = path.join(dir, 'fixture.lbug');
const db = new lbug.Database(dbPath, 0, false, false, LBUG_MAX_DB_SIZE);
const conn = new lbug.Connection(db);
return (async () => {
await conn.query('LOAD EXTENSION fts');
await conn.query(
'CREATE NODE TABLE Doc(id STRING, name STRING, content STRING, PRIMARY KEY(id))',
);
// Batch-insert via UNWIND so large fixtures (`--rows`) build in seconds
// instead of one round-trip per row. The fixture size drives the per-arena
// FTS allocation, which is what makes a size-proportional leak observable.
const rows = [];
for (let i = 0; i < ROWS; i++) {
const w = WORDS[i % WORDS.length];
const name = `sym_${i}`;
const content = `${w} ${name} block number ${i} ${WORDS[(i + 3) % WORDS.length]}`;
rows.push({ id: `doc:${i}`, name, content });
}
const INSERT_CHUNK = 2000;
for (let i = 0; i < rows.length; i += INSERT_CHUNK) {
const chunk = rows.slice(i, i + INSERT_CHUNK);
const stmt = await conn.prepare(
'UNWIND $rows AS r CREATE (:Doc {id: r.id, name: r.name, content: r.content})',
);
await conn.execute(stmt, { rows: chunk });
}
await conn.query(
"CALL CREATE_FTS_INDEX('Doc', 'doc_fts', ['name', 'content'], stemmer := 'porter')",
);
await conn.close();
await db.close();
return dbPath;
})();
}
const QUERIES = ['login token', 'parse node', 'memory arena', 'search index', 'worker reclaim'];
// ── NATIVE mode ─────────────────────────────────────────────────────────────
async function runNative() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fts-rss-'));
console.error(
`[fts-rss] NATIVE: ${REPOS} fixtures × ${ROWS} rows × ${CYCLES} cycles ` +
`(readOnly=${READONLY}, awaitClose=${AWAIT_CLOSE})`,
);
console.error(`[fts-rss] building ${REPOS} FTS fixture(s) under ${root} …`);
const srcDb = await buildFixture(path.join(root, 'src'));
const repoPaths = [];
for (let k = 0; k < REPOS; k++) {
const dst = path.join(root, `repo-${k}`);
fs.cpSync(path.dirname(srcDb), dst, { recursive: true });
repoPaths.push(path.join(dst, 'fixture.lbug'));
}
// Mirror the pool's evict→reload: each visit opens a FRESH Database, makes a
// Connection, LOADs fts, runs an FTS query, then closes — no caching, so every
// visit is a reload. K>5 amplifies the LRU-thrash signal the pool would see.
const series = [];
gc();
await sleep(50);
const baseline = rssMb();
console.error(`[fts-rss] baseline RSS=${baseline}MB`);
for (let cycle = 0; cycle < CYCLES; cycle++) {
for (let k = 0; k < REPOS; k++) {
const db = new lbug.Database(repoPaths[k], 0, false, READONLY, LBUG_MAX_DB_SIZE);
const conn = new lbug.Connection(db);
try {
await conn.query('LOAD EXTENSION fts'); // the per-reload re-LOAD under test
const q = QUERIES[(cycle + k) % QUERIES.length];
const res = await conn.query(
`CALL QUERY_FTS_INDEX('Doc', 'doc_fts', '${q}') RETURN node.id AS id, score ORDER BY score DESC LIMIT 20`,
);
// Drain so the query actually materializes results.
if (res && typeof res.getAll === 'function') await res.getAll();
} catch (e) {
console.error(`[fts-rss] query error (cycle ${cycle}, repo ${k}): ${e?.message || e}`);
} finally {
// AWAIT_CLOSE (default) is the best case for reclamation. --no-await-close
// mirrors the pool's fire-and-forget close (closeOne: db.close().catch())
// so a leak that only manifests without awaiting is not hidden.
if (AWAIT_CLOSE) {
try {
await conn.close();
await db.close();
} catch {
/* ignore */
}
} else {
conn.close().catch(() => {});
db.close().catch(() => {});
}
}
}
gc();
// Longer settle when not awaiting close, so fire-and-forget native teardown
// has a chance to complete before the RSS sample (avoids a false PLATEAU).
await sleep(AWAIT_CLOSE ? 20 : 200);
const rss = rssMb();
series.push(rss);
console.error(`[fts-rss] cycle ${String(cycle + 1).padStart(3)}/${CYCLES} rssMB=${rss}`);
}
fs.rmSync(root, { recursive: true, force: true });
return { baseline, series, corpus: `${REPOS}×${ROWS} rows, native, awaitClose=${AWAIT_CLOSE}` };
}
// ── VIA-POOL mode (real gitnexus pool from compiled dist) ───────────────────
async function runViaPool(lbugPath) {
if (!fs.existsSync(lbugPath)) {
console.error(`[fts-rss] --via-pool path not found: ${lbugPath}`);
process.exit(2);
}
// Compiled dist is required (the pool pulls the native addon + many modules).
const distUrl = new URL('../../dist/core/lbug/pool-adapter.js', import.meta.url);
let pool;
try {
pool = await import(distUrl.href);
} catch (e) {
console.error(
`[fts-rss] could not import compiled pool-adapter (${e?.message}). ` +
`Run \`node scripts/build.js\` first, or use NATIVE mode.`,
);
process.exit(2);
}
const { initLbug, executeParameterized, closeLbug } = pool;
console.error(
`[fts-rss] VIA-POOL on ${lbugPath} × ${CYCLES} cycles ` +
`(explicit closeLbug+initLbug per cycle = forced evict→reload)`,
);
// Probe ALL FTS indexes the analyzed graph carries (mirrors fts-schema.ts
// FTS_INDEXES) so the per-cycle FTS arena load matches production, not a
// 2-of-5 subset that would understate it.
const FTS_INDEXES = [
{ table: 'File', indexName: 'file_fts' },
{ table: 'Function', indexName: 'function_fts' },
{ table: 'Class', indexName: 'class_fts' },
{ table: 'Method', indexName: 'method_fts' },
{ table: 'Interface', indexName: 'interface_fts' },
];
const series = [];
gc();
const baseline = rssMb();
console.error(`[fts-rss] baseline RSS=${baseline}MB`);
for (let cycle = 0; cycle < CYCLES; cycle++) {
try {
await initLbug(lbugPath, lbugPath);
const q = QUERIES[cycle % QUERIES.length];
for (const { table, indexName } of FTS_INDEXES) {
await executeParameterized(
lbugPath,
`CALL QUERY_FTS_INDEX('${table}', '${indexName}', $q) RETURN node.id AS id, score ORDER BY score DESC LIMIT 20`,
{ q },
).catch(() => []); // index may not exist for this graph — that's fine
}
await closeLbug(lbugPath); // force eviction → next cycle reopens + re-LOADs fts
} catch (e) {
console.error(`[fts-rss] pool cycle ${cycle} error: ${e?.message || e}`);
}
gc();
// closeLbug fires a fire-and-forget native close (pool closeOne:
// db.close().catch()), so settle longer than NATIVE's awaited close to let
// native teardown finish before sampling — else a real leak reads PLATEAU.
await sleep(200);
const rss = rssMb();
series.push(rss);
console.error(`[fts-rss] cycle ${String(cycle + 1).padStart(3)}/${CYCLES} rssMB=${rss}`);
}
await closeLbug().catch(() => {});
return { baseline, series, corpus: `via-pool ${path.basename(path.dirname(lbugPath))}` };
}
// ── verdict ─────────────────────────────────────────────────────────────────
function verdict({ baseline, series, corpus }) {
const third = Math.max(1, Math.floor(series.length / 3));
const firstMed = median(series.slice(0, third));
const lastMed = median(series.slice(-third));
const delta = lastMed - firstMed;
const slope = slopeMbPerCycle(series);
// All label logic lives in the pure, unit-tested classifier (fts-rss-verdict.mjs):
// epsilon-first flat→PLATEAU, decelerated→PLATEAU, sustained-sub-floor→INCONCLUSIVE,
// ≥floor sustained→CLIMB, step→INCONCLUSIVE; floor scales with the working-set
// growth (peak−baseline), not the pre-DB baseline RSS.
const {
verdict: label,
firstHalfSlope,
secondHalfSlope,
decelRatio,
floor,
stepDiscontinuity,
maxJump,
peak,
} = classifyVerdict(series, baseline);
console.log('\n==================== FTS evict→reload RSS verdict ====================');
console.log(`corpus: ${corpus}`);
console.log(`samples (MB): ${series.join(' ')}`);
console.log(
`baseline=${baseline} firstThirdMed=${firstMed} lastThirdMed=${lastMed} delta=${delta}MB ` +
`peak=${peak} overallSlope=${slope.toFixed(2)} firstHalfSlope=${firstHalfSlope.toFixed(2)} ` +
`secondHalfSlope=${secondHalfSlope.toFixed(2)}MB/cycle floor=${floor.toFixed(2)} decelRatio=${decelRatio.toFixed(2)} ` +
`maxJump=${maxJump}MB step=${stepDiscontinuity} cycles=${series.length}`,
);
if (label === 'CLIMB') {
console.log(
'VERDICT: CLIMB — the per-cycle increment is SUSTAINED (second-half slope ≈ first-half),\n' +
' i.e. RSS rises ~linearly with no decay. The native FTS arena is NOT reclaimed\n' +
' by db.close(); the leak is real over a long-lived session.\n' +
' → plan U4 (worker/process isolation of the FTS read path) is JUSTIFIED.',
);
} else if (label === 'PLATEAU') {
console.log(
`VERDICT: PLATEAU at this corpus (${corpus}) — the per-cycle increment DECAYS to flat\n` +
' (second-half slope below the noise floor). db.close() reclaims the FTS arena;\n' +
' footprint is bounded (and the pool further caps it at MAX_POOL_SIZE). No\n' +
' unbounded leak. Caveat: synthetic fixture — confirm with a --via-pool run\n' +
' against a real large analyzed repo before fully closing plan U4.',
);
} else {
console.log(
`VERDICT: INCONCLUSIVE at this corpus (${corpus}) — the run is noisy (step discontinuity)\n` +
' or still decelerating without reaching flat, so neither a clean PLATEAU nor a\n' +
' sustained linear CLIMB can be asserted. NATIVE synthetic runs do not resolve\n' +
' this reliably at scale. The definitive test is a --via-pool run against a real\n' +
' large analyzed repo over many cycles (with GITNEXUS_POOL_RSS_TRACE=1). Plan U4\n' +
' stays GATED — neither closed nor built on this evidence.',
);
}
console.log(
`MACHINE: ${JSON.stringify({ mode: VIA_POOL ? 'via-pool' : 'native', corpus, baseline, firstMed, lastMed, delta, overallSlope: Number(slope.toFixed(3)), firstHalfSlope: Number(firstHalfSlope.toFixed(3)), secondHalfSlope: Number(secondHalfSlope.toFixed(3)), floor: Number(floor.toFixed(3)), decelRatio: Number(decelRatio.toFixed(3)), maxJump, stepDiscontinuity, peak, cycles: series.length, verdict: label })}`,
);
console.log('=====================================================================\n');
}
// ── main ────────────────────────────────────────────────────────────────────
(async () => {
const result = VIA_POOL ? await runViaPool(VIA_POOL) : await runNative();
verdict(result);
process.exit(0);
})().catch((e) => {
console.error('[fts-rss] fatal:', e?.stack || e);
process.exit(1);
});
+105
View File
@@ -0,0 +1,105 @@
// Pure, side-effect-free verdict classifier for the FTS evict→reload RSS bench
// (fts-evict-reload-rss.mjs). Extracted so it can be unit-tested WITHOUT importing
// the native LadybugDB addon or running the bench — this module has zero imports
// and zero module-scope side effects. Do not add imports or top-level statements.
//
// The discriminant between a real leak and allocator warmup is SLOPE DECELERATION,
// not total delta. A true per-reload leak (stranded FTS arena) rises ~linearly:
// the second-half slope stays ≈ the first-half slope. Allocator working-set warmup
// rises then flattens: the second-half slope decays to a fraction of the first.
//
// Thresholds:
// EPSILON (~0.1 MB/cycle) — below this the tail is effectively flat (no leak).
// SUSTAIN_FLOOR (0.5 MB/cycle) — the base noise floor.
// The floor SCALES with the working-set growth (peak − baseline), NOT the pre-DB
// `baseline` RSS: baseline is interpreter/addon overhead (and is LARGER in
// --via-pool mode), so a baseline-keyed floor would inflate and HIDE leaks. A
// bigger fixture has a bigger arena and bigger per-cycle noise, so the floor
// rises with the working set: floor = SUSTAIN_FLOOR · max(1, (peak−baseline)/REF).
export const EPSILON_MB_PER_CYCLE = 0.1;
export const SUSTAIN_FLOOR = 0.5;
// Reference working-set (MB) at which the floor equals SUSTAIN_FLOOR; the floor
// scales up linearly for larger arenas. ~200 MB ≈ a small FTS fixture's footprint.
export const FLOOR_REF_WORKINGSET_MB = 200;
export function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
}
export function slopeMbPerCycle(series) {
// Least-squares slope of rss vs cycle index.
const n = series.length;
if (n < 2) return 0;
const xs = series.map((_, i) => i);
const xMean = xs.reduce((a, b) => a + b, 0) / n;
const yMean = series.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i++) {
num += (xs[i] - xMean) * (series[i] - yMean);
den += (xs[i] - xMean) ** 2;
}
return den === 0 ? 0 : num / den;
}
/**
* Classify an RSS-per-cycle series into PLATEAU / CLIMB / INCONCLUSIVE.
* Pure: no I/O, no globals. `baseline` is the pre-DB RSS; `peak` defaults to the
* series max. Returns the label plus the diagnostics the bench prints.
*/
export function classifyVerdict(series, baseline, peak = Math.max(...series)) {
const cycles = series.length;
const half = Math.max(1, Math.floor(cycles / 2));
const firstHalfSlope = slopeMbPerCycle(series.slice(0, half));
const secondHalfSlope = slopeMbPerCycle(series.slice(-half));
const decelRatio = secondHalfSlope / Math.max(firstHalfSlope, 1e-9);
// Step discontinuity: a single cycle-to-cycle jump far larger than the typical
// per-cycle delta — a one-time allocator/arena reservation (then flat), not a
// per-reload leak, but a noisy run we won't claim a clean result on.
const deltas = series.slice(1).map((v, i) => v - series[i]);
const absDeltas = deltas.map(Math.abs).sort((a, b) => a - b);
const medAbsDelta = absDeltas.length ? absDeltas[Math.floor(absDeltas.length / 2)] : 0;
const maxJump = deltas.length ? Math.max(...deltas) : 0;
const stepDiscontinuity = maxJump > Math.max(30, 5 * Math.max(medAbsDelta, 1));
// Working-set-scaled floor (see header). Guard against a negative working set.
const workingSet = Math.max(0, peak - baseline);
const floor = SUSTAIN_FLOOR * Math.max(1, workingSet / FLOOR_REF_WORKINGSET_MB);
const SUSTAINED = 0.6; // decelRatio at/above which the tail is "not decaying"
let verdict;
if (stepDiscontinuity) {
verdict = 'INCONCLUSIVE';
} else if (secondHalfSlope < EPSILON_MB_PER_CYCLE) {
// Effectively flat — no leak, regardless of decelRatio (a flat-from-start run
// has decelRatio ≈ 1 but is still PLATEAU). This gate is what keeps a true
// negative from being over-corrected into INCONCLUSIVE.
verdict = 'PLATEAU';
} else if (secondHalfSlope >= floor) {
// Tail is still substantial: sustained → real leak; decelerating → unresolved.
verdict = decelRatio >= SUSTAINED ? 'CLIMB' : 'INCONCLUSIVE';
} else if (decelRatio < SUSTAINED) {
// Below the floor AND decelerating — warmup converged toward flat → PLATEAU.
verdict = 'PLATEAU';
} else {
// Below the floor but SUSTAINED — a slow steady creep RSS can't distinguish
// from noise at this scale. The honest label is "not resolved", NEVER a clean
// PLATEAU ("no leak"). This is the headline tri-review fix.
verdict = 'INCONCLUSIVE';
}
return {
verdict,
firstHalfSlope,
secondHalfSlope,
decelRatio,
floor,
stepDiscontinuity,
maxJump,
peak,
};
}
@@ -1,57 +0,0 @@
#!/usr/bin/env node
/**
* Build tree-sitter-dart native binding in node_modules/ after materialize-vendor-grammars.cjs.
* Vendored source lives in vendor/ only; see #836 and #1728.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Opt-out: skip the native rebuild entirely. Dart parsing becomes
// unavailable but `npm install gitnexus` finishes much faster on machines
// without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
// (read as a string), and any other value all fall through to the rebuild.
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
console.warn(
'[tree-sitter-dart] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Dart parsing will be unavailable until reinstalled without the env var.',
);
process.exit(0);
}
const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart');
const bindingGyp = path.join(dartDir, 'binding.gyp');
const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node');
try {
if (!fs.existsSync(bindingGyp) || fs.existsSync(bindingNode)) {
process.exit(0);
}
try {
require.resolve('node-addon-api');
require.resolve('node-gyp-build');
} catch (resolveErr) {
console.warn(
'[tree-sitter-dart] Skipping build: hoisted build deps not resolvable (%s).',
resolveErr.message,
);
console.warn(
'[tree-sitter-dart] Dart parsing will be unavailable. Install without --no-optional and with scripts enabled to build.',
);
process.exit(0);
}
console.log('[tree-sitter-dart] Building native binding...');
execSync('npx node-gyp rebuild', {
cwd: dartDir,
stdio: 'pipe',
timeout: 180000,
});
console.log('[tree-sitter-dart] Native binding built successfully');
} catch (err) {
console.warn('[tree-sitter-dart] Could not build native binding:', err.message);
console.warn(
'[tree-sitter-dart] Dart parsing will be unavailable. Non-Dart functionality is unaffected.',
);
process.exit(0);
}
@@ -0,0 +1,126 @@
#!/usr/bin/env node
/**
* Activate the vendored tree-sitter native bindings IN PLACE under `vendor/`.
* One registry-driven script replaces the former per-grammar
* build-tree-sitter-<name>.cjs files (they were ~95% identical).
*
* The grammars (tree-sitter-c/dart/proto/swift/kotlin) are loaded from
* `vendor/<name>/` by absolute path at runtime (see
* src/core/tree-sitter/vendored-grammars.ts) and are NEVER copied into
* node_modules — an undeclared package under node_modules is "extraneous" to
* every subsequent npm/npx reify, which prunes/relocates it (Windows
* `EPERM: …, symlink` + a silent grammar deletion on the 2nd run; #2111/#1728).
*
* For each grammar the resolution order is identical:
* 1. If the vendored source is absent (no binding.gyp) or the binding is
* already built, do nothing.
* 2. Prefer a committed prebuild for this platform-arch (toolchain-free) via
* node-gyp-build — `vendor/<name>/prebuilds/` ships all six tuples, so on a
* supported platform this returns immediately and writes nothing.
* 3. Otherwise source-build from the vendored grammar source (binding.gyp +
* src/) into `vendor/<name>/build/` (gitignored) so parsing still works on
* a toolchain host that lacks a matching prebuild.
*
* HARD INVARIANT: this runs in `gitnexus`'s postinstall, so it MUST NEVER throw
* or exit non-zero — a failure for any single grammar must not break the install.
*
* Opt-out: GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (strict '1') skips the OPTIONAL
* grammars only. tree-sitter-c is REQUIRED (it backstops upstream's 4/6 ARM
* prebuild gap, #2116) and is always built.
*
* Usage:
* node build-tree-sitter-grammars.cjs # all grammars (postinstall)
* node build-tree-sitter-grammars.cjs swift c # only the named grammars
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Registry. `display`/`ext` drive the human-readable warnings; `required`
// grammars ignore the opt-out gate. Insertion order == build order (c first).
const GRAMMARS = {
c: { required: true, display: 'C', ext: '.c' },
dart: { required: false, display: 'Dart', ext: '.dart' },
proto: { required: false, display: 'Proto', ext: '.proto' },
swift: { required: false, display: 'Swift', ext: '.swift' },
kotlin: { required: false, display: 'Kotlin', ext: '.kt/.kts' },
};
const skipOptional = process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1';
function buildGrammar(short) {
const cfg = GRAMMARS[short];
const tag = `[tree-sitter-${short}]`;
if (!cfg.required && skipOptional) {
console.warn(
`${tag} Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). ${cfg.display} parsing will be unavailable until reinstalled without the env var.`,
);
return;
}
const dir = path.join(__dirname, '..', 'vendor', `tree-sitter-${short}`);
const bindingGyp = path.join(dir, 'binding.gyp');
const bindingNode = path.join(dir, 'build', 'Release', `tree_sitter_${short}_binding.node`);
try {
// Not materialized (no source), or already built — nothing to do.
if (!fs.existsSync(bindingGyp) || fs.existsSync(bindingNode)) {
return;
}
// Prefer a committed prebuild for this platform-arch (no toolchain needed).
try {
require('node-gyp-build').path(dir);
return;
} catch {
// No matching prebuild — fall through to the source build below.
}
// The hoisted build deps must be resolvable to source-build.
try {
require.resolve('node-addon-api');
require.resolve('node-gyp-build');
} catch (resolveErr) {
console.warn(
`${tag} Skipping build: hoisted build deps not resolvable (${resolveErr.message}).`,
);
console.warn(
`${tag} ${cfg.display} parsing will be unavailable until a prebuild or toolchain is present.`,
);
return;
}
console.log(`${tag} No prebuild for this platform — building native binding from source...`);
execSync('npx node-gyp rebuild', { cwd: dir, stdio: 'pipe', timeout: 180000 });
console.log(`${tag} Native binding built successfully`);
} catch (err) {
console.warn(`${tag} Could not build native binding:`, err.message);
console.warn(
`${tag} ${cfg.display} (${cfg.ext}) parsing will be unavailable. Non-${cfg.display} functionality is unaffected.`,
);
}
}
function main() {
const args = process.argv.slice(2).filter(Boolean);
const targets = args.length > 0 ? args : Object.keys(GRAMMARS);
for (const short of targets) {
if (!GRAMMARS[short]) {
console.warn(`[tree-sitter] Unknown grammar '${short}' — skipping.`);
continue;
}
// Defensive: never let an unexpected throw escape and fail the install.
try {
buildGrammar(short);
} catch (err) {
console.warn(`[tree-sitter-${short}] Unexpected build error (ignored): ${err.message}`);
}
}
// Hard guarantee: postinstall must never exit non-zero.
process.exit(0);
}
if (require.main === module) main();
module.exports = { GRAMMARS, buildGrammar };

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