* perf(hooks): cmdline-first Linux db-lock scan, drop the lsof fallback (#2180)
The probe's Linux scan was O(processes × fds) — stat every fd of every
process — so on a busy host it blew its budget and fell through to lsof,
which then timed out (~2 s) and fail-closed. Every Grep/Glob/Bash hook
spent ~2 s of CPU to conclude 'couldn't tell'.
Rewrite linuxProcScanFindGitNexusServer (name kept; return type now
tri-state 'owned' | 'not-owned' | 'timeout') as three phases:
0. /proc/<pid>/comm prefilter — kernel task->comm, never touches the
target's memory maps; truncation-safe whitelist match (comm is
capped at 15 visible chars). Calibrated to what a real server
reports: @ladybugdb/core's worker_threads rename the main thread to
'MainThread', so that is whitelisted alongside the launcher
basenames — omitting it would blind the probe to every server.
1. bounded /proc/<pid>/cmdline read (openSync+readSync, default 16 KiB
with a floor of 4 KiB and a bounded escalation up to a hard ceiling)
so a D-state holder cannot stall the hook and the mcp/serve mode
token is never clipped off a long interpreter path.
2. dev+ino fd match for the 0–2 survivors only.
Dispatch: 'owned' and 'timeout' both map to true. Timeout is now
fail-closed (overload self-throttle) instead of falling through to lsof;
the Linux lsof fallback is removed entirely. End-to-end semantics on
busy hosts are unchanged (the old lsof arm also fail-closed there) — the
~2 s of wasted work and the orphan-spawning lsof are what's gone.
macOS lsof+ps and Windows Restart Manager paths are untouched.
Also: fix the budget parse bug (Number(raw && trim()) treated '0' as
1200; now parseInt-then-validate, with <= 0 an explicit immediate
timeout) and add GITNEXUS_HOOK_PROC_ROOT so the Linux scan can be unit
tested against a fixture procfs instead of the host's real /proc.
Measured on a 583-process host with 6 background gitnexus mcp servers:
owner detection 6–12 ms (was ~1216 ms + lsof timeout), ~100x.
Tests: new hook-db-lock-probe.test.ts drives all three phases against a
fake procfs (comm-truncation safety, Phase 0 trap, 4 KiB-boundary
owner-miss guard, budget=0 immediate timeout, EACCES fail-closed) plus a
live-/proc e2e that pins the fd-visible lbug-handle property against a
real subprocess holder. The lsof/ps owner-detection suites are relaned
to macOS (Linux no longer takes that path); the lsof orphan-reaping
suite is removed (no lsof is spawned on Linux now) with a rationale note.
Note: pre-commit typecheck skipped; remaining tsc errors are pre-existing
on main (none in files touched here).
* fix(hooks): honest EACCES verdict + real escalation coverage (#2183 review)
Addresses the tri-review (maintainer + Codex):
- [P2] Phase-2 fd-dir EACCES no longer claims 'owned'. /proc/<pid>/fd is
owner-only (0500), so a cross-user/root gitnexus server serving ANY
repo cleared Phase 0+1 and hit EACCES here, and the old catch returned
'owned' — falsely claiming it locks THIS repo's lbug (dev+ino never
compared) and permanently suppressing augment. Split the failure
shapes: ENOENT -> continue (raced away); EACCES/EPERM and transient
EIO/ESTALE -> 'timeout' (unverifiable -> fail-closed, but honest, not a
false ownership claim); ENOTDIR/other structural errors -> continue
(not a real fd dir). Same fail-closed dispatcher outcome, no false
'owned', plus a GITNEXUS_DEBUG diagnostic so an operator can tell this
skip path from a real owner.
- [P2] The escalation test now actually iterates the escalation loop:
the gitnexus token sits under 4 KB while the mode token is padded past
GITNEXUS_HOOK_PROC_CMDLINE_MAX=4096, and a readSync spy asserts >1 read
(the old 9 KB-under-16 KB-cap shape read once and never escalated).
- escalation loop now re-checks the budget each iteration and returns a
distinct timeout sentinel (never '' — an empty string would read as
'not a candidate' and could drop a real owner -> fail-open); the caller
maps it to 'timeout'.
- GITNEXUS_HOOK_PROC_ROOT is gated to test context so a stray production
env export can't disable Linux owner detection (fail-open).
- New uid-agnostic spy tests pin every fd-readdir errno branch
(EACCES/EPERM/EIO/ESTALE -> timeout, ENOTDIR -> not-owned) regardless
of the runner's uid (the disk chmod-000 tests no-op under root).
Note: pre-commit typecheck skipped; remaining tsc errors are pre-existing
on main (none in files touched here).
* fix(hooks): drop the always-true outOfBudget presence guard (CodeQL #2183)
CodeQL flagged `typeof outOfBudget === 'function' && outOfBudget()` as
unneeded defensive code: readLinuxCmdline has a single caller
(linuxProcScanFindGitNexusServer) that always passes the callback, so
the typeof guard is dead. Drop it, leaving `if (outOfBudget())`, and note
the invariant in the comment. Mirrored in the byte-identical plugin copy.
* fix(hooks): parse numeric hook env with Number() so scientific notation works (#2183 review)
getCmdlineMaxBytes and resolveLinuxProcBudgetMs parsed their env via
Number.parseInt(raw, 10), so a value like "16e3" silently became 16 (parseInt
stops at 'e') instead of 16000. Switch both to Number(String(raw).trim()),
which honors scientific notation and is stricter on trailing garbage
("123abc" -> NaN -> default) — matching the repo-majority Number()+isFinite
env idiom (src/cli/analyze.ts, src/core/embeddings/hf-env.ts).
The two functions had DIFFERENT guard skeletons, so a verbatim swap would
regress the budget: resolveLinuxProcBudgetMs used `raw != null ?` with no
empty-string short-circuit, and Number("")===0 (vs parseInt("")===NaN) would
make a set-but-empty GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS="" resolve to budget 0
=> immediate fail-CLOSED timeout => augment permanently skipped. Added the
`&& String(raw).trim()` guard so ''/whitespace fall to the 1200 default while
"0" still parses to the deliberate #2180 immediate-timeout vector.
Exported both helpers for white-box tests (the values are otherwise only
observable indirectly through scan timing) and added platform-independent
coverage: "16e3"->16000, ""/whitespace->1200 (the regression guard), "0"->0,
"123abc"/unset->1200, cmdline "8e3"->8000, "2e3"/""/unset->16384.
Both byte-identical hook-db-lock-probe.cjs copies updated together.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(hooks): allocUnsafe the per-chunk cmdline read buffer (#2183 review)
readLinuxCmdline allocated each per-chunk read buffer with Buffer.alloc(chunkCap),
zero-filling memory that readSync immediately and fully overwrites. Switch the
hot read buffer to Buffer.allocUnsafe — safe because readSync initializes
exactly [0, bytes), only buf.subarray(0, bytes) is consumed, and Buffer.concat
deep-copies that slice into `collected`, so the uninitialized tail can never
reach the decoded cmdline. The zero-length `collected = Buffer.alloc(0)` is left
unchanged (allocUnsafe gains nothing on a 0-length buffer). The existing D3
multi-chunk decode tests cover the read path and stay green.
Both byte-identical hook-db-lock-probe.cjs copies updated together.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(hooks): harden the live /proc owner-detection e2e against CI flake (#2183 review)
Two flake mechanisms, fixed without weakening what the e2e proves:
- Holder readiness (the genuine false-FAIL): the pid-file poll was 200x25ms=5s;
a loaded runner can be slow to spawn the child, tripping
expect(holderPid).toBeGreaterThan(0). Widened to ~10s and raised the per-test
timeout 20s -> 40s.
- Scan budget (kept the assertion honest): the live scan ran at the default
1200ms. Because the dispatcher maps a budget 'timeout' to owned=TRUE, a busy
host exhausting 1200ms before reaching the holder would make the assertion
pass for the WRONG reason (a hollow timeout, not real fd-visible detection).
Set a generous explicit 10000ms budget via the existing setEnv() helper so the
module afterEach restores it (replacing the raw `delete process.env...` that
bypassed env tracking). Raised the coarse timing regression guard to sit ABOVE
the budget (5000 -> 15000) so a legitimately-slow-but-correct scan can't trip
it.
The load-bearing asserts (dev+ino fd-visibility precheck, owned===true for our
own lbug) are unchanged. Verified the e2e executes (not skipped) on Linux.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(changelog): empty the root CHANGELOG [Unreleased] section
Per maintainer request, nothing should sit under [Unreleased] in the root
CHANGELOG.md (the release-owned changelog is gitnexus/CHANGELOG.md, whose
[Unreleased] is already empty). Removes all three accumulated blocks — Fixed
(#2163), Performance (#2180), Changed (KuzuDB->LadybugDB) — leaving only the
[Unreleased] header above [1.5.3]. Pure removal; no release sections touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): add graph-load skip decision helper and node threshold (#2178)
* feat(web): skip graph download in connectToServer for chat-only mode (#2178)
* feat(web): add graphMode state and empty-graph chat-only handling (#2178)
* feat(web): read and thread ?skipGraph URL param through connect flow (#2178)
* feat(web): chat-only empty state with load-graph-anyway escape hatch (#2178)
* style(web): apply prettier formatting to graph-load files (#2178)
* fix(review): apply autofix feedback
- Fail-safe confirm + authoritative node count (P1: prevent re-triggering the hang via Load-graph-anyway when count unknown)
- In-flight guard on loadGraphAnyway (P1: double-fire)
- Honor explicit ?skipGraph in onAnalyzeComplete and DropZone (R6/U4)
- Extract buildGraphFromConnectResult shared helper (DRY across 3 connect sites)
- Add tests: switchRepo skip path, threshold config override, loadGraphAnyway error path, confirm fail-safe, in-flight guard
* fix(review): address tri-review findings
- P1 (correctness+adversarial+risk): stop the cross-repo / F5 chat-only leak.
loadGraphAnyway no longer persists ?skipGraph=0, and onAnalyzeComplete +
DropZone no longer inherit a stale ?skipGraph for a different repo — both
could bypass auto-detect and re-trigger the #2178 hang. ?skipGraph is now a
bookmark hint honored only by the initial auto-connect; in-session repo
changes auto-detect.
- P2 (performance): auto-detect now also skips on edge count (edge-driven
force-layout cliff), not just nodes; LARGE_GRAPH_EDGE_THRESHOLD default 50K.
- P2 (julik): reset graphMode/chatOnlyNodeCount at the top of switchRepo so a
failed switch can't leave a stale chat-only overlay.
- P2 (julik): set serverBaseUrl before awaiting handleServerConnect in
auto-connect so the Load-graph-anyway button isn't briefly a no-op.
- P2 (risk): hide the misleading '0 nodes / 0 edges' stats in chat-only mode
(Header + StatusBar).
- P2 (performance): guard the GraphCanvas layout effect against the empty
chat-only graph.
- Tests: edge-threshold decision + connectToServer edge-trigger; load-anyway
no longer asserts URL persistence.
* fix(web): make Load-graph-anyway cancellable, unmount-safe, fail-safe confirm (#2178)
- AbortController + mountedRef: cancel the in-flight download on unmount and
guard every post-await setState by the mounted ref (an abort surfaces as a
BackendError, not a DOMException AbortError, so name-checks would miss it)
- Stale-result guard: a load-anyway that resolves after a concurrent switchRepo
no longer clobbers the new repo's graph/mode/count
- GraphCanvas confirm fails SAFE (treat as declined) when window.confirm is
unavailable or throws, instead of silently proceeding into a large download
* fix(web): make the AI agent and chat surface aware of chat-only mode (#2178)
- buildDynamicSystemPrompt + createGraphRAGAgent take a chatOnly flag and append
a note (both prompt branches) that supersedes VISUAL GROUNDING: the graph isn't
loaded, [[Type:Name]] node citations won't highlight, prefer [[path:START-END]]
- initializeAgent resolves chatOnly = opts ?? graphModeRef.current==='chatOnly':
connect-flow callers (handleServerConnect, switchRepo, loadGraphAnyway re-init)
pass it explicitly; lazy/settings re-inits fall back to live mode via the ref
- loadGraphAnyway re-inits the agent (chatOnly:false) after a full load so the
prompt drops the note
- RightPanel shows a chat-only banner so the degradation is visible where AI
output renders (en + zh-CN)
* fix(web): streaming circuit breaker for graphs with missing size stats (#2178)
- GraphTooLargeError + a mid-stream breaker in parseNdjsonGraphResponse: count
nodes/relationships as they arrive and abort (cancel reader in try/finally,
then throw) the moment either crosses its limit — reusing the existing node/
edge thresholds, no new magic constant. Throwing right after the offending
push means a later error record in the same chunk can't pre-empt it.
- fetchGraph gains optional maxNodes/maxEdges (off by default → existing callers
unchanged). connectToServer arms them only for auto-detect downloads
(skipGraph !== false) and catches GraphTooLargeError → chat-only, re-throwing
every other error. This backstops the no-stats fail-open path that could
otherwise re-trigger the original hang.
* chore(autofix): apply prettier + eslint fixes via /autofix command
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(mcp): advertise search_query/statement params for query/cypher tools (#2175)
Claude Code drops a tool-call argument named exactly 'query', making the
query and cypher tools unusable from it. Rename the advertised required
parameters to search_query and statement so the client transmits them.
Handler-side backward-compat for the legacy 'query' key follows in the
next commit.
* fix(mcp): accept search_query/statement with legacy query fallback (#2175)
Resolve the new advertised param names in the backend while still accepting
the legacy 'query' key, so curl/HTTP, other MCP clients, the CLI, the group
path, and the internal executeCypher() all keep working. Alias is normalized
once at the callTool chokepoint (covers group-forward + search alias); query()
and cypher() dual-read defensively. New name wins when both are supplied.
Updates the required-error message and adds dual-accept unit + integration
coverage.
* fix(cli): pass canonical search_query/statement params to query/cypher tools (#2175)
Stop the CLI from depending on the deprecated 'query' alias. No user-facing
change — the positional args are unchanged and the backend accepts both keys.
* fix(mcp): generators advertise search_query in query() examples (#2175)
Update the three doc/example generators (ai-context AGENTS/CLAUDE block,
skill-gen community skills, resources repo hint) so future analyze runs emit
query({search_query: ...}) — the param name Claude Code actually transmits.
Tests assert the new form is present and the legacy query({query: form is
absent (the #2059 generator-test pattern).
* docs(mcp): advertise search_query/statement in skill & guidance examples (#2175)
Sync the committed agent-facing docs to the renamed params so a Claude Code
agent following them emits the transmittable key: AGENTS.md/CLAUDE.md gitnexus
block, the canonical gitnexus/skills/* source and its installed/plugin/cursor
mirrors, and the README examples. Scoped rewrite of the two call prefixes only
(query({query: -> search_query, cypher({query: -> statement).
* style(mcp): prettier line-wrap for #2175 alias-resolution edits
* fix(review): uniform search_query precedence + cypher empty guard (#2175)
Code-review findings (correctness/adversarial/api-contract/maintainability
consensus):
- Group-mode query inverted the 'new name wins' rule: the callTool chokepoint
backfilled params.query only when empty and the @group-forward read
params.query directly, so a both-keys (or whitespace-legacy) group call let
the legacy value win — unlike the local path. Replace the hidden param
mutation with a self-contained 'search_query ?? query' resolve at the
group-forward; precedence is now uniformly new-wins at every consumer site.
- cypher() now returns the same friendly required-param error as query() when
neither statement nor query is supplied, instead of a raw DB prepare error.
- Document the legacy alias as permanent (third-party clients may send query=).
Adds group-forward alias tests (both-keys + legacy-only), empty/whitespace
search_query, the search-alias path, and the cypher empty-statement guard.
* fix(review): non-string alias safety + drop stale chokepoint comment (#2175)
Tri-review findings (correctness/adversarial/security + maintainability):
- Non-string statement/search_query/query (the MCP envelope is not
schema-validated) hit .trim() and threw TypeError to the server boundary
instead of a friendly required-param error. Introduce resolveAliasString()
(new name wins; non-string -> undefined) used by query(), cypher(), and the
group-forward, so all three return the structured error. Empirically verified
(123 ?? '' -> 123, (123).trim() throws) — this overrides a critic refutation
that mis-read ?? as a string coercion.
- Remove the stale query() comment claiming alias resolution happens at a
callTool chokepoint; that mutation was removed earlier in this PR — each site
resolves the alias itself.
- Document GroupToolPort.query's intentionally-narrower required type vs the
wider LocalBackend impl.
Adds non-string and empty-new-key precedence tests.
* fix(mcp): alias falls back to legacy value when new key is blank (#2175)
PR #2186 review finding: resolveAliasString used `canonical ?? legacy`
(nullish), so an explicitly empty/whitespace new-name value (e.g.
{search_query:'', query:'real'}) won and was rejected — discarding a valid
legacy value, contradicting the 'new name wins when both supplied' intent.
Resolve to the first NON-BLANK string instead (new preferred when it carries
a real value, else legacy). Covers query(), cypher(), and the group-forward
(all route through the helper); non-string still resolves to a friendly error.
Flips the presence-based test and adds whitespace/cypher/group fallback cases.
* fix(mcp): drop legacy "query" mention from query/cypher schema descriptions (#2175)
PR #2186 review finding: the search_query/statement inputSchema descriptions
named the legacy "query" key — the exact arg Claude Code drops — and
description text is read by an LLM choosing arguments, weakly nudging it to
send "query". Trim the descriptions to their clean form and move the
legacy-alias note to a code comment next to the schema (preserved for
maintainers / non-CC clients). properties/required unchanged (no `query`).
* 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>
* 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>
* 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.
* 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).
* 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).
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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)
* 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>
* 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>
* 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>
`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>
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.
* 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>
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.
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.
* 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>
* 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>
* 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>
* 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>
* 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>
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>
* 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>
* 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>
* 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
* 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>
* fix(java): close parsing-layer coverage gaps F35/F38/F41 (#1928)
Registry-primary scope-resolution path (the live one post-#942/#943):
- F35 [HIGH]: qualified / qualified-generic constructor calls. `new pkg.Foo()`
parses as a `scoped_type_identifier` that the query bound only as
`@reference.call.constructor.qualified` with no `@reference.name`, so the
scope extractor fell back to the whole-expression anchor and the reference
name became the raw `new pkg.Foo()` text (never resolved). Bind the simple
-name tail (end-anchored last child) and add an arm for the previously
uncaptured `new pkg.Box<String>()` (qualified + generic) shape.
- F38 [MEDIUM]: `super(...)` / `this(...)` explicit constructor invocations,
modeled as `explicit_constructor_invocation` and never matched by the scope
query, dropped the chained-constructor CALLS edges. Synthesize them with the
target resolved structurally (this -> enclosing type name; super -> superclass
tail via the shared javaBaseLookupNameNode, skipping implicit Object) plus
arity for overload disambiguation.
- F41 [LOW]: interpretJavaTypeBinding stripped the qualifier before generics, so
a qualified generic type arg (`Map<String, com.example.User>`) was cut inside
the generic into `User>`. Strip generics first, then the qualifier; make the
erasure fallback qualifier-tolerant.
F36/F37 already landed upstream (#1940/#1956); F39/F40 are legacy-bank remnants
that are no longer consumed (legacy @import skipped in parse-worker; legacy
@call never read in parse-impl) so they are intentionally left untouched.
Tests: low-level capture unit tests (constructor shapes incl. double-match
guard; super/this/enum/implicit-Object), interpretJavaTypeBinding unit tests
(qualified generic args + the corruption case), and end-to-end resolver tests
with new fixtures asserting the CALLS edges resolve to the correct constructors.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(scope-resolution): register Constructor overload keys so this()/super() chains don't self-loop (#1928 F38 review)
Review of #2045 caught two gaps; both confirmed by reproduction.
P2 — F38 this() emitted a self-loop. On the java-explicit-constructor fixture,
Child(int){ this(); } produced CALLS Child()#0 -> Child()#0 instead of
Child(int)#1 -> Child()#0. Root cause is the language-agnostic graph-bridge: the
parse phase mints distinct Constructor nodes (Child#0, Child#1) carrying
parameterTypes, but node-lookup.ts registered the parameter-types / shape
overload keys only for Function/Method, never Constructor, so both ctors
collapsed onto the first-wins qualified/simple key and the caller Child(int)
resolved to Child#0 (the this() target). Extend the overload keys to Constructor
in both node-lookup.ts (registration) and ids.ts (lookup) via a shared
isOverloadableCallable predicate. Verified the edge now connects distinct nodes
(Child#1 -> Child#0); super(1)->Base#1 still correct. No cross-language
regressions (the 9 worker-path failures reproduce identically on clean HEAD).
Also harden the integration test: it matched the this() edge on name only, which
a self-loop satisfies; now assert the endpoints are DISTINCT constructors.
P3 — F41 order-regression guard was inert (List<Map<String,User>> normalizes to
List under both strip orders). Add List<com.x.Foo<String>> -> List, which is
corrupted to Foo<String>> under the old order and only correct generics-first.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(java): update fingerprint and add notes for constructor query captures in baselines.json
Updated the fingerprint for the Java section and added detailed notes regarding the enhancements in constructor query captures, including qualified and qualified-generic constructor queries. This change reflects ongoing improvements in the parsing layer coverage and fixture updates.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* test(ingestion): characterize Laravel route → controller CALLS edges (RING4-2 #943)
Pins the current processRoutesFromExtracted edge-emission behavior (which had
no direct coverage) before migrating it off the legacy ResolutionContext.resolve
tiered lookup. Locks edge target, reason, and confidence values.
* refactor(ingestion): resolve Laravel route controllers via type registry (RING4-2 #943)
Migrate processRoutesFromExtracted off the legacy ResolutionContext.resolve
tiered lookup onto model.types.lookupClassByName (global class resolution) +
model.symbols.lookupExactAll (same-file method lookup). Drops the TIER_CONFIDENCE
dependency for a fixed ROUTE_EDGE_CONFIDENCE constant matching the prior
global-tier confidence. Characterization tests (6) stay green — behavior preserved.
* refactor(ingestion): delete ResolutionContext.resolve tiered lookup (RING4-2 #943)
Removes the legacy tiered name resolution — resolve/resolveUncached,
TieredCandidates, ResolutionTier, TIER_CONFIDENCE, walkBindingChain, the
package-dir index, the per-file resolve cache, and tier-hit stats. The context
is now a thin holder for the live SemanticModel plus the (now-dead) per-file
import maps, which the follow-up prune removes.
Deletes the dedicated resolution-context.test.ts and symbol-resolver.test.ts
(both exercised the removed .resolve tiered lookup). Full unit suite green
(the 3 analyze worker-pool tests are pre-existing load flakes — pass isolated).
* refactor(ingestion): delete legacy import-map plumbing + wildcard synthesis (RING4-2 #943)
The per-file importMap / namedImportMap / packageMap / moduleAliasMap that fed
the retired tiered resolver are now dead — nothing reads them (IMPORTS edges
come from scope-resolution's imports-to-edges bridge, independent of these
maps). Removes:
- wildcard-synthesis.ts (synthesized the dead namedImportMap/moduleAliasMap)
- import-processor's resolution path (processImports/processImportsFromExtracted/
wireImplicitImports/buildImportResolutionContext), keeping only the live
preprocessImportPath path-cleanup helper
- the parse-impl orchestration that drove them
The parse phase now threads its SemanticModel to scope-resolution directly
(parseOutput.model) instead of wrapping it in the resolution context. Deletes
the obsolete wildcard/import-processor unit tests; trims the dead processImports
cases from sequential-language-availability (processParsing coverage kept).
* refactor(ingestion): delete resolution context + named-binding plumbing (RING4-2 #943)
Completes the legacy-resolution retirement. With the tiered resolver gone, the
entire per-file import-extraction chain is dead — its only consumer was the
deleted ResolutionContext.resolve, and scope-resolution emits IMPORTS edges
from its own finalized ImportEdges:
- delete model/resolution-context.ts (the legacy context); the parse phase
now hands its SemanticModel to scope-resolution as parseOutput.model
- delete the named-bindings/ extractors + the namedBindingExtractor provider
hook (built the dead NamedImportMap) across all 8 providers + the worker
- delete the orphaned implicitImportWirer hook + Swift implementation +
providersWithImplicitWiring (scope-resolution owns implicit imports now)
- drop the dead ExtractedImport type + worker/sequential import accumulation
(result.imports / WorkerExtractedData.imports)
- import-processor.ts and its preprocessImportPath helper are now unreferenced
Deletes the obsolete named-bindings + preprocessImportPath unit tests. tsc
clean; full unit suite green (3 analyze worker-pool tests are pre-existing load
flakes); 1229 import/cross-file/resolver integration tests pass incl. the
wildcard-import languages (Go/Ruby/C++/Swift) that previously used synthesis.
* docs(ingestion): scrub stale references to deleted resolution-context machinery (RING4-2 #943)
* docs(ingestion): reword route resolver comment to clear acceptance grep gate (#943)
* fix(review): apply autofix feedback (RING4-2 #943)
Code-review autofixes from the multi-agent pass:
- delete orphaned dead code the deletion missed: swift.ts groupSwiftFilesByTarget
+ SwiftPackageConfig import (live copy is target-grouping.ts), import-resolvers
EMPTY_INDEX export (no consumers after the importCtx reset was removed)
- scrub stale comments referencing deleted symbols (processImports,
preprocessImportPath, moduleAliasMap, NamedImportMap/PackageMap, wildcard-synthesis)
and fix a broken comment fragment in parse-impl.ts
- document the intentional global-resolution convergence for route controllers
(the import-scoped tier was deleted with the resolver): confidence flattens
0.9→0.5 but resolved edges stay at the 0.5 process-trace/community gate; only
the narrow imported-controller-with-unresolved-method guessed edge crosses it
- add an overloaded-method characterization case pinning lookupExactAll[0]
* style(ingestion): prettier-format parse-impl unwind + route characterization test (#943)
* refactor(ingestion): address tri-review findings (RING4-2 #943)
From the PR #2033 tri-review (Codex + CE lanes):
- delete the now-dead importSemantics provider field + ImportSemantics type
(wildcard-synthesis.ts was its sole consumer; zero readers remain) across
language-provider.ts + 7 providers + DEFAULTS
- correct the processRoutesFromExtracted JSDoc: the import-disambiguated
controller skip is STRICTER than the legacy global-tier guard (the legacy
import-scoped tier resolved aliased / same-short-name controllers and emitted
the edge); document the aliased-import missed-edge case explicitly
- add an aliased-controller characterization test pinning the documented
global-resolution convergence (no edge for an aliased/unresolvable controller name)
- scrub stale parse-impl.ts docstrings/comments that still listed the removed
import-resolution / wildcard-synthesis / heritage passes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ingestion): capture routes-file use/FQN map for Laravel controller resolution (#943)
Adds ExtractedRoute.controllerQualifiedName: the Laravel route extractor now
builds the routes file's `use`-import alias map (local→normalized dot-joined
FQN, via splitNamespaceUseDeclaration) and captures inline qualified ::class
references, threading the disambiguating FQN through every route. Normalized via
the shared normalizeQualifiedName so it matches the type registry's key shape
(issue #1982). Foundation for qualified-first route→controller resolution (U2).
* fix(ingestion): resolve Laravel route controllers qualified-first (#943)
processRoutesFromExtracted now resolves the controller via
model.types.lookupClassByQualifiedName(route.controllerQualifiedName) when the
extractor disambiguated it (aliased use / same-short-name / inline FQN), falling
back to the short-name lookupClassByName (which still skips on ambiguity). This
restores the route→controller CALLS edges the PR #2033 tri-review (Codex F1 +
ce-adversarial) found dropped, without re-adding the deleted per-file import map.
Method resolution, guessed-id, and confidence are unchanged. JSDoc rewritten to
qualified-first precedence; the aliased characterization test flips from no-edge
to edge; adds duplicated-name-disambiguated + stale-FQN-fallback cases.
* test(ingestion): end-to-end Laravel route→controller qualified resolution + PSR-4 disambiguation (#943)
Adds an integration test that parses real namespaced PHP controllers + a routes
file through the worker pipeline and asserts the route CALLS edges target the
correct namespaced controller — the authoritative gate the unit tests can't be
(hand-built models). It surfaced that PHP's statement-form `namespace X;`
leaves the structure-phase qualifiedName as the SHORT name, so
lookupClassByQualifiedName misses; resolveControllerByQualifiedName now adds a
PSR-4 file-path disambiguation (FQN namespace tail ↔ file directory tail) to
pick the right same-short-name controller. Forces the worker path
(workerThresholdsForTest) since route extraction is worker-only.
* style(ingestion): prettier-format Laravel route resolution changes (#943)
* test(ingestion): regenerate php-captures golden for the new php-laravel-routes fixture (#943)
* test(ingestion): move route fixture out of the php-* scope-capture corpus (#943)
The laravel route-resolution fixture lived under lang-resolution/php-laravel-routes,
which the php scope-capture golden + benchmark both glob (lang-resolution/php-*),
drifting their fingerprints. The fixture is for route resolution, not php
scope-capture parity, so rename it to lang-resolution/laravel-route-resolution
to decouple it. Reverts the golden's php-laravel-routes entries; bench
scope-capture --check passes (php back to baseline).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ci): stabilize gitleaks after #2024 and clear history false positive
Fetch PR base/head SHAs before gitleaks-action so fork PRs do not fail with
ambiguous revision ranges. Add .gitleaks.toml allowlist for fake keys in
http-embedder tests, rename the redaction probe key, and point the README CI
badge at abhigyanpatwari/GitNexus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ci): restore gitleaks default rules and narrow allowlist
Add [extend] useDefault = true so default secret rules run again. Replace
file-level allowlist with regexes for known fake embedding API keys.
Route PR SHAs through env vars in the gitleaks fetch step.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Update README.md
* Update README.md
* Update README.md
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(ingestion): share a codec for __heritage__/__property__ markers (Ruby + Dart) (#1994)
The Ruby and Dart heritage/property pipelines encoded side-effect facts as ':'-delimited synthetic-import marker strings, hand-constructed and hand-parsed at ~8 sites with the field layout kept in agreement only by a comment — the fragility behind the #1981 edge-drop. Route every site through a single shared codec (utils/heritage-marker.ts: encodeMarker / decodeMarker / isHeritageMarker).
encodeMarker throws on a colon-bearing field so the silent-drop class becomes a loud failure; the ':' wire format is preserved byte-for-byte (ruby-captures-golden unchanged). Language-neutral — keyed only on the literal shared prefixes. Dart already single-sources its prefix and is heritage-only, so its import-target guard is left untouched (no invented __property__ path). Pure refactor: no new edges or behavior.
Verified: new codec unit test; ruby resolver + golden 155/155 (zero golden diff) and dart resolver 63/63 on registry-primary, both green on legacy; tsc + prettier clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(dart): single-source DART_HERITAGE_PREFIX from the shared codec (#1994)
Alias DART_HERITAGE_PREFIX to HERITAGE_MARKER_PREFIX (utils/heritage-marker.ts)
instead of re-declaring the '__heritage__:' literal, so the Dart import-target
heritage guard cannot desync from the codec's encode/decode. Value-identical;
gives the codec prefix a direct production consumer. Addresses the tri-review
nit on PR #2007.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): qualify Ruby same-tail nested mixin modules + route IMPLEMENTS by scope (#1991)
A Ruby `module` maps to the Trait label but is not a typeDeclaration, so the structure phase never qualified its node id: two same-tail nested mixin modules (App::Loggable / Web::Loggable) collapsed onto one Trait:f.rb:Loggable node and the bare-name `include Loggable` cross-wired IMPLEMENTS (first-wins tail).
Structure phase: expose buildQualifiedName as a `qualifyScopeName` ClassExtractor hook and thread it for Trait nodes in parsing-processor + parse-worker (lockstep), so a module node keys by its qualified scope path (App.Loggable). Not Option A — `Trait` is not in CLASS_LIKE_LABELS and the qualified-id selection gates it out; qualifyScopeName bypasses the typeDeclaration gate that makes extractQualifiedName bail on modules. getQualifiedOwnerName also falls back to qualifyScopeName so methods inside a nested module own through the same qualified Trait id (no dangling HAS_METHOD).
Resolution: emitRubyMixinEdges resolves a bare mixin reference lexically by the including class's enclosing scope (`App::S` + `Loggable` -> `App::Loggable`), and the simple-tail fallback is now delete-on-collision (refuse to guess on a same-tail tie) instead of first-wins.
New single-file fixture + tests: two distinct Trait nodes, S IMPLEMENTS App.Loggable only, T IMPLEMENTS Web.Loggable only, no dangling HAS_METHOD; both resolver legs + worker path. Module->Trait preserved; Trait NOT added to CLASS_LIKE_LABELS. ruby-captures-golden regenerated additively.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(ingestion): single-source the Ruby Trait scope-label predicate; regen ruby bench baseline (#1991)
F5 follow-up to #1991: replace the four hardcoded `nodeLabel === 'Trait'` checks
(two each in the sequential parsing-processor.ts and worker parse-worker.ts
definition paths) with a single isQualifiableScopeLabel() in ast-helpers.ts so the
lockstep paths can't drift. Value-identical predicate — no behavior change.
Also regenerate the ruby scope-capture bench baseline: #1991 added the
ruby-nested-mixin-tail-collision fixture (and updated the ruby captures-golden),
but the bench baseline was never regenerated, so the order-independent fingerprint
drifts (bf6b13a -> f0d9b4c6, fixture_count 85 -> 86). Pure fixture-corpus drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(ingestion): delete legacy call-resolution DAG + heritage processor (#942)
RING4-1: all 16 production languages (incl. Vue #940) are registry-primary, so
the legacy resolution legs only ran under the now-removed CI parity gate. Calls
and inheritance now resolve exclusively through scope-resolution
(Registry.lookup, preEmitInheritanceEdges, emitHeritageEdges, buildMro →
MethodDispatchIndex).
Removed:
- Call-resolution DAG: call-processor.ts legacy body (processCalls,
processCallsFromExtracted, resolveCallTarget + all resolver/dispatch/chain
helpers), model/resolve.ts MRO-via-HeritageMap, model/heritage-map.ts,
type-env DAG types; inferImplicitReceiver/selectDispatch LanguageProvider
hooks + Ruby impls; DispatchDecision/ImplicitReceiverOverride/ReceiverEnriched.
- Legacy heritage path: heritage-processor.ts, heritage-types.ts,
heritage-extractors/, @heritage.* tree-sitter queries, heritageExtractor/
heritageDefaultEdge/interfaceNamePattern wiring, worker + parse-impl heritage
passes (parse-worker/parsing-processor lockstep), cross-file-impl DAG pass.
- Scope-parity infrastructure entirely (no legacy↔registry parity left to run):
scripts/run-parity.ts, scripts/ci-list-migrated-languages.ts,
ci-scope-parity.yml, test:parity, and the scope-parity ci.yml gate. Resolver
integration tests still run via the normal tests job.
Kept (shared infra, NOT call-DAG-only): type-env.ts buildTypeEnv (field
extraction / structure phase / embeddings), model/resolve.ts c3Linearize +
gatherAncestors (mro-processor mroPhase), route/fetch/exported-type-map helpers
in call-processor.ts, preEmitInheritanceEdges (legacy-edge dedup simplified).
Acceptance: grep for resolveCallTarget/inferImplicitReceiver/selectDispatch/
buildHeritageMap/HeritageMap/processHeritage/heritageExtractor/@heritage. is zero
across src + test. tsc clean (both packages); resolver integration suite green
(bit-compatible EXTENDS/IMPLEMENTS/CALLS); scope-capture fingerprints unchanged
(python re-baselined: removed redundant ignored captures). ARCHITECTURE.md
updated to scope-resolution-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(review): apply autofix feedback (#942)
ce-code-review autofix pass on the RING4-1 deletion:
- parse-cache.ts: bump SCHEMA_BUMP 2→3 — ParseWorkerResult lost its `heritage`
field, so stale on-disk caches must invalidate (prevents a rollback replaying
a heritage-less cache into legacy code) [api-contract P2].
- parse-impl.ts: drop 3 now-unused type imports (ExtractedCall,
ExtractedAssignment, FileConstructorBindings) left by the deferred-block
removal — would fail the eslint CI gate [correctness+maintainability P1].
- AGENTS.md / CLAUDE.md / scope-resolver.ts contract doc: fix stale pointers to
the deleted "§ Call-Resolution DAG" section + removed hooks; preserve the
language-neutrality rule [project-standards P1].
- registry-primary-flag.ts / cross-file.ts / parse-impl.ts: refresh stale
comments referencing deleted symbols (legacy DAG, runCrossFileBindingPropagation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(ingestion): remove the vestigial isRegistryPrimary flag (#942)
With the legacy call-resolution DAG deleted, the per-language
`REGISTRY_PRIMARY_<LANG>` / `isRegistryPrimary` / `MIGRATED_LANGUAGES` flag had
only one meaningful state — every production language resolves via
scope-resolution — and an explicit `=0` override could only *disable*
resolution with no fallback (a footgun the review flagged). Removing it.
- Delete `registry-primary-flag.ts` and the now-dead `shadow-harness.ts`
(legacy↔registry shadow-parity tool) + its test.
- Collapse the three flag gates to their behavior-preserving outcome
(`SCOPE_RESOLVERS == MIGRATED_LANGUAGES`, so this is a no-op):
- scope-resolution phase now runs for every registered `SCOPE_RESOLVERS`
entry (was `∩ MIGRATED_LANGUAGES`).
- import-processor `addImportGraphEdge` + parse-impl `shouldAccumulate`:
the legacy emit/accumulate paths were already inert for migrated
languages (scope-resolution owns IMPORTS via the imports-to-edges bridge);
drop the flag term.
- Collapse flag-branching tests to the scope-resolution path and delete the
csharp legacy-`=0`-leg describe blocks; remove the ruby/rust-scope env-forcing
hooks (no-ops now).
- Refresh docs/comments (ARCHITECTURE.md "one registration", scope-resolver
cookbook, phase deps) — adding a language is now a single `SCOPE_RESOLVERS`
registration.
Verified: tsc clean (both packages); resolver integration tests green
(747 assertions across cobol/csharp/ruby/rust/typescript/go, IMPORTS edges
intact); grep for the flag symbols is zero across src + test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(format): prettier formatting on #942 changes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ci): drop legacy heritage-capture tests + re-baseline scope-capture fingerprints (#942)
Two CI failures from the #942 cleanup, surfaced by the tri-review + CI:
- tree-sitter-languages.test.ts: two tests asserted `@heritage.*` captures
(Rust trait-impl, Dart extends/implements/with) that this PR removed. The
acceptance grep used `@heritage\.` (with `@`); these reference the runtime
capture name `heritage.trait` (no `@`), so they slipped the earlier sweep.
Inheritance is now covered by the resolver integration suite. (fixed macos-latest)
- Re-baselined the scope-capture bench fingerprints for csharp/rust/ruby/java/
javascript/kotlin (baselines.json) + python (python-scope/baseline-fingerprint.txt).
The earlier test-cleanup reworded comments inside the lang-resolution fixture
files (Shapes.cs, child.rs, derived.rb, IA.java/Plain.java, Service.js, F.kt,
app.py) to scrub deleted-symbol references for the acceptance grep; those are
the bench corpus, so capture node positions shifted. Capture LOGIC is
unchanged — verified `--check` passes for all 14 langs + python. (fixed benchmarks)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs/chore: scrub remaining REGISTRY_PRIMARY + deleted-symbol references (#942)
Tri-review P3 follow-ups (verified):
- TESTING.md: rewrite the "Scope-resolution parity" section — the legacy
dual-leg (REGISTRY_PRIMARY_<LANG>=0/1) and `npm run test:parity` no longer
exist; resolver tests run once on the sole scope-resolution path in the
normal tests job.
- scripts/bench-scope-resolution.ts: drop the inert `REGISTRY_PRIMARY_PYTHON=1`
env set + usage hint (the flag is gone).
- ruby/scope-resolver.ts, php/captures.ts: re-point doc-comments off the
deleted heritage-map.ts / heritage-processor.ts to the current behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ci): prettier format + regenerate scope-capture goldens (#942)
Two more CI failures, same root cause as the bench re-baseline (the
test-cleanup reworded comments in lang-resolution bench/golden-corpus fixtures):
- quality/format: prettier on tree-sitter-languages.test.ts (blank line left by
the deleted heritage-capture tests) + TESTING.md (the rewritten section).
- tests/ubuntu/coverage: `csharp-captures-golden` (and python/ruby/rust) drifted
because the edited fixtures feed the per-language capture-golden snapshots too
(not just the bench). Regenerated via UPDATE_GOLDEN=1. Verified safe: only the
edited-fixture entries changed; csharp `captureGroups` unchanged (38) — digest
shifted from comment-position only; capture LOGIC untouched. 1168 scope-
resolution tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(resolvers): drop createResolverParityIt wrapper, use vitest it directly
The parity-aware `it` wrapper became a no-op when #942 removed the legacy
call-resolution DAG (it just returned vitest's `it`). Remove it entirely so
the resolver tests call vitest's `it` directly instead of shadowing it with a
local `const it` (or `pit`/`rustParityIt`):
- helpers.ts: delete createResolverParityIt + its now-unused vitestIt import
and VitestIt type.
- 16 files: drop `const it = createResolverParityIt('x')` and import `it`
from vitest instead.
- ruby.test.ts (pit) + rust.test.ts (rustParityIt): rename calls to `it`.
- Scrub every comment that described the removed wrapper / dual-mode parity
skip / legacy_skip gate (vue-scope, js/ts/dart/php/python headers, rust x2,
cpp, swift x4, rust-coverage). Genuine test rationale is kept; only the
vestigial two-leg framing is dropped. Accurate "legacy DAG (removed in
#942)" historical notes are retained.
No fixtures touched (no bench/golden re-baseline). tsc clean; rust+ruby
resolver suites green (323 tests, incl. #1992 worker-path parity after a
local dist build).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cpp): resolve cross-namespace same-tail inheritance bases bridge-held (#1993)
PR #1981's bridge fixed within-namespace same-tail heritage (NS::A::Inner vs NS::B::Inner). The residual: a cross-namespace same-tail base (NS1::A::Inner vs NS2::A::Inner) both key the namespace-omitted `A.Inner` in the qualifiedNames index, so resolveQualifiedInheritanceBase couldn't pick a winner and the deriving classes cross-wired (DB's EXTENDS bound to NS1's A::Inner).
Fixed bridge-held via the existing `namespacePrefix` sidecar — no qualifiedName invariant flip, no resolution-index re-keying: (1) tagNamespacePrefixes also tags defs declared directly in a namespace (the deriving NS1::DA), composed identically to the class-nested path; (2) resolveQualifiedInheritanceBase breaks a same-tail tie by preferring the candidate whose namespacePrefix matches the deriving class's. Two-phase lookup, UDC, brace-init, file-local linkage untouched (def.qualifiedName + index keys unchanged).
New cpp-cross-namespace-same-tail fixture + registry-primary test (in the cpp parity expected-failures). Verified: cpp suite 287/287 primary, 209 + 78 skips legacy — no regression; tsc + prettier clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cpp): worker-path parity for #1993 cross-namespace tie-break + correct narrative
Add the missing parse-worker.ts parity describe for the #1993 cross-namespace
same-tail heritage tie-break, mirroring the #1982/#1995 worker siblings
(workerThresholdsForTest minFiles:1/minBytes:1, workerPoolSize:2, usedWorkerPool
guard, and the same NS1.DA→NS1.A.Inner / NS2.DB→NS2.A.Inner base assertions), and
register both worker test names in LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES['cpp']
(registry-primary-only, like the sequential entry). Closes the DoD sequential≡worker
gap flagged in the tri-review of PR #2005.
Also correct the fixture/test narrative: the pre-fix failure is a CROSS-WIRE (DB's
EXTENDS binds NS1::A::Inner via the refuse-on-tie scope-walk fallback), not a silent
miss — the empirical pre-fix run shows the edge exists but points at the wrong target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(scope-resolution): type the namespacePrefix sidecar; regen cpp bench baseline (#1993)
F4 follow-up to #1993: declare `namespacePrefix?: string` on SymbolDefinition
(gitnexus-shared) and drop the six `as { namespacePrefix?: string }` casts in
walkers.ts / graph-bridge/ids.ts that #1993 introduced. Pure type-level — the `as`
assertions erase at compile time, runtime is byte-identical, and the field stays a
sidecar (no graph-node identity; the qualifiedName-keyed index is untouched).
Also regenerate the cpp scope-capture bench baseline: rebased onto main (now
carrying #1995's cpp fixtures), #1993 adds cpp-cross-namespace-same-tail, growing
the cpp-* corpus 272->273 and drifting the fingerprint d63ded6->6d6207ae. Pure
fixture-corpus drift — no scope-extractor 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>
* fix(cpp): qualify types nested in a named union by their union scope (#1995)
`union_specifier` was missing from cppClassConfig.ancestorScopeNodeTypes, so a struct nested in `union U1` and one in `union U2` both qualified to the bare `Inner` and merged onto one Struct:...:Inner node — from_u1/from_u2 cross-wired (invisible to findDanglingEdges). Adding `union_specifier` lets buildQualifiedName pick up the named union's `name` segment, materializing distinct `U1.Inner` / `U2.Inner` nodes. Anonymous unions have no `name` child and correctly contribute nothing (members inject into the enclosing scope); the separate C config is untouched. New fixture + positive-identity tests (sequential + worker, both legs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cpp): distinct nodes for anonymous-namespace-nested same-tail types (#1995)
An anonymous `namespace { }` is a namespace_definition with no `name` child, so the scope walker dropped it (empty segment) and two `namespace { struct Inner {} }` blocks in one TU collapsed onto a single `Inner` node — from_anon_a/from_anon_b cross-wired. A C++ `extractScopeSegments` override (the first consumer of the existing config hook) gives each anonymous namespace a deterministic per-block discriminator from its start byte, keeping the nested types distinct. Named scopes (incl. `inline namespace`) and anonymous unions are unaffected. Deterministic across the sequential and worker full-file parses. New fixture + tests assert node DISTINCTNESS (count==2 / distinct owners), not the non-portable discriminator value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cpp): regenerate cpp scope-capture bench baseline for #1995 fixtures
Rebased onto main (which now carries #1992 + its rust baseline). #1995 adds the
cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures, growing
the cpp-* corpus 270->272 and drifting the order-independent fingerprint
(538e8be -> d63ded6). Pure fixture-corpus drift — no scope-extractor change;
existing fixtures' captures byte-identical. (cpp has no captures-golden gate, so
only the bench baseline needs regenerating.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): own generic Rust inherent-impl methods through the mod-qualified Impl node (#1992)
A generic inherent-impl target (`impl<T> Inner<T>`) is a `generic_type` node, which the inherent-impl owner walk (findEnclosingClassInfo) did not match — so the walk returned null and the method got `File -> DEFINES` with NO HAS_METHOD edge (orphaned, and invisible to findDanglingEdges). The Impl node was already correctly mod-qualified (the @name capture drills into the inner type_identifier, tree-sitter-queries.ts), so this is an owner-walk-only fix: drill into the generic base and mirror the node gate so the owner id == the node id byte-for-byte. A scoped-generic target (`impl<T> a::Inner<T>`) materializes no Impl node and is left orphaned (deferred) rather than minting a phantom owner.
The owner walk is shared by the sequential and worker paths. New fixture + tests assert positive HAS_METHOD ownership through distinct `a.Inner` / `b.Inner` nodes on both resolver legs and the worker path, plus a negative scoped-generic guard. rust-captures-golden regenerated additively for the new fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): qualify className for same-tail Rust generic impls + regen rust bench baseline (#1992)
F3 follow-up to #1992: two same-tail generic inherent impls under sibling mods
that ALSO share a method name (`mod a { impl Inner { fn m } }` +
`mod b { impl Inner { fn m } }`) keyed the method node id `${className}.${name}`
with the bare tail (`Inner.m`) and collapsed onto one Function node (graph addNode
is first-write-wins), silently dropping the second. The owner Impl `classId` was
already mod-qualified, masking the collision behind distinct HAS_METHOD sources.
Qualify `className` (`a.Inner` / `b.Inner`) in the bare inherent-impl arm so the
node id inherits the mod scope; symmetric with the call-resolution fallback, and
the HAS_METHOD owner anchors on the unchanged qualified classId. New
same-method-name fixture + sequential & worker-parity tests; holds on both legs.
Also regenerate the rust scope-capture bench baseline: the new
rust-nested-tail-collision-generic (#1992) + rust-generic-impl-same-method-name
(F3) fixtures grow the rust-* corpus, so the order-independent fingerprint drifts
(56ffc1c0 -> b00aea0f, fixture_count 127 -> 129). Pure fixture-corpus drift — no
scope-extractor change; existing fixtures' captures byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(rust): regenerate rust-captures golden for the F3 same-method-name fixture (#1992)
The rust-* scope-capture corpus is fingerprinted by TWO gates: the bench baseline
(bench/scope-capture/baselines.json, already updated) and the rust-captures-golden
unit test (test/fixtures/rust-captures-golden/expected-captures.json). Adding the
F3 fixture rust-generic-impl-same-method-name grew the corpus 128->129 entries, so
the committed golden drifted too. Regenerated additively (UPDATE_GOLDEN=1) — only
the new fixture's entry is added; existing fixtures' captures are byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(cli): add .gitnexusrc config and --default-branch for analyze (#243)
Let a repo preconfigure recurring `gitnexus analyze` options via a
project-local `.gitnexusrc` (JSON) plus a new `--default-branch` flag, so
projects on `develop`/`master` no longer get the generated regression
example rewritten to `base_ref: "main"` on every analyze run.
- New `cli/analyze-config.ts`: locate/parse/validate `.gitnexusrc` (flat +
nested `analyze` form, alias mapping, fail-closed on unknown keys / bad
types / hidden chars), merge with CLI (CLI overrides config), and resolve
the default branch (CLI > config defaultBranch/branch > auto-detected
origin/HEAD > "main").
- `getDefaultBranch()` in storage/git.ts (best-effort, local-only, no network).
- Thread `defaultBranch` through analyze -> run-analyze -> ai-context so the
generated regression-compare example uses the configured branch,
JSON-escaped; the --skills re-generation path uses the same branch.
- `skipContextFiles`/`skipAiContext` alias `skipAgentsMd` (block only, does
not imply skipSkills); `indexOnly` stays the stronger "skip all injection".
- README + CLI help; unit tests for the config module and end-to-end wiring
tests that fail if config is parsed but not threaded into analyze/context.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): harden .gitnexusrc against Markdown injection and stale base_ref (#243)
Addresses the tri-review findings on PR #1996.
- P1 (Markdown injection into generated AGENTS.md/CLAUDE.md): reject the
backtick in validateBranchName (covers --default-branch, .gitnexusrc, and the
origin/HEAD auto-detect via sanitizeDetectedBranch) and strip it at the
ai-context sink (markdownSafeBranch); reject Markdown-significant chars
(` * [ ] < >) in the config `name` (it lands in generated bold/code-spans),
while still allowing `_ . - /`. Corrected the false "can't break the code
span" comment.
- P2 (configured defaultBranch silently no-ops on an up-to-date repo): on the
alreadyUpToDate fast path, surgically refresh only the `base_ref:` line in
AGENTS.md/CLAUDE.md (refreshBaseRefLine), preserving the rest of the block
incl. --skills community rows; no-op when unchanged.
- P3: gate the .gitnexusrc key lookup with Object.hasOwn so inherited keys
(__proto__, constructor, …) hit the actionable "Unknown key" error.
- Cleanups: strip a leading UTF-8 BOM before JSON.parse; give --default-branch
CLI validation its own `default-branch-invalid` recovery hint; drop the dead
`options.defaultBranch` write and the now-redundant `options?.` chaining.
- Tests: backtick rejection + even-backtick generated output, 255-char branch
bound, config `name` Markdown rejection, __proto__ → Unknown key, BOM,
mergeAnalyzeOptions omits defaultBranch, willGenerateContext suppression, and
the fast-path base_ref refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The query/cypher/impact stdout tests and the eval-server tests assumed mini-repo
had already been indexed by an earlier analyze test. That analyze test silently
tolerates a subprocess timeout (`if (result.status === null) return`), so under
parallel load (cli-e2e runs in the default integration project) the repo went
unregistered and every dependent test failed confusingly with "No indexed
repositories found" / exit 1.
- beforeAll now indexes mini-repo once into the isolated suite registry (retried
a few times; re-analyze of an already-indexed repo is a cheap alreadyUpToDate
no-op), removing the implicit cross-test ordering dependency.
- The four dependent describes get { retry: 2 } (Vitest 4 second-arg options) so
a transient subprocess hiccup self-heals instead of failing the suite.
Genuine analyze/registration regressions are still caught loudly by the
dedicated analyze tests (which use isolated GITNEXUS_HOMEs). Full cli-e2e file:
34/34 pass locally.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(vue): migrate Vue SFC to scope-based resolution (RFC #909 Ring 3, closes#940)
Adds `vueScopeResolver` and wires Vue into the scope-resolution pipeline
(`SCOPE_RESOLVERS`, `MIGRATED_LANGUAGES`). Vue's `<script>` / `<script
setup>` blocks are TypeScript — `emitVueScopeCaptures` extracts the script
block via the existing `extractVueScript` utility and delegates to
`emitTsScopeCaptures`, keeping grammar identity consistent with the cached
tree the parse-worker already builds.
- `languages/vue/captures.ts` — `emitVueScopeCaptures`
- `languages/vue/import-target.ts` — `makeVueResolveImportTarget` (TS
resolver + tsconfig path-alias support; explicit `.vue` imports
resolve via the exact-path branch)
- `languages/vue/scope-resolver.ts` — `vueScopeResolver`
- `languages/vue/index.ts` — barrel + known-limitations doc
- `languages/vue.ts` — `emitScopeCaptures` hooked up
- `scope-resolution/pipeline/registry.ts` — Vue entry added
- `registry-primary-flag.ts` — `SupportedLanguages.Vue` added
to `MIGRATED_LANGUAGES` (production default → registry-primary)
- `vue-composition-api` — `<script setup lang="ts">`, defineProps /
defineEmits macros, cross-file TS imports, computed refs
- `vue-options-api` — `defineComponent({methods, computed, data})`,
this-based method calls, imported utility calls
- `vue-cross-file` — composable functions returning class instances,
multi-level import chains, UserModel/PostModel method calls
- `fieldFallbackOnMethodLookup: true` — Options API `this.X()` calls may
not resolve through the type-binding layer (no formal class); fallback
catches common patterns via declared field names.
- `allowGlobalFreeCallFallback: false` — Vue uses explicit imports;
workspace-wide unique-name fallback would produce spurious edges for
built-ins (ref, reactive, defineProps, …).
- Template expression calls intentionally out of scope: component-
reference CALLS edges are already emitted by the legacy template
extractor. Remaining template gaps tracked in #1647.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(vue): address P0/P1 review findings from #1950
## P0 #1 — missing scope-resolution hooks in vueProvider
`pass3CollectImports` early-returns when `interpretImport` is undefined,
producing zero IMPORTS and zero cross-file CALLS edges. Add the four
hooks to `vueProvider` in `vue.ts`:
- `interpretImport: interpretTsImport`
- `interpretTypeBinding: interpretTsTypeBinding`
- `bindingScopeFor: tsBindingScopeFor`
- `importOwningScope: tsImportOwningScope`
Also add `receiverBinding`, `mergeBindings`, `arityCompatibility`, and
`resolveImportTarget` to complete the scope-resolution contract.
## P0 #2 — template-component CALLS dropped when Vue is registry-primary
`isRegistryPrimary(Vue) → true` makes the main call-processor loop skip
Vue files entirely, silencing the inline `vue-template-component` CALLS
emitter at ≈L1506. Add a dedicated post-loop pass in `call-processor.ts`
that emits template-component CALLS for Vue files whenever Vue is
registry-primary. Update the stale `vue/index.ts` limitation comment to
reflect the new emit site.
## P1 #3 — worker-mode double-extraction → zero captures
In worker mode (≥15 files) the parse worker pre-extracts the `<script>`
block and passes `scriptContent` as `sourceText`. `emitVueScopeCaptures`
was calling `extractVueScript` a second time, getting null, and returning
`[]`. Fix: if extraction returns null and the content has no SFC block-
level markers (`<template`, `<style`), treat it as already-extracted
script text and delegate directly to `emitTsScopeCaptures`.
## Test assertion strictness
Replace all `toBeGreaterThanOrEqual(1)` assertions with exact `toBe(N)`
counts. IMPORTS counts reflect per-symbol scope-based edges (value imports
only; `import type` is not emitted as an IMPORTS edge). CALLS counts are
1 per single-call-site.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(vue): template-derived edges + pipeline benchmark (#1950 review)
Addresses the reviewer's request for template edge attribution and a
performance benchmark.
## Template event-handler CALLS (`vue-template-callback`)
Add `extractTemplateEventHandlers` to `vue-sfc-extractor.ts`. Extracts
bare single-identifier handlers from `@event="methodName"` and
`v-on:event="methodName"` attributes. Inline expressions with arguments
or operators (`@click="toggle(item)"`) are intentionally excluded.
Wire into the dedicated registry-primary Vue template pass in
`call-processor.ts`. For each extracted handler name, `ctx.resolve`
finds the in-file Function/Method node and emits a CALLS edge with
`reason: 'vue-template-callback'`.
## Template attribute-binding ACCESSES (`vue-template-attribute`)
Add `extractTemplateAttributeBindings` to `vue-sfc-extractor.ts`.
Extracts bare single-identifier values from `:prop="varName"` and
`v-bind:prop="varName"` bindings. Member-access (`:key="post.id"`) and
literals are excluded by the identifier-boundary regex.
Wire into the same template pass. For each extracted variable, `ctx.resolve`
finds the in-file node and emits an ACCESSES edge with
`reason: 'vue-template-attribute'`.
## `vue/index.ts` limitations comment
Updated to accurately describe all three categories of template-derived
edges and explicitly document the complex-expression exclusions.
## Tests
Add 6 new assertions in `vue-scope.test.ts`:
- `@click="handleSave"` → CALLS `handleSave` (UserProfile.vue)
- `@select="onPostSelected"` → CALLS `onPostSelected` (App.vue composition)
- `@keyup.enter="addTodo"` → CALLS `addTodo` (TodoList.vue)
- `@loaded="onUserLoaded"` → CALLS `onUserLoaded` (App.vue cross-file)
- `:userId="currentUserId"` → ACCESSES `currentUserId` (App.vue composition)
- `:posts="allPosts"` → ACCESSES `allPosts` (App.vue composition)
Add `vue` entry to `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES` in
`helpers.ts` documenting which assertions are registry-primary-only
(IMPORTS cardinality, template-derived edges, `<script setup>` export).
## Benchmark
Add `vue-pipeline-benchmark.test.ts` (gated by `GITNEXUS_BENCH=1`).
Generates N-component synthetic repos (10 / 25 / 50 / 100) and asserts
that wall-clock and node counts scale sub-quadratically with component
count, guarding against O(n²) regressions in the template extraction
or scope-resolution passes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(vue): BINDS_EVENT_HANDLER/EMITS_EVENT edges via ScopeResolver hook
Per maintainer feedback on PR #1950:
- Do not edit call-processor.ts (will be removed when all languages migrate)
- Model Vue component-event system with dedicated edge types to avoid CALLS
noise in deep component hierarchies (per contributor discussion)
Changes:
- gitnexus-shared: add BINDS_EVENT_HANDLER and EMITS_EVENT to RelationshipType
- vue-sfc-extractor: add extractComponentEventBindings, extractNativeElementEventHandlers,
and extractScriptEmitCalls
- ScopeResolver contract: add optional emitPostResolutionEdges hook
- run.ts: wire emitPostResolutionEdges after emitImportEdges
- vue/scope-resolver: implement emitPostResolutionEdges emitting:
1. CALLS (vue-template-component) — PascalCase component File refs
2. CALLS (vue-template-callback) — @event on native HTML elements
3. BINDS_EVENT_HANDLER (vue-event: @name) — @event on component elements;
source = handler fn in parent, target = child component File (not CALLS)
4. EMITS_EVENT (vue-emit: name) — emit() calls; self-loop on component File,
joinable with BINDS_EVENT_HANDLER via Cypher for impact tracing
5. ACCESSES (vue-template-attribute) — :prop="var" bindings
- call-processor.ts: revert dedicated Vue post-loop pass; moved to scope resolver
- Tests and parity expected-failures updated accordingly
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(vue): close review gaps in scope/parity extraction
Resolve the new PR #1950 review findings by widening Vue scope context to include TS/JS import closures, fixing BINDS_EVENT_HANDLER endpoint assertions, hardening emit/event extraction to avoid comment/property false positives, supporting kebab-case component tags, and ensuring parity runs include vue-scope suites.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(vue): address second review round — regex safety, emit coverage, arch
Closes items raised in the Jun 2 review comment on PR #1950.
Correctness fixes:
- ReDoS mitigation: bound attribute-capture spans to [^>]{0,512}? in all
three template tag regexes to prevent pathological backtracking.
- Kebab-case misclassified as native: added (?![A-Za-z0-9-]) negative
lookahead to NATIVE_TAG_RE so <post-list> is no longer split as native
tag `post` with attrs `-list ...`.
- Hyphenated event names dropped: widened TAG_EVENT_RE from [\w:.]+ to
[\w:.-]+ so @user-loaded and @update:model-value are captured.
- this.$emit silently dropped: collectBareEmitEventNames now allows
this.$emit(...) by looking back past the '.' to verify preceding token
is exactly `this`; socket.emit etc. remain blocked.
- Event names with colon rejected: extended validator to accept
update:modelValue and update:model-value patterns.
Architecture fix:
- Moved collectVueScopeFilePaths out of shared phase.ts into a new
collectScopeContextPaths optional hook on ScopeResolver, keeping shared
pipeline code language-agnostic. vueScopeResolver implements the hook.
- Fixed memory leak: preExtractedByPath cleanup now iterates filePaths
(all context files) not just primaryFilePaths (only .vue files).
Cleanup:
- Removed unused extractTemplateEventHandlers and duplicate EVENT_HANDLER_RE.
- Fixed skipped comment numbers in emitPostResolutionEdges (1,2,4,5,6 -> 1-6).
- Updated vue/index.ts: four categories -> five (added EMITS_EVENT).
- Fixed gitnexus-shared EMITS_EVENT JSDoc to reflect File->File reality.
Tests: 7 new unit tests covering hyphenated events, this.$emit, kebab-case
native-tag exclusion, and update:modelValue event name validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(vue): eliminate double file-read and per-file template re-scans
Two performance fixes from the self-review pass:
1. **No more double read of .vue files in phase.ts**: primary files were
previously read once for `collectScopeContextPaths` (via
`entryFileContents`) and again in the blanket `readFileContents(filePaths)`
call. Now the primary-file map is passed directly and only the extra
context files (TS/JS import closure) require a second I/O round-trip.
2. **Single template parse per .vue file in emitPostResolutionEdges**:
previously each of the five extractor functions (components, native
handlers, component event bindings, emit calls, attribute bindings) ran
`TEMPLATE_RE.exec(content)` independently — five full-file scans per
`.vue` file. Replaced with a new `extractVueTemplateEdgeData` batching
helper that parses the template and script blocks once and feeds all five
extractors from the pre-extracted content. emitPostResolutionEdges now
calls a single function and destructures the results.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(parity): exclude TypeScript HOC/HOF/JSX scope-resolver tests from legacy DAG parity gate
Three test files introduced in prior PRs exercise scope-resolver-only
correctness wins: HOC-wrapped const declarations, HOF-callback caller
attribution, and JSX-as-call CALLS edges. The parity runner's
${slug}-*.test.ts glob now picks them up, causing typescript [legacy]
failures in CI.
Fix: convert each file to use createResolverParityIt('typescript') and
register all 26 legacy-failing test names in
LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.typescript with explanatory
comments. Legacy mode: 11+11+4 tests skipped, zero failures.
Registry-primary mode: all 37 tests pass as before.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(test): remove registry-primary-flag unit tests after migration complete
All languages are now in MIGRATED_LANGUAGES; the per-language flip
tests are no longer needed. Addresses PR #1950 review feedback.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(ingestion): qualify nested-type node identity for C++/Ruby (#1978)
Nested types sharing a tail name in one file — C++ `Outer::Inner` vs
`Other::Inner`, Ruby `Outer::Inner` vs `Other::Inner` modules — silently merged
into a single graph node keyed by the simple tail (`Struct:file:Inner`),
cross-wiring their methods/properties onto one owner.
Key class-like type nodes (Class/Struct/Interface/Enum/Record) by their
normalized fully-qualified path (`Struct:file:Outer.Inner`) instead of the
simple name. Gated per-language by a new `qualifiedNodeId` config flag
(default false → byte-identical for every other language); enabled here for
C++ and Ruby.
- class-types.ts / generic.ts: `qualifiedNodeId` flag on ClassExtractor + config
- ast-helpers.ts: findEnclosingClassInfo gains an optional getQualifiedOwnerName
hook + EnclosingClassInfo.qualifiedClassId, so member-owner edges resolve to
the qualified class node id (owner id == node id by construction)
- parsing-processor.ts + parse-worker.ts: flag-gated qualified node-id + owner
edges on both the sequential and worker parse paths (incl. routed properties)
- call-processor.ts: same qualifier in the routed-property pre-pass (lockstep
with the worker `kind === 'properties'` block)
- configs/c-cpp.ts, configs/ruby.ts: qualifiedNodeId: true
Method/Property node ids stay simple-qualified; only type nodes get the
qualified id.
Deferred to a resolution-side follow-up: Ruby SAME-TAIL routed-property/mixin
owner identity under registry-primary (`emitRubyMixinEdges` keys owners by the
simple tail name, last-wins); and Rust inherent-impl methods (impl_item is not
a typeDeclaration — its #1978 test is describe.skip).
Tests: same-tail collision fixtures + #1978 resolver tests for C++/Ruby
(positive owner identity, R7), a worker-path parity block, and an unambiguous
nested attr_accessor case; the C++ #1975 out-of-line test updated to assert
qualified-id distinctness (forward-decl + out-of-line now unify). Verified
green on both parity legs, the worker path, and tsc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): scope #1978 resolver tests to registry-primary leg; fix lint
- helpers.ts: exclude the new #1978 C++/Ruby resolver tests from the legacy
parity leg (LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES). They PASS on legacy
too — the fix lives in the SHARED structure phase, not the legacy resolution
path — so this is a deliberate registry-primary-only scoping (not a legacy
gap), keeping the legacy path untouched and uncoupled from the new
node-identity behavior.
- rust.test.ts: drop the `eslint-disable vitest/no-disabled-tests` directive.
That rule isn't configured in this repo, so eslint errored "Definition for
rule 'vitest/no-disabled-tests' was not found" and failed `quality / lint`.
The describe.skip needs no disable directive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(test): satisfy CI for the new #1978 fixtures (format + golden + fingerprint)
Adding the {cpp,ruby,rust}-nested-tail-collision fixtures changed the
lang-resolution corpus, which the scope-capture golden snapshots and the
fingerprint baselines gate on. These are pure fixture-corpus additions —
#1978 does not touch the scope-capture phase (captures.ts / emit*ScopeCaptures
are unchanged). Verified: the regenerated ruby/rust golden diffs are
additive-only (no existing fixture's capture digest changed), so the cpp/ruby/
rust fingerprint drift is solely the new fixtures.
- prettier --write test/integration/resolvers/{ruby,rust}.test.ts
- regenerate ruby/rust captures-golden snapshots (UPDATE_GOLDEN=1; +1 fixture each)
- rebaseline cpp/ruby/rust scope-capture fingerprints (bench/scope-capture/baselines.json)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(ingestion): extract shared qualified-name normalizer (#1982)
Move normalizeQualifiedName/splitQualifiedName out of class-extractors/
generic.ts into utils/qualified-name.ts so the structure-phase
buildQualifiedName, the scope-resolution inheritance resolver, and the
per-language capture emitters can all key against ONE normalizer. A raw
'::' qualifier must normalize to the exact '.'-joined key the
QualifiedNameIndex already holds, or the qualified lookup silently misses
(the #1982 resolution-side foundation). Pure relocation — byte-identical
function bodies; tsc clean; existing C++ nested-collision tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): resolve same-tail C++ nested-type heritage to the correct qualified node (#1982)
Registry-primary C++ inheritance (preEmitInheritanceEdges -> resolveInheritanceBaseInScope)
resolved a same-tail nested base by its SIMPLE TAIL with first-wins, so
`struct DerivedB : Other::Inner` mis-resolved EXTENDS to Outer.Inner (the wrong
sibling; 0 dangling, so undetected). The namespace qualifier was discarded at the
C++ inheritance capture.
Fix (additive, qualified-first):
- ReferenceSite gains an optional `rawQualifiedName`; the C++ inheritance capture
emits `@reference.qualified-name` (qualifier-preserving, template-stripped:
Other::Inner, ns::Base<T> -> ns::Base) only when the base is qualified, registered
as a sub-tag so it can't shadow the `@reference.inherits` anchor.
- resolveInheritanceBaseInScope resolves the qualifier against the full-path
QualifiedNameIndex FIRST (which already carries Outer.Inner / Other.Inner keys from
the structure phase), with progressive-prefix lookup for relative bases and
refuse-on-tie, falling through to the existing simple-tail walk on miss — so
unqualified bases and the single-candidate cross-file case are unchanged.
Registry-primary cpp.test.ts 278/278 (incl. worker-path: rawQualifiedName survives
worker serialization). Legacy leg unaffected (207 pass / 71 skip) — the new
resolution-side assertions are registry-primary-only via helpers.ts. tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): resolve same-tail Ruby mixin/attr_accessor owners to the correct qualified node (#1982)
emitRubyMixinEdges keyed its owner map by the SIMPLE tail (def.qualifiedName
split-popped) with last-wins, and the __heritage__/__property__ markers carried
only the immediate owner name — so `module Outer; class Inner` and
`module Other; class Inner` collapsed onto one `Inner` key and cross-wired their
include/attr_accessor edges onto whichever Inner was processed last.
Fix (lockstep, full-qualified):
- ruby/captures.ts: build the marker owner from the FULL enclosing class/module
chain (buildEnclosingQualifiedName walks all ancestors, normalizing the compact
`class Outer::Inner` scope_resolution form via the shared splitQualifiedName) so
the marker owner byte-matches the resolution def's qualifiedName.
- ruby/scope-resolver.ts: key graphIdByName by the full def.qualifiedName instead
of the simple tail. Top-level owners/mixins are unchanged (full == simple).
Registry-primary ruby.test.ts 142/142 incl. a new worker-path block (the deferred
note's duplicate-edge concern: markers survive worker serialization, exactly one
HAS_PROPERTY per attr). Legacy leg unaffected (136 pass / 6 skip) — new assertions
registry-primary-only via helpers.ts. tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): rebaseline #1982 golden/fingerprint + lint/format sweep
Cross-cutting verification artifacts for the #1982 same-tail resolution fix:
- ruby capture golden regenerated: ONLY the ruby-nested-tail-collision fixture
drifts (+10 capture groups from its new include/attr_accessor + the now
full-qualified __heritage__/__property__ marker owner). All other ruby fixtures
byte-identical (proves the owner-qualification is localized to nested owners).
- bench/scope-capture/baselines.json: rebaseline cpp + ruby fingerprints (the only
two that drift; 12 other languages byte-identical). cpp = additive
@reference.qualified-name capture; ruby = the localized owner change. Provenance
notes record both. scaling linear (~1.0), 14/14 PASS.
- generic.ts: drop the now-unused normalizeQualifiedName import (lint error).
- walkers.ts / ruby.test.ts: prettier formatting.
Verified: cpp 278/278 + ruby 142/142 (registry-primary), both legacy legs clean
(skips registry-primary-only assertions), go/java/csharp 542 (cross-language
regression — the qualified-first branch is gated on rawQualifiedName, set only by
C++, so non-C++ inheritance resolution is unchanged). tsc + eslint(0 errors) + prettier clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): resolve nested Ruby mixin included by short name (#1982)
emitRubyMixinEdges keyed graphIdByName by the full def.qualifiedName on the
owner side, but the __heritage__ marker carries the mixin target as the bare
written name (arg.text). A nested mixin module included by its short name
(include Loggable where it is App::Loggable) missed the full-qn map and its
IMPLEMENTS edge was silently dropped (0 dangling, undetectable). The shipped
same-tail fixture used only top-level mixin modules, so CI stayed green.
Add a secondary simple-tail fallback map consulted only when the full-qn mixin
lookup misses; owner lookups stay full-qn so same-tail owner disambiguation is
preserved. Characterization test + fixture (registry-primary only); golden
regenerated additively.
Addresses PR #1981 review (4417182679) P1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): normalize qualified Ruby mixin arg in heritage marker (#1982)
`include Outer::Mixin` embedded the raw `Outer::Mixin` into the ':'-delimited
__heritage__ marker, so the `::` collided with the field separator and
emitRubyMixinEdges mis-split it (className became empty), dropping the IMPLEMENTS
edge. Normalize the mixin arg via splitQualifiedName(...).join('.') before emit
so the marker carries the dotted form, which both parses correctly and matches
the mixin def's qualifiedName. Simple names are unchanged (no golden drift).
Addresses PR #1981 review (4417182679) secondary R2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): resolve C++ same-tail nested heritage inside a namespace (#1982)
A namespace-nested C++ type's scope-model qualifiedName carried its enclosing
CLASS chain (A.Inner) but dropped the enclosing NAMESPACE, while the
structure-phase graph node is keyed by the full path (NS.A.Inner). resolveDefGraphId's
qualifiedKey therefore missed and fell back to simpleKey('Inner'), collapsing
same-tail nested bases across sibling namespace members — DB : B::Inner pointed
at NS.A.Inner. The shipped fixture was top-level only, so it could not catch this.
Fix without disturbing the qualifiedName-keyed resolution index (an earlier
attempt that rewrote qualifiedName regressed brace-init / UDC / two-phase
namespace resolution): tagNamespacePrefixes records each namespace-nested def's
enclosing-namespace prefix on a sidecar field, and resolveDefGraphId retries the
node lookup with the namespace-prefixed key before the simpleKey fallback. The
helper is language-agnostic (acts only on Namespace scopes) and opt-in — only the
C++ provider calls it. Namespaced fixture + sequential & worker tests
(registry-primary only). All 280 cpp resolver tests pass; tsc clean.
Addresses PR #1981 review (4417182679) P2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): worker-path parity for Ruby mixin IMPLEMENTS + C++ DerivedA (#1982)
The Ruby worker-path parity block asserted only attr_accessor (HAS_PROPERTY);
add an IMPLEMENTS assertion so a dropped/cross-wired mixin owner on the worker
path is caught (the __heritage__ marker owner must survive serialization). The
C++ worker heritage block asserted only DerivedB; add a DerivedA assertion with
a toHaveLength(1) duplicate guard. Registry-primary only.
Addresses PR #1981 review (4417182679) test-coverage gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): distinct Rust same-tail nested-mod inherent-impl ownership (#1982)
Rust methods live in `impl Inner` blocks, and findEnclosingClassInfo keyed the
inherent-impl owner by the target's RAW tail (`Impl:lib.rs:Inner`), so two
same-tail `impl Inner` blocks under different mods (mod outer / mod other)
collapsed onto ONE Impl node and their methods cross-wired. The shipped fixture
test for this was skipped/deferred.
Qualify an UNSCOPED inherent-impl target by its enclosing `mod_item` scope
(`outer.Inner`) in BOTH the owner walk (ast-helpers.qualifyRustImplTargetByModScope)
and the Impl-node materialization (parsing-processor + parse-worker, lockstep) so
the owner edge and node id agree byte-for-byte. Gated on the Impl label +
impl_item + an unscoped type_identifier target — Rust-impl-exclusive, so C++/Ruby
and the rust captures golden are untouched; a SCOPED `impl a::Inner` keeps its
full raw text (#1975, unchanged). The previously-skipped distinct-ownership test
is now active and passing; rust 170/170, cpp+ruby+golden 437/437, tsc clean.
Done in-PR at maintainer request (was deferred as a follow-up). Addresses PR #1981
review (4417182679) test-coverage gap R7.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(ingestion): single qualified-name normalizer + module-scoped Ruby PROPERTY_PREFIX (#1982)
Replace cpp/captures.ts's parallel normalizeCppNamespaceQName with the shared
normalizeQualifiedName (behaviorally equivalent for C++ qualified-identifier
inputs: '::'->'.' with leading/trailing-:: handling; no interior whitespace
reaches it). Promote Ruby's PROPERTY_PREFIX to module scope alongside
HERITAGE_PREFIX (was function-local — asymmetric with no behavioral effect).
Maintainability only; cpp+ruby resolver suites 428/428, tsc clean.
Addresses PR #1981 review (4417182679) maintainability item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf+fix(ingestion): single enclosing-class walk + root-anchored base guard (#1982)
U7 (perf): preEmitInheritanceEdges resolved the deriving class AND
resolveQualifiedInheritanceBase re-walked findEnclosingClassDef for the same
site. Resolve callerClass once and thread it into resolveInheritanceBaseInScope
-> resolveQualifiedInheritanceBase -> enclosingScopeSegments, so the enclosing
class is walked once per qualified site. Add a 'program' early-exit to
buildEnclosingQualifiedName (ruby/captures.ts). Behavior-preserving.
U8 (P3): a root-anchored C++ base ": ::A::Inner" names the GLOBAL type, but
resolveQualifiedInheritanceBase prepended the deriving class's enclosing
segments and could mis-bind to an enclosing-relative same-path type. Detect the
leading "::" on the raw qualifier and try only the root-anchored key.
Discriminating fixture + test (registry-primary only).
cpp+ruby+rust resolver suites 599/599; tsc clean. Addresses PR #1981 review
(4417182679) perf + P3 items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): rebaseline ruby+cpp scope-capture fingerprints for new #1982 fixtures
The four new fixtures (ruby-nested-mixin-shortname, ruby-qualified-mixin,
cpp-namespaced-collision, cpp-global-base-anchor) grow the lang-resolution
corpus, drifting the ruby and cpp order-independent capture fingerprints.
Verified purely additive: the ruby captures golden shows only the two new
fixtures added (existing byte-identical), and removing the two cpp fixtures
reverts the cpp fingerprint to the prior baseline (so the U3/U6/U8 code changes
are scope-resolution / behavior-preserving, not capture-emission). measure.mjs
--check PASS (14 languages).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(ingestion): prettier-wrap ruby resolver test call (#1982)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(cpp): index ADL candidates once instead of per-site rescans
C++ scope-resolution `emit` dominated large-repo analysis (~6.76h on a
5,969-file repo — ~70% of the total run). `pickCppAdlCandidates` ran once
per unresolved ADL-eligible call site and each time:
- rescanned every parsed file (rebuilding a per-file scope map per call),
- scanned every workspace def (`findCppClassDefBySimpleName`), and
- used an O(scopes²) child-scope walk for hidden friends.
That is O(unresolved sites × files); with hundreds of thousands of
unresolved C++ sites the emit phase went super-linear. `resolve` (registry
lookup) was only 3.5s — the cost was entirely in fallback edge emission.
Build an `AdlCandidateIndex` once per run (lazy, guarded by `parsedFiles`
identity, reset in `clearCppAdlState`) and query it per site:
- `classDefsBySimple` — preserves `defs.byId` order so first-match /
ambiguous semantics are identical to the legacy linear scan.
- `nsCandidates` — namespace-owned callables, with inline-namespace
transparency.
- `friendCandidates` — hidden-friend + class-member callables; a
parent→children scope index replaces the O(scopes²) walk.
- `nsFunctionsByQName` / `nsFunctionsBySimple` — function-reference ADL path.
A monotonic `seqByNodeId` (file-major; namespace defs before friend/member
defs within a file) lets the per-site query merge candidates across
associated namespaces, dedup by nodeId, and sort — reproducing the exact
legacy candidate set and order.
Per-site cost drops from O(sites × files) to O(associated namespaces); the
emit phase goes from linear-in-sites to flat. Benchmark (files=80): emit at
1000 sites 232ms → 9ms, 2000 sites flat at 17ms; the eliminated term scales
with file count, so the speedup is ~1000×+ on the real 5,969-file repo.
Behavior is unchanged: synthetic candidate output is byte-identical
before/after, all 270 C++ integration resolver tests and 4/4
resolver-parity-expected-failures pass, and tsc + eslint are clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(cpp): correct ADL state-lifecycle and cache-guard comments
The header lifecycle block listed three module-level maps and named
clearFileLocalNames as the reset caller; both became inaccurate when the
candidate index was added. Enumerate all five state pieces, name the real
caller (loadResolutionConfig), and document that ensureAdlIndex's staleness
guard keys on parsedFiles identity while the index also depends on scopes
and classToNamespaceQualifiedName.
Addresses PR #1990 tri-review (U1, U3). Doc-only; no behavior change.
* test(cpp): guard the ADL seq-coverage invariant in dev/test
pickCppAdlCandidates sorts merged candidates by seqByNodeId with a `?? 0`
fallback. That fallback is unreachable today (every bucketed def is
seq-assigned in the same build block), but a future regression could break
it and silently collapse two seq-0 candidates, dropping a CALLS edge with no
error. Add validateAdlSeqCoverage and run it from buildAdlIndex under the
resolver's opt-in validation gate (NODE_ENV!=production && VALIDATE_SEMANTIC_MODEL!=0),
so a broken invariant throws loudly in dev/CI instead. Production behavior
and the hot path are unchanged. Unit-tested; 270/270 cpp integration tests
pass with the guard active.
Addresses PR #1990 tri-review (U2).
* test(cpp): parity fixture for ADL hidden-friend + namespace-callable merge
pickCppAdlCandidates merges friendCandidates (hidden friends of associated
classes) and nsCandidates (namespace-owned callables) for a single associated
namespace. The byte-identical-parity claim rested only on an uncommitted
harness. Add a fixture that reaches one callable through each bucket — combine
only via a hidden friend, process only via a namespace member — so dropping
either bucket from the merge fails the suite. Candidate order is not observable
(narrowing resolves a unique survivor or suppresses), so the guard is on the set.
Addresses PR #1990 tri-review (U4).
* test(cpp): add ADL emit-scaling benchmark
Guards the PR #1990 optimization against reintroducing the O(sites x files)
ADL candidate scan. Generates many UNRESOLVED ADL sites (class-typed arg +
a callee declared nowhere) and co-scales files and sites with N, so the old
cost is O(N^2) and the new cost O(N). Isolates the scope-resolution emit ms
from parse-dominated wall time via the logger test destination (capture
verified) and asserts the end-to-end emit ratio stays under fileRatio^1.5.
Gated by GITNEXUS_BENCH=1; runs build-free (workerPoolSize: 0).
Addresses the benchmark request alongside PR #1990 (U5).
* test(cpp): add cpp pipeline file-count benchmark
Fills the one missing per-language pipeline benchmark (cobol/csharp/go/php/
ruby/rust already have one); modeled on cobol-pipeline-benchmark.test.ts.
Generates synthetic C++ with constant per-file work and constant header
fan-out, sweeps file count through the full pipeline, and guards linearity
with a coarse time-ratio bound plus a deterministic node-ratio bound (the
non-flaky guard against reintroducing O(fileCount^2) work). Gated by
GITNEXUS_BENCH=1; runs build-free (workerPoolSize: 0).
Addresses the benchmark request alongside PR #1990 (U6).
* style(cpp): prettier-format adl benchmark
* test(cpp): rebaseline scope-capture fingerprint for new ADL fixture
The U4 parity fixture (cpp-adl-ns-plus-hidden-friend-same-name) lives under
test/fixtures/lang-resolution/cpp-*, so its lib.h + app.cpp join the cpp
scope-capture bench corpus (bench/scope-capture/measure.mjs). That is pure
fixture-corpus growth — no scope-extractor change, existing fixtures' captures
byte-identical — so the cpp fingerprint legitimately drifts (fixture_count
265->267). Rebaseline cpp to match, as #1965/#1975 did for earlier fixture
additions. Verified: --check PASS for all 14 languages.
Addresses PR #1990 tri-review (U4 follow-on).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(embeddings): guard local ONNX runtime on macOS Intel before transformers.js import
macOS Intel (darwin/x64) crashed on `gitnexus analyze --embeddings` with a raw
`Cannot find module .../bin/napi-v6/darwin/x64/onnxruntime_binding.node`: both
embedders imported @huggingface/transformers at module scope, which loads
onnxruntime-node and resolves the (unshipped) native binding before any backend
could be selected. ONNX_WEB_BACKEND=wasm could not help (#1516).
- Add a native-free runtime-support guard (getLocalEmbeddingRuntimeBlocker) that
returns a clear, actionable message on darwin/x64 and null elsewhere.
- Convert both the core and MCP embedders to type-only transformers imports plus
a guarded lazy `await import()`; throw the blocker in initEmbedder before any
transformers.js / onnxruntime-node resolution. HTTP mode is unaffected.
- Surface the blocker cleanly in the analyze CLI instead of the misleading
"installation may be corrupt" module-not-found hint.
- Add unit tests: guard DI, lazy-import timing, core+MCP darwin/x64 rejection,
and HTTP mode not blocked.
Refs #1515, #1516
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(doctor): surface macOS Intel local-embedding limitation
`gitnexus doctor` now reports whether the local embedding runtime can load on
the current platform. macOS Intel (darwin/x64) users see up front that local
embeddings are unavailable — plus the recommended alternatives — instead of
only discovering it when `analyze --embeddings` fails (#1515).
The Embeddings section gains a "Support" line; on a blocked platform the full
guidance (reused from getLocalEmbeddingRuntimeBlocker, single source of truth)
is written to stderr. doctor stays import-safe — it never loads transformers.js
or onnxruntime-node, so it runs cleanly on macOS Intel.
Refs #1515
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(embeddings): close#1515 guard coverage gaps + PR #1987 review polish
Resolves the maintainer tri-review feedback on PR #1987:
- Add the analyze error-branch test (new analyze-local-embedding-error.test.ts):
a darwin/x64 blocker routes to the clean local-embedding-unsupported message
(exit 1), not the module-not-found "installation may be corrupt" branch, and
wins over isHfDownloadFailure even when both match (guards the reorder below).
- Cover the MCP embedQuery darwin/x64 paths — HTTP bypass via httpEmbedQuery
without importing transformers, and local-mode rejection before the import.
- Make the "defaults platform/arch" guard test falsifiable by stubbing the
platform, instead of asserting null === null on the CI host.
- analyze.ts: evaluate the blocker-message branch before the network-heuristic
isHfDownloadFailure branch so the explicit platform message takes priority.
- runtime-support.ts: the blocker message now also notes GITNEXUS_EMBEDDING_DEVICE
=wasm/cpu cannot help, not only ONNX_WEB_BACKEND=wasm.
- doctor.ts: resolve platform/arch once instead of re-resolving after the guard.
Refs #1515, #1516
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): align agent system prompt with registered tools
Rewrites BASE_SYSTEM_PROMPT to fix tool-name mismatches, citation format,
and schema guidance from PR #14 tri-review, and adds unit tests that
guard prompt ↔ tool registry parity.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): enforce agent prompt/tools parity and harden assertions
U1: assert GRAPH_RAG_TOOL_NAMES equals the names createGraphRAGTools actually registers (via a no-op stub backend), closing the const<->registration drift gap the prompt-parity test previously missed.
U2: make the forbidden-name guard word-boundary (catches bare-prose mentions, not just backticked); make the highlight_in_graph guarantee registry-level (reword-proof) plus a presence check; add a parser-recognized [[Type:Name]] symbol-citation assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(web): drop test-only GRAPH_RAG_TOOL_NAMES from llm barrel
U3: GRAPH_RAG_TOOL_NAMES has no runtime consumer -- the parity test imports it directly from ./tools -- so remove it from the public index.ts barrel re-export. Update the constant's doc comment to name the registration<->const<->prompt coupling now enforced by agent-prompt.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(test): derive symbol-ref assertion from NODE_REF_REGEX
Source the symbol-citation assertion from the UI parser's own NODE_REF_REGEX instead of a hardcoded 4-label subset, so the test tracks the parser's allowlist rather than forking it. Also drop a redundant array spread and an unnecessary readonly-tuple cast surfaced by the simplify pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(web): forbid affirmative highlight_in_graph call instructions
Code review noted the registry-absence + bare-presence pair would pass if a future prompt edit affirmatively instructed calling highlight_in_graph (string present, still not registered). Add an assertion that the prompt never says use/call/invoke highlight_in_graph -- restoring the protective intent of the replaced negation check without its brittleness.
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>
* fix(rust): scope-resolution coverage gaps — F66,F68,F71,F72,F73 (#1934)
* fix(rust): reviewer fixes — macro namespace, revert pattern:(_), drop variadic
* fix(rust): wire macro resolution end-to-end + materialize unions (#1974 review)
Addresses the outstanding #1974 review (second batch). Per maintainer
decision, F72 is FULLY WIRED rather than documented capture-only.
F72 macro — was a capture-only no-op (@reference.macro dropped downstream):
- gitnexus-shared: add 'macro' ReferenceKind + Reference.kind; add
MACRO_KINDS (['Macro']) and a MacroRegistry that resolves a macro
invocation ONLY to a macro_rules! definition — never a same-named free
function (the disjoint-namespace guarantee the review required).
- scope-extractor: referenceKindFromAnchor @reference.macro -> 'macro';
normalizeNodeLabel 'macro' -> Macro.
- resolve-references: route 'macro' sites through MacroRegistry.
- emit-references / graph-bridge edges: 'macro' -> USES (kept out of the
CALLS keyspace, which denotes function/method dispatch).
- node-lookup isLinkableLabel: Macro is linkable, bridging the registry
def to the legacy @definition.macro graph node.
- rust query: capture macro_rules! as @declaration.macro; fix the scoped
macro arm to capture the tail identifier, not the full path (P3).
F71 union — the @declaration.struct scope capture had no graph node to
resolve to (legacy RUST_QUERIES never captured union_item):
- legacy query: capture union_item as @definition.struct so the union is
materialized as a Struct node and is genuinely resolvable.
- query.ts: document the deliberate union->Struct downgrade rationale.
Tests:
- rust.test.ts (parity-gated): pipeline-level union resolution + macro
resolution (USES to the Macro, exactly one CALLS to fn, none to Macro).
Macro resolution is registry-primary-only -> listed in
LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES['rust'].
- rust-coverage.test.ts: scoped-macro tail + macro-def capture assertions;
reframed as capture-layer only, pointing at the pipeline tests.
- new fixtures rust-macro, rust-union.
F73: dropped from baselines.json _note (variadic was never implemented).
Rebaselined the rust capture golden + scope-capture fingerprint
(a5fdff2c..., scaling ~0.99, fixture_count 126).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(rust): prettier-format the Reference.kind union (#1974)
CI quality/format gate — collapse the multi-line 'macro' addition back to
one line (fits the 100-col print width).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): failing target tests + graph-integrity helper for scoped-declaration nodes (U1, #1975)
Adds findDanglingEdges() and pipeline-level tests asserting that Ruby
namespaced class/module declarations materialize a Class/Trait node with
a resolving HAS_METHOD edge. Red by design on the pre-fix base (5 failing)
— the fix lands in U2 (shared core) + U3 (Ruby enablement).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): materialize graph nodes for Ruby namespaced class/module declarations (U2/U3, #1975)
Widen the Ruby legacy structure query so `class Foo::Bar` / `module Baz::Qux`
(name field is a scope_resolution node) match @definition.class/.module as
separate top-level patterns. The node is keyed by its full scoped name, which
matches the HAS_METHOD owner id that findEnclosingClassInfo derives from the
same name field — so the previously-dangling ownership edges now resolve, and
distinct namespaces (Foo::Bar vs Baz::Bar) stay distinct nodes (no collision).
No change to findEnclosingClassInfo (zero call-resolution blast radius) and no
scope-extractor/golden/bench impact — the fix is purely the legacy structure
query gate. Finalizes the U1 target assertions to the qualified-name identity.
Validated: 134/134 Ruby resolver tests pass on BOTH legs; tsc --noEmit clean;
dangling HAS_METHOD edges on the ruby-namespaced fixture drop from 3 to 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): resolve C++ out-of-line nested definition method ownership (U4, #1975)
For an out-of-line `struct Outer::Inner { ... }`, the container name is a
qualified_identifier, so findEnclosingClassInfo derived the owner id from the
full `Outer::Inner` text — but the type is keyed by its in-class declaration
(the nested `Inner` node), leaving the method's HAS_METHOD edge dangling.
Reduce a qualified_identifier container name to its tail segment for the owner
id/name, matching how inline nested definitions are already keyed. Node-type
scoped, so Ruby's scope_resolution names stay full (distinct-by-namespace) and
no language is named in shared code. Only out-of-line-def methods (already
dangling) change behavior — zero impact on bare classes or call resolution.
Validated: C++ 268/268 default leg, 205+63-skip legacy leg, no regression;
2 new target tests pass both legs; Ruby namespaced tests still pass; tsc clean;
scope-capture bench rebaselined (cpp +cpp-out-of-line-class fixture) — --check
PASS (13 langs). Dangling HAS_METHOD on the new fixture: 1 -> 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): resolve Rust scoped impl-target method ownership (U5, #1975)
`impl path::Type` and `impl Trait for path::Type` name the target with a
scoped_type_identifier. Two coordinated fixes:
- findEnclosingClassInfo: reduce a scoped_type_identifier impl target to its
trailing type name (both the trait-impl `for` branch and the inherent
branch), matching the type's own tail-keyed declaration.
- tree-sitter-queries: add a @definition.impl arm for scoped inherent impls so
the Impl node is materialized (keyed by the same tail) instead of missing.
Together the trait-impl method owns through the real Struct node and the
inherent-impl method owns through a real Impl node — no dangling edges. Rust's
scoped_type_identifier has a name: field, so the tail extraction is exact.
Validated: Rust 163/163 on BOTH legs, no regression; new target test passes;
C++/Ruby suites unaffected; tsc clean; scope-capture bench rebaselined
(rust +rust-scoped-impl fixture) — --check PASS (13 langs). Dangling 1 -> 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): cross-namespace collision test + regenerate ruby/rust captures goldens (U6, #1975)
- Add ruby-tail-collision fixture + test: Foo::Bar and Baz::Bar share the tail
'Bar' but must stay two distinct Class nodes (locks the KTD-2 anti-collision
guarantee from full-scoped-name keying). No dangling, no cross-wiring.
- Regenerate the ruby + rust captures goldens for the fixtures added in U3-U6
(ruby-tail-collision, rust-scoped-impl). Both diffs are additive-only — a
single new entry each, existing entries byte-identical (no capture-logic
drift; the fixes are in the legacy structure query + findEnclosingClassInfo,
not the scope-extractor).
- Re-baseline the ruby scope-capture fingerprint (81->82 fixtures).
N/A-language verification: C#/Java/PHP have no class-declaration scoped-name
gap and show no regression (606 passed; the 2 C# worker-pool failures are the
known worktree 'parse-worker.js not built' limitation, unrelated to this change).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* revert(ingestion): drop C++/Rust scoped-owner reduction; ship Ruby-only (#1975)
The self-tri-review of PR #1977 (review 4411683756) found — and reproduced —
that the C++/Rust tail-reduction in findEnclosingClassInfo collides same-tail
types declared in the same file (struct Outer::Inner + struct Other::Inner ->
one Struct:Inner node, methods silently mis-attributed; same-named members
merge). Root cause is pre-existing: GitNexus keys nested-type nodes by their
tail name within a file, so even plain inline same-tail nested types already
merge. A correct fix needs fully-qualified nested-type node identity — a broad
change deferred to #1978.
This reverts the C++ (qualified_identifier) and Rust (scoped_type_identifier
impl) owner reductions in ast-helpers.ts, the Rust @definition.impl scoped arm,
and the cpp/rust fixtures+tests+golden+bench entries. The Ruby fix is unaffected
(it keys the node by the full scoped text — no collision) and stays:
namespaced class/module node materialization + the cross-namespace collision test.
Validated Ruby-only: 136/136 both legs; ruby+rust captures goldens 19/19;
bench --check PASS (14 langs); tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): collision-safe C++/Rust scoped-declaration node ownership (#1975)
Re-introduces the C++/Rust fix the tri-review reverted, using a collision-safe
approach instead of owner tail-reduction (which merged same-tail types in one
file). Key the scoped DECLARATION's node by its full qualified text so it
matches the owner id and stays distinct from a same-tail type elsewhere:
- C++: widen the legacy structure query to materialize a node for out-of-line
defs (class/struct Outer::Inner — name is qualified_identifier), keyed by the
full text. No findEnclosingClassInfo change needed — BASE already derives the
full-text owner, which now matches. Outer::Inner and Other::Inner stay
distinct; 3-level A::B::C resolves. (A redundant forward-decl node remains.)
- Rust: @definition.impl arm for scoped inherent impls (keyed full) +
findEnclosingClassInfo inherent-impl branch accepts scoped_type_identifier
with full text. impl a::Inner and impl b::Inner stay distinct.
Collision-aware fixtures + positive owner-identity assertions (per the
tri-review) replace the single-type fixtures. Deferred to #1978: Rust trait
impls on a scoped struct path (impl T for a::Inner) and the pre-existing inline
same-tail node collision — both need qualified struct-node identity.
Validated: Ruby 136/136, C++/Rust 434/434 both legs (371+63-skip legacy);
ruby+rust captures goldens 19/19 (additive); bench --check PASS (14 langs);
tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(format): apply prettier to scoped-declaration changes (#1975)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(scope-resolution): migrate Dart to registry-primary call resolution (#939)
Add a Dart scope-resolution module (languages/dart/) mirroring the Swift
template and flip Dart to registry-primary. Resolution edges
(CALLS/IMPORTS/ACCESSES/EXTENDS/IMPLEMENTS/METHOD_IMPLEMENTS) now route
through the shared registry pipeline with byte-for-byte parity against the
legacy DAG: test/integration/resolvers/dart.test.ts passes 53/53 under both
REGISTRY_PRIMARY_DART=0 and =1 (scripts/run-parity.ts --language dart: 2/2).
Dart-specific handling:
- Function scopes are synthesized to span signature..body (tree-sitter
function_signature/function_body are siblings, not parent/child).
- extends rides @reference.inherits (EXTENDS via the generic pre-pass);
implements/with are carried as __heritage__ side-effect imports and
emitted as IMPLEMENTS, since Dart `implements <class>` must be IMPLEMENTS
regardless of the target's symbol kind.
- imports are wildcard (whole-library) with expandsWildcardTo so imported
return types propagate cross-file (var u = getUser(); u.save()).
- getInnerSignature now self-returns a bare signature node so top-level
function params/return/name extract (legacy-safe: legacy only ever passes
method_signature/declaration wrappers).
Also: add Dart scope-capture bench coverage (linear ~0.99 scaling); update
two tests that used Dart as a non-migrated control (Vue / forced legacy).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(scope-resolution): close Dart registry-primary parity gaps from review
Adversarial review of #1970 surfaced real divergences from the legacy DAG on
constructs the 10 fixtures don't exercise. All fixed; parity gate still 2/2
(now 55/55 each mode):
- Implicit-constructor construction (`Foo()` with no explicit ctor): the
legacy DAG emits `caller -> Foo` (Class) but registry emitted nothing
(callee tagged @reference.call.free never reaches constructorCallTargetsClass).
Re-tag UpperCamelCase free-callees to @reference.call.constructor (Dart types
are UpperCamelCase) so they link to the Class. Locked in with a regression
fixture + test that passes in BOTH modes.
- Cascade calls (`list..add(1)..sort()`) were dropped — cascade_section has no
`selector` wrapper, so the reference walk never saw them while legacy emitted
them as free calls. Add a cascade_section handler.
- BUILT_INS (setState/then/push/pop/listen/...) were not suppressed on the
registry path, so a user symbol shadowing one produced a spurious CALLS edge
the legacy DAG suppresses. Skip built-in-named call refs at capture time
(extract the set to a leaf module shared with the provider).
- Enhanced-enum methods mis-parented to Module (no enum scope). Add
`(enum_declaration) @scope.class` so enum members are owned by the enum.
Re-baseline the Dart scope-capture fingerprint (linear ~0.95).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(scope-resolution): apply issue #1926 F24/F25 findings to the Dart scope path
Issue #1926 catalogs Dart parsing-layer coverage gaps. Apply the two that the
registry-primary scope-resolution path owns (call edges + call attribution),
registered as legacy-expected-failures since they are scope-resolver-only wins.
- F24: the scope path's unified tree-walk already captures member calls
(obj.method()) in return / list-literal / named-argument / arrow-body
contexts — the legacy DAG only captures them under expression_statement /
initialized_variable_definition. Lock it with the dart-member-call-contexts
fixture + tests.
- F25 (constructor portion): a constructor's body is a sibling of the WRAPPING
method_signature (class_body > method_signature > constructor_signature, then
function_body), so findFunctionBody now walks up to the method_signature
wrapper. Constructor bodies get a Function scope and their body-calls
attribute to the Constructor (a valid caller anchor) instead of the class.
Add the dart-constructor-body fixture + test.
Switch dart.test.ts to createResolverParityIt('dart') and add the dart entry to
LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES (5 wins). Both modes pass:
run-parity --language dart → 2/2 (registry 60/60; legacy 55 pass + 5 skipped).
Not applicable to the scope path (structure-phase / shared-pipeline, tracked by
#1926's legacy fix): F25 getter/setter (Property is not a caller anchor) and
operator (no Method node emitted by the structure phase) bodies; F26 (static
field Property nodes); F27 (no generic_type reference in the scope module);
F28/F29 (typedef/variable node extraction). Re-baseline the Dart scope-capture
fingerprint (linear ~1.0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(scope-resolution): fix Dart named-constructor file-drop + container-name mis-binding (tri-review)
Multi-engine tri-review (GitNexus + CE personas + Codex gpt-5.5) of #1970
found a P0 the parity gate missed plus a P2 wrong-edge:
- P0 (file drop): a named constructor with a body (`class A { A.named() {…} }`,
idiomatic Dart) parses as ONE constructor_signature carrying multiple `name:`
fields, so the scope query matched it more than once and synthesized two
identical-range @scope.function captures → ScopeTreeInvariantError(duplicate-
scope-id) → extractParsedFile swallowed it → the WHOLE file was dropped from
registry-primary resolution (CALLS=0 vs legacy CALLS=2). Introduced by the
#1926 F25 findFunctionBody change that started giving constructors body
scopes. Fix: dedup function-like declarations by their statement node so each
is emitted once. Add dart-named-constructor-body fixture + a parity guard test
(both modes) that fails if the file is dropped, plus the named-ctor F25
attribution win (registry-only).
- P2 (wrong edge): normalizeDartType's Future<X>/List<X> unwrap is unreachable
(generic args are stripped upstream to a bare `Future`/`List`), so a return/
field type binding to the bare container name let a same-named user class
(`class Stream {…}`) capture the receiver — a wrong CALLS edge legacy didn't
emit. Suppress type bindings that normalize to a bare container name (leaving
the call unresolved, matching legacy) instead of binding to the container.
Both modes still pass: run-parity --language dart → 2/2 (registry 62/62; legacy
56 + 6 skipped). Re-baseline the Dart scope-capture fingerprint. Also: refresh
the captures.ts module doc (constructors get scopes; cascade calls).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(scope-resolution): address Dart tri-review follow-ups (heritage collision + polish)
- P2 heritage cross-file name collision: emitDartHeritageEdges resolved both
child and base by a global last-write-wins simple-name map, so two files each
declaring `class Logger` (one `implements Logger`) produced a wrong-file
IMPLEMENTS edge. Resolve with same-file affinity (prefer a same-file class,
then a workspace-unique match, else refuse to guess) — the #1951 file-affinity
pattern. Add dart-heritage-name-collision fixture + a parity test (both modes
resolve same-file). Also reason-qualify the dedup key so `implements X` + `with X`
keep distinct edges.
- Polish: buildDartMro uses Sets instead of Array.includes-in-loop; merge-bindings
uses named tier constants matching swift; drop the dead no-op stripQuotes in
import-target (targetRaw already arrives quote-stripped).
Both modes pass: run-parity --language dart → 2/2 (registry 63/63; legacy 57 + 6
skipped). Re-baseline the Dart scope-capture fingerprint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): steer npm 11 users away from npx install crash (#1939)
Prefer global gitnexus or pnpm dlx in hooks and generated AI context, warn
when npm 11.x would use the broken npx path, and document workarounds for
the arborist node.target null failure mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(hooks): stage resolve-analyze-cmd.cjs for antigravity adapter; harden load checks
The antigravity adapter gained a top-level require('./resolve-analyze-cmd.cjs')
but stageAdapter() did not copy it, so the spawned adapter crashed with
MODULE_NOT_FOUND. Three load-sensitive tests failed; four silent-path tests
false-passed on empty stdout.
Stage the helper alongside the other sibling helpers, and assert status===0 and
no MODULE_NOT_FOUND on the four silent-path tests so a non-loading hook can never
pass green again. Force a deterministic invocation mode in the stale-index test
so the emitted analyze command no longer varies by CI-runner PATH.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): standardize invocation hints on gitnexus@latest; single-source CJS helper
NPX_REF becomes a literal `gitnexus@latest` in resolve-invocation.ts, dropping
the package.json require and the module-load throw (a malformed/absent version
can no longer crash any CLI command at import). The safety this PR delivers is
the install method steered to (global / pnpm dlx), not a pinned gitnexus
version, and the in-repo CJS mirror already degraded to `latest` once copied
outside the package.
Make the two resolve-analyze-cmd.cjs copies byte-identical and add a parity
test that fails on drift. The separate, version-pinned NPX_REF that setup.ts
writes into the MCP server registration is intentional and left unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
* perf(cli): move npm-11 npx warning off module load; memoize invocation mode
warnIfNpm11NpxRisk() ran at index.ts module load, so every CLI invocation
(including the `gitnexus mcp` stdio hot path) paid which/where + npm --version
spawns — against the lazy-startup/MCP-stdout discipline (#207, #1383). Move the
call into analyzeCommand, after the ensureHeap() re-exec guard, so it fires once
in the working process and only for `analyze`.
Memoize the PATH-probe-derived invocation mode (the GITNEXUS_INVOCATION override
stays uncached) so repeated callers don't re-probe, and add a test-only reset so
the cache + once-only warning flag don't leak across the unit suite. Covers the
mode!=='npx', npm<11, and npm-absent suppression branches.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): detect .exe/extensionless global gitnexus shims on Windows
The winGitnexusWrapper branch only matched .cmd/.bat, so a global gitnexus
installed by Volta or scoop (a .exe or an extensionless shim) was missed and the
hint fell back to pnpm/npx. Accept .exe and treat any non-empty `where` hit as
on-PATH (the emitted hint is `gitnexus analyze` regardless of which shim
resolves it). Mirror the change into both resolve-analyze-cmd.cjs copies so the
TS source and the byte-identical hook mirrors stay in sync.
Add Windows-mocked test cases (.exe-only, extensionless, .cmd preference, CRLF
stripping) and register resolve-invocation.test.ts in cross-platform-tests.ts so
the windows-latest runner exercises the branch.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): emit fixed pnpm dlx analyze command in generated AGENTS.md/CLAUDE.md
ai-context baked a machine-resolved command (formatAnalyzeCommand) into
git-tracked AGENTS.md/CLAUDE.md, so the stale-index hint varied per machine and
churned across branches (the #1706 class). Emit the fixed string
`pnpm dlx gitnexus@latest analyze` instead: committed AI-context is the most
authoritative instruction an agent reads, so it must name an install-free,
crash-free method — never `npx`, the npm-11 path #1939 steers away from.
formatAnalyzeCommand stays exported and unit-tested in resolve-invocation.ts
(it still mirrors the two .cjs hook copies); ai-context just no longer calls it.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(cli): unify hook-helper copy into one non-silent routine
installClaudeCodeHooks copied its four hook helpers in separate try/catch blocks
that silently swallowed failures, while installAntigravityHooks recorded an
error per failed copy. Extract one copyHookHelpers(srcDir, destDir, label,
result) with a single canonical helper list (including resolve-analyze-cmd.cjs)
and the antigravity loop's error-reporting policy, and use it from both paths so
a missing helper surfaces as a setup error instead of a silent runtime crash.
Assert both the Claude and Antigravity install paths co-locate
resolve-analyze-cmd.cjs next to the adapter, and that a failed copy records an
error rather than passing silently.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(cli): reattach installClaudeCodeHooks JSDoc after helper extraction
The extracted HOOK_HELPERS/copyHookHelpers block landed between the
installClaudeCodeHooks JSDoc and its function, leaving the doc reading as if it
described the helper list. Move the block above the doc so it documents the
function again. No behavior change.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(cli): enforce TS<->CJS invocation parity and guard CLI startup posture
Tier-2 review found two in-scope gaps in the #1945 follow-up:
- The "mirrors resolve-invocation.ts / test enforces parity" comments overclaimed:
the parity test only compared the two .cjs copies to each other, so the TS
source and the CJS hook copies could silently drift (NPX_REF, the per-mode
command, and the Windows shim regex were hand-edited in all three this PR).
Add TS<->CJS value parity (NPX_REF + formatAnalyzeCommand for every forced
mode) and a source-level shim-regex parity check, and make the mirror comments
accurately describe what is enforced.
- No test locked the R3/R4 startup posture, so re-adding warnIfNpm11NpxRisk()
(or any resolve-invocation import) at index.ts module scope -- the #207/#1383
lazy-startup regression -- would pass CI. Add a guard asserting index.ts has
no module-load invocation probe and the warning is wired into analyzeCommand.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(cli): collapse npx-invocation resolver to one source of truth
PR #1945 carried the gitnexus/pnpm/npx selection in three hand-synced
places — the canonical hook helper, its byte-identical plugin copy, and a
full TypeScript re-implementation in resolve-invocation.ts — kept in lockstep
by per-mode-command and regex-extracted-by-regex parity tests. The TS
formatAnalyzeCommand had no production caller (ai-context emits a fixed
string), and the module memoized + exposed a test-only reset for a "repeated
callers" case that has exactly one caller.
Make hooks/claude/resolve-analyze-cmd.cjs the single source: extract the
Windows-shim line-picking into a pure, exported pickPathMatch() and add an
injectable probe to resolveInvocationMode() so the shipped logic is testable
without spawning or global mocks. resolve-invocation.ts (118 -> 59 lines) now
consumes that cjs via createRequire for resolveInvocationMode/NPX_REF and adds
only the CLI-only npm-version probe and warning; the relative path resolves
identically from src/cli/ (tsx, vitest) and dist/cli/ (shipped, hooks/ is a
published sibling of dist/). Tests exercise the real shipped artifact, the
NPX_REF/mode-command parity scaffolding is dropped (one implementation can't
drift), and parity narrows to the two cjs copies staying byte-identical.
No behavior change: hook stale-index hints and the analyze warning are
byte-identical; the pre-existing setup.ts resolveGitnexusBin is untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): bound stale-index hook PATH probe under the hook budget (U1)
The PostToolUse stale-index hint calls formatAnalyzeCommand(), which probes which/where; named PROBE_TIMEOUT_MS=2000 keeps git rev-parse (~3s) + up to two probes well under Claude Code's 10s hook timeout while preserving the machine-correct hint. Byte-identical in the plugin copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): steer generated cross-repo group commands off npx (#1939) (U2)
The Cross-Repo Groups block in generated AGENTS.md/CLAUDE.md still emitted bare 'npx gitnexus group ...', funneling npm-11 users into the arborist crash; switch to fixed 'pnpm dlx gitnexus@latest group ...'. Export generateGitNexusContent and add a group-branch test asserting no 'npx gitnexus' literal survives.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: align steering guidance on pnpm dlx gitnexus@latest (U3)
README troubleshooting uses gitnexus@latest; the repo's own committed CLAUDE.md/AGENTS.md stale-index hint now matches the generated output (pnpm dlx gitnexus@latest analyze) so the repo dogfoods the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(hooks): assert exact @latest analyze command and pin invocation mode (U4)
Drop dead PKG_VERSION/NPX_REF version-pinned constants; the cjs always emits gitnexus@latest, so assert exact toContain(...) instead of the /@\\S+/ wildcard; pin GITNEXUS_INVOCATION in the --embeddings tests for host-independent determinism.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cli): cover resolver warn/edge branches; document probe seam (U5)
Add coverage for the gitnexus-mode warn suppression, getNpmMajorVersion edge inputs (empty/pre-release/non-numeric), and the Windows non-wrapper pickPathMatch branch; widen the InvocationResolver interface to document the optional probe param.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): lower hook PATH-probe timeout to 1000ms (U1)
In a linked worktree the stale-index hook runs git rev-parse --git-common-dir (~2s) + rev-parse HEAD (~3s) before up to two PATH probes; PROBE_TIMEOUT_MS=1000 holds the worst case near ~7s under Claude Code's 10s hook budget (was 2000, ~1s headroom). Byte-identical in the plugin copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): fail closed in gitnexus setup on missing required hook helper/adapter (U2)
copyHookHelpers now returns the failed REQUIRED helpers (the .cjs trio; win-rm-list-json.ps1 stays best-effort since it fails open). Both install paths skip hook registration with an actionable error when a required helper failed; the Claude path also gains the adapter-existence guard the Antigravity path already had. Prevents registering a hook that crashes MODULE_NOT_FOUND on every tool event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(skills): steer committed skill files off npx to pnpm dlx gitnexus@latest (U3)
All 26 committed skill-file copies (gitnexus/skills, .claude, plugin, cursor) used 'npx gitnexus analyze', contradicting the generated freshness line and funneling npm-11 users into the arborist crash. Replace with 'pnpm dlx gitnexus@latest analyze'; add a regression guard (skills-steering.test.ts) that globs all four locations and fails if any reintroduces it. The cli skill's non-analyze npx subcommands (status/clean/list/wiki) are left as-is (out of the analyze-funnel scope).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): guard resolver import shape; assert group-impact steering (U4)
Add a load-time guard on the createRequire(resolve-analyze-cmd.cjs) cast so a drifted/renamed cjs export fails loudly at module load instead of as a late TypeError in warnIfNpm11NpxRisk. Add the missing 'group impact' assertion to the ai-context Cross-Repo Groups test, and a resolver-contract test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): auto-select invocation path with pnpm --allow-build (#1939)
Probe npm/pnpm versions and PATH to pick a working analyze command without
user configuration: global gitnexus first, pnpm dlx with --allow-build on
npm 11+ (Ladybug native scripts), npx on npm 10 and earlier. Update docs,
skills, and tests to match the canonical install-free command.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): place pnpm --allow-build before dlx, repair version-injection seam (#1939)
The auto-selected install command emitted `pnpm dlx --allow-build=… analyze`,
but pnpm < 10.14 keeps `dlx` in its argv escape list, so flags placed *after*
`dlx` are parsed as package specs and rejected (ERR_PNPM_SPEC_NOT_SUPPORTED) on
pnpm 10.2–10.13.x — strictly worse than the bare command. Move the flags before
`dlx` (the position pnpm has honored since 10.2.0) in both byte-identical hook
copies, the committed AGENTS.md / CLAUDE.md, and every skill tree.
Also repairs the CI-red resolveInvocationMode seam: injecting `{ npmMajor: null }`
to simulate an absent npm fell through `??` to the host's real `npm --version`
(npm 10.x on the CI runners → routed 'npx' instead of 'pnpm'). Use an
`'npmMajor' in deps` sentinel so an injected null is honored, drop the dead
parseMajorVersion guard, and gate the flags on pnpm >= 10.2 via a single
minor-aware probeVersion spawn (skipped for committed docs). Align the TS
getNpmMajorVersion timeout to the 1s hook budget and strengthen the
skills-steering guard with a pre-dlx positive assertion plus a post-dlx
regression check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: add npm-11 pnpm caveat to README Quick Starts (#1939)
The root, package, and cursor-integration README Quick Starts still steered
first-contact users to bare `npx gitnexus analyze` — the exact npm 11.x
arborist install crash issue #1939 names as a funnel. Add a one-line pnpm
`--allow-build … dlx` caveat (keeping the simple npx default for npm<=10 /
pnpm / yarn users); the package README points to its existing npm-11
workaround section.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(skills): route every gitnexus-cli command off npx to pnpm dlx (#1939)
The gitnexus-cli skill demonstrated analyze via `pnpm --allow-build … dlx`
but still showed status/clean/wiki/list via bare `npx gitnexus` — the same
package, the same npm-11 crash-prone install path — and its header claimed
"all commands work via npx". Convert every subcommand to the pnpm form across
all three skill copies and reconcile the header. Broaden the skills-steering
guard to forbid any `npx gitnexus` command in the cli-skill copies.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(hook): probe pnpm once on the stale-index path (#1939)
The stale-index hook resolved pnpm twice — `which pnpm` for mode selection
then `pnpm --version` for the allow-build gate — two spawns for one tool in a
~9s/10s budget. Capture the version once in formatAnalyzeCommand and thread it
through the existing deps seam (a successful `pnpm --version` proves presence),
sharing a memoized PATH probe with resolveInvocationMode. Add explicit pnpm
10.0-suppress / 10.2-emit boundary tests and relabel the unknown-minor case.
Both byte-identical cjs copies updated together.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(setup): single-quote POSIX hook command + assert cliPath patch applied (#1939)
The hook `command` written into editor settings is shell-evaluated; the
double-quoted `node "<path>"` form left `$`, backtick, and other metacharacters
live in an adversarial $HOME. Single-quote the path on POSIX (Windows keeps the
double-quoted form — those chars are illegal in Windows filenames). Also assert
the cliPath source-literal replace() actually matched, recording an actionable
error on drift instead of silently shipping a hook with an unresolved relative
path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(setup): normalize expected hook path for the Windows runner (#1939)
The new POSIX-escaping test built its expected hook path with path.join,
which emits backslashes on the Windows runner, while setup.ts forward-slash-
normalizes the path before quoting — so `expect(cmd).toBe(node '<path>')`
mismatched on tests/windows-latest. Normalize the expected path the same way.
Production code was already correct; only the test's expected value was
platform-fragile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): steer docs/skills via a project-local runner, not a pnpm default (#1939)
The prior approach hardcoded `pnpm --allow-build=… dlx gitnexus@latest <cmd>`
into every committed skill + the generated AGENTS.md/CLAUDE.md, which assumes
pnpm is installed. Replace it with a CLI-neutral project-local runner:
- `gitnexus analyze` drops `.gitnexus/run.cjs` (a copy of the canonical
`resolve-analyze-cmd.cjs`, which gains `buildRunnerArgv` + a `require.main`
exec tail) next to the index. Docs/skills reference `node .gitnexus/run.cjs
<cmd>`, which auto-selects the runner (global `gitnexus` → `pnpm dlx` → `npx`)
at call time — no package-manager assumption. README first-run + an inline
bootstrap note stay universal `npx gitnexus analyze`.
- The exec tail uses `shell` on Windows so `.cmd`/`.ps1`/`.exe` shims resolve
(execFileSync can't otherwise; Node blocks `.cmd` without a shell,
CVE-2024-27980), and prints a diagnostic instead of a silent exit 1.
Tests: runner exec-tail (real spawn, exit-code propagation + ENOENT diagnostic),
copy-failure graceful degradation, and per-subcommand routing + pnpm-fallback
vacuity guards. The generated CLAUDE.md block stays under the #856 token budget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): resolve Windows .cmd version probes so pnpm steering fires (#1939)
probeVersion (and the TS getNpmMajorVersion mirror) spawned npm/pnpm
--version via execFileSync with no shell, so on Windows the .cmd shims
ENOENT'd, the probe reported a present tool as absent, and the stale-index
hook recommended the npx crash path #1939 exists to avoid. Add
shell: process.platform === 'win32' to the version probes (the exec tail
already does this). Parse the first version-shaped line so a Corepack/notice
banner on stdout no longer defeats the parse. Carry pnpm presence separately
from version so a present-but-unparseable pnpm still selects pnpm. Drop the
dead probe ?? resolveOnPath coalesce. Cover resolve-analyze-cmd.cjs (+ plugin
twin) with the shell-injection and windowsHide source-regression guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): widen pnpm allow-build for the --embeddings=N equals form (#1945)
buildRunnerArgv detected embeddings via gitnexusArgs.includes('--embeddings'),
which missed the equals form (--embeddings=5000) that Commander also accepts,
dropping --allow-build=onnxruntime-node on pnpm 10.2+. Match both forms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cli): cover the runner exec-tail Windows shell branch on CI (#1945)
runner-exec-tail.test.ts was POSIX-only and unregistered in
cross-platform-tests.ts, so the run.cjs Windows shell:true exec branch ran on
no platform despite the file comment claiming windows-latest covered it. Add a
.cmd-shim it.skipIf(onPosix) case and register the file in SPAWN_CLI so the
windows-latest job runs it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: fix broken troubleshooting anchor in gitnexus README (#1945)
The npm-11 quick-start note linked to #npx-gitnexus-crashes-with-nodetarget-is-null-npm-11,
which matches no heading; the actual troubleshooting heading slugifies to
#cannot-destructure-property-package-of-nodetarget-as-it-is-null. Repoint the link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(hooks): guard resolve-analyze-cmd.cjs in antigravity e2e sanity check (#1945)
The antigravity adapter top-level require()s resolve-analyze-cmd.cjs, but the
beforeAll helper-presence loop did not check for it — a failed copy would
surface as noisy MODULE_NOT_FOUND in downstream tests instead of the intended
actionable 'Helper not installed' error. Add it to the loop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(skills): tie a missing-runner Cannot-find-module error to recovery (#1945)
Generated CLAUDE.md/AGENTS.md make `node .gitnexus/run.cjs` the primary
command, but the runner is gitignored, so a fresh clone or git clean leaves an
agent facing a raw MODULE_NOT_FOUND. The CLAUDE.md block is token-budget-capped
(#856), so the recovery guidance lives in the cli skill (its documented home):
the bootstrap note now names the `Cannot find module` error and points at
`npx gitnexus analyze` to (re)generate the runner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(cli): disambiguate the MCP-pinned ref from the @latest hint (#1945)
setup.ts and resolve-analyze-cmd.cjs both exported a constant named NPX_REF
with different values (version-pinned for the persisted MCP entry vs.
gitnexus@latest for hints). Rename setup.ts's module-private constant to
MCP_PINNED_REF (value and behavior unchanged — the MCP pin stays pinned),
leaving the cjs hint ref and its re-export alone. Also route the createRequire
cast through 'unknown' so it reads as an explicit narrowing to the subset this
module uses rather than a claim about the cjs's full export shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: JS/TS scope-resolution coverage gaps — F44, F83, F85, F86, F87 (#1929)
F44: Add (class) @scope.class for class expressions in TS query.
F83: Fix qualified new_expression (new ns.Foo()) to capture @reference.name.
F85: Add enum member declaration patterns (bare + valued) as @declaration.property.
F86: Unblocked by F44 — class expression methods get correct Class scope.
F87: Add 4 missing optional_parameter type annotation patterns (predefined_type,
union_type, array_type, readonly_type) matching required_parameter.
Grammar verification via node-types.json confirms all node types exist.
9 new tests proving each fix fails on main and passes on the branch.
* chore(bench): update TypeScript scope-capture baseline after F44/F85/F87
---------
Co-authored-by: Sparsh <sparshprajapati2002@gmail.com>
* fix: guide pnpm dlx/pnpx users through skipped native install
`pnpm dlx gitnexus serve` (and `pnpx gitnexus`) crash with a raw
`ERR_DLOPEN_FAILED` stack trace because @ladybugdb/core's native addon
(lbugjs.node) is placed by a postinstall script, and dlx/pnpx run
ephemerally without executing lifecycle scripts.
The existing checkLbugNative() guard already catches the missing binary
for serve/mcp/analyze, but its guidance only mentioned bun and
--ignore-scripts. Extend the message to call out the common pnpm dlx /
pnpx case and the fix (`pnpm add -g gitnexus && pnpm approve-builds -g`,
or use npx/npm). Add a matching README troubleshooting section.
This does not make `pnpm dlx` itself work — that requires a runtime
fallback in @ladybugdb/core. It turns the crash into actionable guidance.
Refs #307
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: add pnpm --allow-build dlx option to native-check guidance
Incorporates collaborator feedback (magyargergo): pnpm's security model
allows `dlx` to run build scripts when you pass `--allow-build` for each
native dep. Add this as the first/preferred pnpm-dlx path in the error
message, README troubleshooting section, and test assertion. Drop the
now-incorrect claim that `pnpm dlx` "cannot be made to work directly".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address PR review on pnpm dlx native-load guidance
Replace removed pnpm approve-builds -g with add -g --allow-build flags,
qualify npm 11 npx caveats, use serve in examples, extend load-failure hints,
and assert --allow-build precedes dlx in tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* ci(devcontainer): retry Docker Hub syntax frontend and build
The devcontainers CLI injects `# syntax=docker/dockerfile:1`, which BuildKit
fetches from Docker Hub. Transient Hub timeouts caused main smoke failures
(run 26797815133). Pre-pull the frontend with backoff and retry the build
once, matching docker-build-push-retry policy.
Co-authored-by: Cursor <cursoragent@cursor.com>
* ci(devcontainer): address tri-review follow-ups on smoke retries
Make syntax-frontend pre-pull best-effort (continue-on-error) so build
retry still runs when Hub flakes only on pull. Clarify comment vs
docker-build-push-retry, and emit a notice when build retry succeeds.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: extend .gitattributes for shell scripts and binary assets
Append explicit `*.sh text eol=lf` and `*.bash text eol=lf` rules so
shell scripts (notably anything COPYed into a Linux container) check out
with LF endings on Windows hosts with `core.autocrlf=true`, regardless
of the auto-detection on the existing `* text=auto eol=lf` line. Add
binary markers for `*.node`, `*.wasm`, `*.onnx`, `*.so`, `*.dll`,
`*.dylib` so native and ML model artifacts aren't ever subjected to text
normalization.
The existing `* text=auto eol=lf` and `.husky/* text eol=lf` rules are
preserved. `git ls-files --eol` confirmed zero CRLF or mixed blobs in
the index, so no `--renormalize` was needed.
* feat(devcontainer): add cross-platform devcontainer for Claude Code, Codex, and Cursor CLIs
Add a Dev Container that pre-installs Claude Code (2.1.153, via Anthropic's
official Feature), OpenAI Codex CLI (pinned 0.134.0), and Cursor CLI alongside
the GitNexus native build chain. Opens via VS Code's Dev Containers extension
on Windows 11 (Docker Desktop + WSL2), macOS, or Linux without OS-specific
branches in devcontainer.json.
Topology and base
- Base image `mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm`
(multi-arch, monthly patched, ships the `node` non-root user, zsh, `gh`).
- Node 22 LTS satisfies `gitnexus/`'s engines `>=22.0.0` and matches the
`node:22-bookworm-slim` SHA-pinned base used by `Dockerfile.cli`.
- Single container with all three CLIs co-installed (vs. docker-compose
per-tool) — prevailing 2026 community pattern, lowest daily-driver friction.
Persistence and auth
- Per-devcontainer named volumes scoped by `${devcontainerId}` for
`/home/node/.claude`, `/home/node/.codex`, `/home/node/.cursor`,
`/commandhistory`, and `/home/node/.npm`. Authentication survives rebuilds
without leaking between workspaces.
- Four sub-workspace `node_modules` volumes (root, gitnexus, gitnexus-web,
gitnexus-shared) keep tree-sitter native bindings and onnxruntime off the
bind mount — the actual Win/Mac perf win.
- Credential mount paths are pre-created in the Dockerfile with
`chown node:node` BEFORE `USER node`, so empty named volumes inherit
correct ownership on first mount and first-run logins don't EACCES.
- `CURSOR_API_KEY` is injected via `containerEnv: ${localEnv:CURSOR_API_KEY}`
(Cursor's documented headless path); falls back to interactive
`cursor-agent login` when the host env var is unset.
Build-arg promotion
- Build args (`CLAUDE_CODE_VERSION`, `CODEX_VERSION`, `CURSOR_VERSION`, `TZ`)
are promoted to ENV in the Dockerfile so lifecycle commands and shells can
resolve them. Without this promotion, Docker ARG values are build-only and
silently no-op at lifecycle time.
Workspace setup
- `postCreateCommand` chowns the four workspace `node_modules` volumes
(Docker creates them root-owned), then installs in dependency order:
root → gitnexus-shared (install + build) → gitnexus → gitnexus-web. The
shared package must build before its consumers (`file:../gitnexus-shared`).
Ports
- 5173 (Vite dev) and 4173 (Vite preview) auto-forwarded.
- 4747 (`gitnexus serve`) marked `requireLocalPort: true` because
`gitnexus-web/src/services/backend-client.ts` hardcodes
`http://localhost:4747` as the default backend URL; a remapped port would
silently break the web UI.
VS Code integration
- Recommended extensions: `anthropic.claude-code`,
`dbaeumer.vscode-eslint`, `esbenp.prettier-vscode`, `eamodio.gitlens`.
- Settings: format-on-save with Prettier, ESLint auto-fix on save, zsh as
default terminal profile, persistent zsh history via `HISTFILE` →
`/commandhistory`.
Documentation
- `.devcontainer/README.md` covers WSL2 setup (clone inside WSL2 for IO and
file-watcher reliability), first-time auth flows for each CLI, port-
forwarding notes, LadybugDB container limitations, and the bumping
procedure for each CLI version.
- `CONTRIBUTING.md` gets a "Containerized development (optional)"
subsection pointing at the devcontainer README.
Deferred to a follow-up PR
- Opt-in egress firewall (originally planned as a fourth implementation
unit). The Dev Containers spec makes `runArgs` static — toggling
`NET_ADMIN`/`NET_RAW` capabilities cleanly requires either a separate
`devcontainer-firewall.json` profile or an `initializeCommand`-generated
overlay. Keeping this PR focused on the working baseline.
- Codespaces-specific tuning (works incidentally when the firewall is off,
not actively tested).
- Inside-container Playwright e2e (needs Chromium libs not in the base
image).
Verification deferred to user
- This change introduces a new dev tooling artifact. Validate by running
`docker build .devcontainer/`, opening the repo in VS Code via
"Dev Containers: Reopen in Container", confirming `claude --version`,
`codex --version`, `cursor-agent --version` resolve inside the container,
and `cd gitnexus && npm run test:unit` runs clean against the
named-volume `node_modules`.
* fix(devcontainer): make interactive login the default auth path for all CLIs
The previous `containerEnv` injected `CURSOR_API_KEY: "${localEnv:CURSOR_API_KEY}"`.
When the host had no `CURSOR_API_KEY` set, this resolved to an empty
string and Docker injected `CURSOR_API_KEY=""` into the container.
Cursor CLI treats a set-but-empty `CURSOR_API_KEY` as "use this key"
rather than "fall back to stored login", which silently broke
`cursor-agent login` on the most common path — users who hadn't
explicitly opted into API key auth.
Drop `CURSOR_API_KEY` from `containerEnv`. Login is now the
unconditional default for all three CLIs (Claude Code, Codex CLI,
Cursor CLI); the named-volume + Dockerfile-chown pattern keeps
credentials persistent across container rebuilds for every login path.
Reorganize the README's auth section to put login first for all three
CLIs uniformly (matching the new behavior) and move API key
authentication into a separate "Alternative" section for CI/headless
use. Document that API keys are intentionally not auto-propagated from
the host and explain the export-in-shell or VS Code dotfiles-repo paths
for users who want them. Update the troubleshooting row to reflect the
new design.
* fix(devcontainer): install gitnexus-web before gitnexus in postCreateCommand
The previous order (root → gitnexus-shared → gitnexus → gitnexus-web)
broke at the `gitnexus` install step because `gitnexus`'s `prepare`
script runs `scripts/build.js`, which compiles `gitnexus-web` whenever
its source tree exists. In the devcontainer the entire workspace is
bind-mounted, so `gitnexus-web/` is present from the start — but its
`node_modules/` wasn't yet, so `tsc -b` failed with:
error TS2688: Cannot find type definition file for 'vite/client'
error TS2688: Cannot find type definition file for 'node'
Reorder so `gitnexus-web` installs before `gitnexus`. Verified
end-to-end via `npx @devcontainers/cli up`: container builds clean,
all three CLIs (Claude 2.1.153, Codex 0.134.0, Cursor) respond, and
`npx tsc --noEmit` inside `/workspace/gitnexus` passes.
Production Dockerfiles (`Dockerfile.cli` etc.) don't hit this because
they only COPY `gitnexus/` + `gitnexus-shared/`, so `gitnexus-web/`
doesn't exist at install time and `scripts/build.js` skips the web
step. The devcontainer's full-tree bind mount changes that calculus.
* fix(devcontainer): clear stale .husky/_ before npm install
When `npm install` runs the root `prepare` script (husky), husky tries
to copyfile `node_modules/husky/husky` → `.husky/_/h`. On Docker Desktop
Windows bind mounts, if `.husky/_/` already exists from a prior
container run, the new container's `node` user can't overwrite it via
the bind mount's permission translation and the install fails with:
Error: EPERM: operation not permitted, copyfile
'/workspace/node_modules/husky/husky' -> '.husky/_/h'
Drop `.husky/_` defensively in `postCreateCommand` before `npm install`
so husky always starts from a clean slate. `.husky/_` is a husky
runtime cache (gitignored), so removing it has no effect on the repo —
husky regenerates it. No-op for WSL2-side checkouts (where this class
of bind-mount permission collision doesn't occur).
Add a troubleshooting row to `.devcontainer/README.md` covering the
manual recovery (`rm -rf .husky/_` on the host) and the long-term fix
(clone in WSL2 — Windows-side bind mounts will keep biting on this
kind of issue across rebuilds with different UID alignment).
* feat(devcontainer): bind-mount host CLI config dirs for plugin/skill/memory sync
Switch the credential/config mounts from per-devcontainer named volumes
to bind mounts of `${localEnv:HOME}/.claude`, `~/.codex`, and
`~/.cursor`. Effect inside the container:
- Authentication is shared with the host. If you've already run
`claude login` / `codex login --device-auth` / `cursor-agent login`
on the host, you're already authenticated in the container.
- Plugins, skills, agents, memory, and settings sync both ways. Install
a plugin in the container, it shows up on the host; add a custom
agent on the host, the container sees it immediately.
- All devcontainers on the host share the same CLI state, mirroring
how host shells already share it. (Per-workspace isolation of plugins
was never a stated requirement; the previous per-devcontainer named
volumes leaked nothing useful.)
Add `.devcontainer/ensure-host-config-dirs.cjs` and wire it as
`initializeCommand`. It runs on the host before container create and
guarantees `~/.claude`, `~/.codex`, `~/.cursor` exist, so Docker doesn't
reject the bind mount when a CLI has never been used on this host.
Cross-platform via Node `os.homedir()` + `fs.mkdirSync({recursive: true})`;
idempotent; no third-party deps.
Update `.devcontainer/README.md`:
- New "How CLI state is shared with your host" section explaining the
bind-mount model up front so users know their host plugins/skills/
memory carry into the container.
- Mark first-time-login section as skippable when the user is already
authenticated on the host.
- Note the high-trust escape hatch: replace the three bind mounts with
`type=volume` named volumes if the host/container trust boundary
needs to be separated (Anthropic's reference pattern for enterprise).
- Replace the obsolete "rm named volume" troubleshooting row with one
that covers EACCES/EPERM on the host-bind-mount path.
* refactor(devcontainer): address ce-code-review findings (P0 + 4 × P1 + 8 × P2 + 2 × P3)
Walkthrough resolution of the 16-finding ce-code-review on PR #1875. 15 of
16 findings applied; one (F12, Anthropic Feature floating tag) was
superseded by F6's Feature removal.
P0
- F1: WSL2 is now REQUIRED for Windows hosts, not just recommended.
${localEnv:HOME} resolves to empty string on Windows-native (no HOME env
var) — bind mounts then point at /.claude, /.codex etc. and silently
break. ensure-host-config-dirs.cjs wrote to USERPROFILE-derived paths
via os.homedir(), so the two surfaces disagreed about which env var was
"home" on Windows. README header reframed; "Windows 11 — WSL2 is required"
section explains the mismatch concretely.
P1
- F2: Workspace `node_modules` volume names now include `-${devcontainerId}`
so two GitNexus checkouts on the same host (~/work/GitNexus and
~/projects/GitNexus) don't share volumes and corrupt each other's
installs.
- F3 + F5: `postCreateCommand` extracted to `.devcontainer/post-create.sh`
with `set -euo pipefail` and six labeled echo steps so failure logs
name the step instead of an opaque &&-chain index. Chown step extended
to cover /home/node/.npm, /commandhistory, and /home/node/.local — these
named-volume mount points were owned by build-time UID 1000 but the
container's `node` is re-IDed at runtime by updateRemoteUserUID on
non-1000 Linux hosts, leaving them unwritable until now.
- F4: Cursor installer downloaded to a temp file with curl --retry +
--max-time; sha256 logged to build output before execution so drift
across rebuilds is visible in CI logs. Full hard-pin (to a versioned
downloads.cursor.com tarball with verified sha256) tracked as a
follow-up in README "What's not included".
P2
- F6: Anthropic Feature replaced with a direct
`npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}` so
CLAUDE_CODE_VERSION actually pins the installed binary (the Feature
ignored the ARG and pulled latest at install time). Honors the
earlier "pin known-good versions" decision and resolves F12's
floating-tag concern for this Feature.
- F7: Dockerfile ARG defaults dropped for the three version vars;
`devcontainer.json` `build.args` is now the single source of truth.
Standalone `docker build .devcontainer/` must pass --build-arg.
- F8: ensure-host-config-dirs.cjs deleted; `initializeCommand` now uses
POSIX `mkdir -p` + `touch ~/.gitconfig` directly, dropping the
host-Node-on-PATH prerequisite that broke on fresh Windows+Docker
Desktop installs without Node.
- F9: ~/.gitconfig bind-mounted read-only so `git commit` inside the
container uses the host's user.name / user.email. Read-only so
container-side `git config --global` doesn't leak to host.
- F10: ~/.config/gh bind-mounted (read-write) so `gh pr create` /
`gh pr checks` / `gh issue create` work inside the container without
re-auth. AGENTS.md's commit + PR workflow now fully functional for
agents inside the container.
- F11: CLAUDE_CONFIG_DIR removed from Dockerfile ENV; canonical value
lives only in devcontainer.json containerEnv. Eliminates the two-file
edit risk.
- F13: Mounts comment now documents per-instance vs per-workspace-name
scoping rationale so future contributors don't guess.
- F14: README "Trust boundary, concretely" paragraph names the exfil
path explicitly (malicious npm postinstall → OAuth tokens →
~/.claude/projects/<workspace>/memory/MEMORY.md secrets) and lists
vendor-side rotation runbook entries.
P3
- F15: Dockerfile pre-create + chown of /home/node/.claude, .codex,
.cursor dropped — those paths are bind-mounted, which fully shadows
any image-side ownership. Only .npm, .local, /commandhistory still
benefit from the pre-create.
- F16: README "Bumping CLI versions" section rewritten against the
post-F6 reality: CLAUDE_CODE_VERSION and CODEX_VERSION are real
pins; CURSOR_VERSION is informational only.
Verified locally: `docker build .devcontainer/ --build-arg ...` succeeds.
Smoke-tested image: `claude --version` (2.1.153), `codex --version`
(0.134.0), `cursor-agent --version` all resolve as the non-root `node`
user; named-volume mount points (/home/node/.npm, /commandhistory) are
node-owned at build time so non-1000 host UIDs get the post-create.sh
chown fix instead of EACCES.
* fix(devcontainer): cross-platform initializeCommand + soften Windows-native posture
The previous commit's `initializeCommand` was POSIX-only (`mkdir -p $HOME/...`).
VS Code on Windows runs the host shell as `cmd.exe /c ...`, which can't
parse POSIX syntax — `$HOME` doesn't expand, `mkdir -p` errors, the init
fails with `The syntax of the command is incorrect`, and container
creation aborts before Docker is invoked.
Switch `initializeCommand` to the spec's OS-keyed object form:
- linux/darwin (covers WSL2 because VS Code runs initializeCommand in
the WSL shell when attached via the WSL extension): POSIX mkdir+touch,
as before
- win32: PowerShell snippet that creates the same directories under
$USERPROFILE and touches the gitconfig if missing
Soften the README's hard "WSL2 required" framing from the previous
commit. Reality per `@devcontainers/cli read-configuration` output:
`${localEnv:HOME}` on Windows-native resolves to `C:\Users\<name>`
(VS Code falls back to USERPROFILE), so the bind mount sources are
valid Windows paths and Docker Desktop handles the translation. The
earlier `accessing specified distro mount service` failure was a
separate Docker Desktop WSL-integration issue, not a HOME-resolution
issue. Windows-native works; it's just slower with more bind-mount
permission edge cases (the husky/_/h EPERM class). The README now
explains the tradeoff and steers toward WSL2 for performance + file
watchers + permission reliability, rather than blocking Windows-native
checkouts outright.
Update the troubleshooting row to reflect the new posture.
* fix(devcontainer): Node-based initializeCommand; bind-mount .ssh + .config/git
Two fixes bundled:
1. The previous commit's OS-keyed `initializeCommand` object was based
on a misread of the Dev Containers spec. The object form on command
properties is **named parallel tasks**, not OS dispatch — VS Code ran
all three keys in parallel via cmd.exe on Windows, the POSIX branches
failed, and container creation aborted before Docker was invoked.
Restore the single-string Node-based form:
`node .devcontainer/ensure-host-config-dirs.cjs`. Node works
identically in cmd.exe on Windows and bash/zsh on Linux/macOS/WSL,
and `os.homedir()` respects $HOME on POSIX and %USERPROFILE% on
Windows. The script is idempotent (mkdirSync recursive is a no-op
for existing dirs; touch is gated on .gitconfig existence).
Document Node ≥18 on the host as the only host-side prerequisite
beyond Docker Desktop and the VS Code Dev Containers extension.
Anyone running Claude Code on the host already has it.
2. Extend the host-bind mount surface with `~/.ssh` and `~/.config/git`,
both read-only:
- `~/.ssh` lets commit signing + push over SSH remotes work inside
the container without copying private keys. Read-only mount means
container code can read keys but can't modify or delete them.
(Threat: a malicious dep can still read private keys from inside
the container; the read-only mount narrows write-side blast
radius, not read-side. Documented in the trust-boundary section.)
- `~/.config/git` covers XDG-style git config (`~/.config/git/config`,
`~/.config/git/ignore`, `~/.config/git/attributes`) for users who
keep settings there instead of `~/.gitconfig`. Read-only, same as
`~/.gitconfig`.
Update the CLI-state-sharing table and trust-boundary paragraph to
reflect the expanded surface.
Re-adds .devcontainer/ensure-host-config-dirs.cjs (deleted before the
OS-keyed attempt).
* fix(devcontainer): fail-fast on Windows-native with HOME-not-set diagnostic
The previous commit's "Windows-native works" softening was wrong. VS Code
on Windows-native resolves `${localEnv:HOME}` by reading the host shell's
HOME env var, and cmd.exe has no HOME set — the bind sources collapse to
`/.claude`, `/.codex`, etc., and Docker errors:
Error response from daemon: invalid mount config for type "bind":
bind source path does not exist: /.claude
The @devcontainers/cli output that prompted the softening was misleading
because I ran it from a Bash session with HOME already set, not from VS
Code's cmd.exe call context. The original Finding-1 P0 — that Windows-
native silently breaks the bind-mount feature — was correct.
Three changes:
1. `ensure-host-config-dirs.cjs` detects the failure mode early:
`if (process.platform === 'win32' && !process.env.HOME)` prints a
targeted error message naming the root cause (cmd.exe has no HOME →
${localEnv:HOME} resolves empty → bind sources fail) and a step-by-step
pointer to set up WSL2. Exits 1 so VS Code surfaces it as a clean
container-creation failure, not the cryptic Docker bind-mount error.
2. README header reverted to "Windows 11 via WSL2" only (not "and
Windows-native"). The "Windows 11 — WSL2 is required" section names
the specific HOME-resolution mismatch concretely so future readers
understand why the constraint exists.
3. Troubleshooting table gets a new row for the `ERROR: GitNexus
devcontainer requires WSL2` message pointing at the setup section.
* feat(devcontainer): support Windows-native via auto setx HOME on first run
Reverses the "WSL2 required on Windows" posture. Windows-native now
works after a one-time auto-handled setup.
The root cause of the bind-mount failure: VS Code resolves
`${localEnv:HOME}` by reading its own process env, and Windows doesn't
set `HOME` by default — Windows uses `USERPROFILE`. So the bind sources
were collapsing to `/.claude`, `/.codex`, etc., and Docker rejected them.
`ensure-host-config-dirs.cjs` now handles this automatically on Windows
hosts where `HOME` is unset:
1. Runs `setx HOME "%USERPROFILE%"`, which writes to the user-level
Windows environment (HKCU\Environment) — no admin required. Every
future user process inherits HOME from there.
2. Prints a clear one-time setup banner explaining the user needs to
fully restart VS Code (File > Exit, not just close the window) for
VS Code to pick up the new env at its next startup.
3. Exits 1 so VS Code surfaces this as a clean container-create failure
instead of letting Docker error opaquely later.
On the second Reopen-in-Container attempt, `HOME` is now set in VS
Code's env, the script skips the setup block, creates the bind-mount
source dirs, and the container builds normally. Subsequent rebuilds
have no extra steps.
Mac, Linux, and WSL2 hosts have `HOME` set by the shell, so the new
block is a no-op there. Same `devcontainer.json` works across all
supported hosts.
README rewritten to reflect the new posture:
- Header lists Windows 11 (native) as a supported host alongside macOS,
Linux, and WSL2, with a note that Windows-native gets a one-time
HOME setup handled by the initializeCommand.
- New "Windows 11 setup" section walks through the auto-handled setup
flow + a manual `setx HOME "%USERPROFILE%"` fallback for users who
want to do it themselves.
- "Known trade-offs of Windows-native vs WSL2" subsection lays out the
Docker Desktop Windows bind-mount edge cases (file watchers, npm
install perf, husky/_ EPERM) so users opting into Windows-native do
so eyes-open. WSL2 remains documented as the faster path for users
who want it, but it's no longer the only supported one.
- Troubleshooting table gets two new rows: the one-time setup banner
(with "what to do" instructions) and the residual `bind source path
does not exist` case (run setx manually + fully exit VS Code).
* fix(devcontainer): drop ~/.gitconfig bind mount; defer to VS Code auto-copy
VS Code's Dev Containers extension auto-copies the host's gitconfig into
the container at attach time using `(dd ...) >> /home/node/.gitconfig`.
A read-only bind mount of ~/.gitconfig blocks that write, so attach
failed with `cannot create /home/node/.gitconfig: Read-only file system`.
Making it read-write would let the append succeed, but the bind mount
means the host file and the container file are the same file — VS Code's
append would double the host gitconfig contents on every container
start.
Drop the ~/.gitconfig bind mount entirely. VS Code's auto-copy is the
purpose-built mechanism for this, gives the container the host's
user.name / user.email transparently, and avoids both the read-only
write failure and the append-duplication trap. The container ends up
with a writable /home/node/.gitconfig that's a copy of the host's, not
a mount.
The remaining six bind mounts (.claude, .codex, .cursor, .ssh, .config/git,
.config/gh) keep their existing modes — XDG-style git config under
~/.config/git is unaffected by VS Code's auto-copy (which only targets
~/.gitconfig), so its read-only bind mount stays.
Also remove the `.gitconfig` touch from ensure-host-config-dirs.cjs
(now unnecessary) and update the README CLI-state table, sharing
explanation, and troubleshooting row to reflect that gitconfig flows
in via VS Code auto-copy rather than the bind mount.
* feat(devcontainer): bind-mount ~/.docker, ~/.aws, ~/.azure for agent workflows
Extend the host bind-mount surface so coding agents inside the container
inherit cloud + container-registry auth from the host without any
per-container setup:
- ~/.docker (read-write) — Docker registry auth (config.json) + buildx
config. Container-registry pushes (ghcr.io, docker.io) from inside the
container pick up host `docker login` state. Read-write because the
Docker CLI refreshes credential-helper tokens.
- ~/.aws (read-only) — AWS CLI / SDK credentials. Read-only because
rotating creds typically happens via the host. Empty on this dev box,
so forward-compatible: the moment you `aws configure` on the host the
container picks it up on the next rebuild.
- ~/.azure (read-only) — Azure CLI credentials. Same pattern as ~/.aws.
`ensure-host-config-dirs.cjs` extends to mkdir these three on init so
the bind mounts always have a valid source even if a CLI has never been
used on this host.
The Docker CLI itself isn't installed in the container by default — the
~/.docker/ mount is inert until you add `docker-outside-of-docker:1` or
similar Feature. README now calls this out under "What you still don't
have inside the container" so it's obvious which CLIs are agent-ready
and which need a feature add to become useful.
README updates:
- Bind-mount table gains a "Why" column and rows for the three new
mounts, making it clear at a glance what each one enables.
- Trust-boundary section lists Docker registry tokens, AWS, and Azure
creds in the read-side exfil path so the threat model stays honest as
the credential surface grows.
- New subsection lists not-included CLIs (Docker, AWS, Azure, gcloud,
kubectl, private-npm) with the exact Feature ID or mount snippet
needed to enable each — turns "I want my agent to do X" into a
one-line config change.
Verified locally: `npx @devcontainers/cli read-configuration` resolves
all 9 host bind mounts to valid C:\Users\<name>/* paths on Windows.
* refactor(devcontainer): hybrid AI CLI config — read-only host share + per-container credentials
Restructure the Claude Code / Codex / Cursor mount topology to fix the
silent first-run-UI bug surfaced in PR testing, and to harden against
the host-write-through escape class the previous bind-mount design
exposed.
The actual root cause of the first-run wizard firing on the user's
screenshot — confirmed via three parallel research agents (best
practices, framework docs deep dive of the OpenAI Codex Rust source,
adversarial design review) — was NOT a credential permission check.
Claude Code splits state across `~/.claude/.credentials.json` AND
`~/.claude.json` (a FILE at $HOME, sibling of the `.claude/` dir).
The latter holds `hasCompletedOnboarding`, `userID`, `oauthAccount`
metadata, MCP user-scope config, and per-project trust state — and
Claude Code reads it at literal `$HOME/.claude.json`, not via
`CLAUDE_CONFIG_DIR`. The previous design mounted `~/.claude/` but
left `~/.claude.json` outside the topology entirely, so every container
started with a missing onboarding-state file and re-ran the wizard.
Confirmed by tfvchow/field-notes-public#10:
"Persisting .credentials.json alone is NOT sufficient. Without
.claude.json, Claude Code treats the session as a fresh install and
prompts for login regardless of valid credentials being present."
The new topology:
**Mounts**
- `${localEnv:HOME}/.claude` → `/host/.claude` (read-only bind)
- `${localEnv:HOME}/.codex` → `/host/.codex` (read-only bind)
- `${localEnv:HOME}/.cursor` → `/host/.cursor` (read-only bind)
- `${localEnv:HOME}/.claude.json` → `/host/.claude.json` (read-only bind)
- `claude-config-${devcontainerId}` → `/home/node/.claude` (named volume)
- `codex-config-${devcontainerId}` → `/home/node/.codex` (named volume)
- `cursor-config-${devcontainerId}` → `/home/node/.cursor` (named volume)
**containerEnv** gains `CODEX_HOME=/home/node/.codex` (Codex's own env
override, per its public Rust source). `CLAUDE_CONFIG_DIR=/home/node/
.claude` was already set.
**`post-create.sh`** stages the named volumes on first run:
- Symlinks shareable subdirs from `/host/.claude` into the named volume:
`plugins/`, `skills/`, `agents/`, `memory/`, `commands/`. Codex gets
`config.toml` symlinked. Cursor has no shareable subdirs (cli-config
.json conflates auth and settings).
- Copies `.credentials.json`, `auth.json`, `cli-config.json` on first
run with `chmod 600`. After first run, container manages its own
refresh; host's credentials untouched.
- Copies `~/.claude.json` on first run (with stub
`{"hasCompletedOnboarding":true,"installMethod":"global"}` fallback
for hosts that haven't run Claude Code). This is the fix for the
observed onboarding-wizard loop.
`ensure-host-config-dirs.cjs` now also touches `~/.claude.json` on the
host if missing, so the bind mount has a valid source on hosts that
have never run Claude Code.
**Why read-only + named volume vs. the previous full bidirectional
bind mount:**
1. **Host filesystem write-through escape, eliminated.** Previous
design symlinked `plugins/`, `agents/`, `skills/` write-through
into the host's `~/.claude/` — a malicious npm package in the
workspace dep tree could drop `agents/evil.md` into the host's
config, which the next host Claude session would auto-load. The
read-only `/host` mount blocks this; container compromise no
longer persists across teardown via host-side autoload.
2. **Windows bind-mount perm-flattening, sidestepped.** Files
surfaced through a Docker Desktop Windows bind mount appear as
`root:root` mode `777`. Credentials in the named volume come with
proper Linux ownership and `chmod 600` — what each CLI expects on
write (none enforces on read, but write-side hygiene matters for
the host's understanding of "where credentials live").
3. **No `ide/` lock-file collisions.** Previous design symlinked
`~/.claude/ide/` write-through, including per-PID lock files. Host
PID and container PID namespaces are unrelated → lock-file PIDs
misclassify dead processes as alive. Skipping `ide/` keeps lock
files container-local.
4. **No `projects/` ghost dirs.** Host encodes the workspace path as
`D--development-coding-GitNexus`, container as `-workspace`.
Bidirectional `projects/` symlinks would split memory and session
state across two ghost project dirs for what is conceptually the
same project. Skipping `projects/` keeps per-project state
container-local; host's projects/ stays untouched.
5. **No `settings.json` version drift.** Container is pinned to a
specific Claude Code version (`CLAUDE_CODE_VERSION` build arg);
host floats with auto-update. Bidirectional `settings.json` writes
produced silent schema rollback. Skipping settings.json keeps each
side authoritative for its own version.
**README** rewritten in the same section to describe the new topology
honestly: what's shared, what isn't, the OAuth refresh-token
divergence between host and container, per-CLI quirks (macOS Keychain
storage, Cursor's known upstream in-container auth bug, Codex
keyring storage). Trust-boundary section updated to name the threat
model accurately — same read surface as before (malicious dep can
still READ all credentials), but write-through into host plugin/agent
dirs is now blocked.
Verified locally: `@devcontainers/cli read-configuration` resolves all
19 mounts correctly on Windows, `post-create.sh` parses, and
`ensure-host-config-dirs.cjs` idempotently touches `~/.claude.json`.
Research backing this design:
- Anthropic Claude Code devcontainer docs (named-volume pattern):
https://code.claude.com/docs/en/devcontainer
- tfvchow/field-notes-public#10 (both files required):
https://github.com/tfvchow/field-notes-public/issues/10
- anthropics/claude-code#29029 (VS Code extension strips
hasCompletedOnboarding):
https://github.com/anthropics/claude-code/issues/29029
- OpenAI Codex Rust source (no read-side perm check):
https://github.com/openai/codex/blob/main/codex-rs/login/src/auth/storage.rs
- Cursor CLI in-Docker auth issue:
https://forum.cursor.com/t/cursor-agent-authentication-issue-inside-docker/143995
* fix(devcontainer): resync AI CLI state from host on every container-create
Two bugs were causing Claude Code to fire the onboarding wizard inside the
container even with valid host credentials:
1. Missing the second state file. Claude Code 2.1.x writes a small `.claude.json`
INSIDE `CLAUDE_CONFIG_DIR` (carrying migration tracking + userID), not just the
one at `$HOME/.claude.json`. If the userIDs in the two files disagree, Claude
treats the session as inconsistent and re-onboards. The previous post-create.sh
only copied the `$HOME` one.
2. First-run guards (`[ ! -e $dst ]`) skipped the copy when stale named volumes
from earlier rebuilds still had the prior session's state in them, leaving the
container desynced from the host.
Replace `copy_on_first_run` with `sync_from_host` that always overwrites from
host on container-create. `link_readonly_share` now clears stale non-symlink dst
entries before linking. Copies both `$HOME/.claude.json` and
`$CLAUDE_CONFIG_DIR/.claude.json` so userIDs stay aligned. Container can still
mutate its own state between rebuilds; resync only happens on rebuild
(postCreate boundary).
* docs(devcontainer): document sync-from-host design + dual-source auth flow
README still described the old "first-run copy" behavior. After the
post-create.sh change to always-sync-from-host, the design works either
direction:
- Log in on host → next container-create syncs the credentials into the
named volume.
- Log in inside the container → the named volume persists the login across
rebuilds; the host has no source to overwrite from, so it stays alone.
Also documents the two-Claude-state-files trap (`$HOME/.claude.json` AND
`$CLAUDE_CONFIG_DIR/.claude.json`, both with the same userID required), and
the volume-deletion recovery path for stale named volumes carried over
from earlier rebuilds.
* fix(devcontainer): full plugin/config parity by dropping CLAUDE_CONFIG_DIR + syncing settings.json
Two changes that together give the container the same plugins and configs
as the host for all three AI CLIs (login stays per-container):
1. Drop CLAUDE_CONFIG_DIR from containerEnv. The named-volume mount target
`/home/node/.claude` already matches Claude's default `~/.claude`, so
the env var added no behavior — but setting it changed which file
Claude reads `hasCompletedOnboarding` from. With it set, Claude reads
`$CLAUDE_CONFIG_DIR/.claude.json` (the small identity-only file that
does NOT carry `hasCompletedOnboarding`); without it, Claude reads
`$HOME/.claude.json` (the big onboarding-state file that does). The
wizard fires every container-create when set, skips when unset.
2. Sync `settings.json` from host (Claude) + symlink `memories/` and
`skills/` from host (Codex). Theme + `enabledPlugins` +
`extraKnownMarketplaces` live in `settings.json` — without syncing
it, the theme picker fires and host-installed plugins stay disabled
even though their files are symlinked in. Codex's `memories/` and
`skills/` are the symmetric Codex user-installed surface, now shared
the same way Claude's plugins/skills/agents/memory/commands are.
Cursor stays as-is — `cli-config.json` conflates auth+settings (already
synced), and there's no separate plugin surface to mirror.
Login details remain per-container by design (acceptable to re-login on
rebuild). Everything else — plugins, skills, agents, memory, MCP user-
scope config, project trust, theme, plugin enablement — now matches
host on every container-create.
* refactor(devcontainer): hybrid RW bind + per-container creds — fixes EROFS on in-container plugin install
The previous Option B topology (RO host stage + named volume + symlinks
into the volume) made `/plugin marketplace add` inside the container fail
with EROFS — the symlinks pointed at a read-only mount, so Claude
couldn't create new marketplace dirs. Switch to a hybrid: shareable
content (plugins/skills/agents/memory/commands/settings.json/$HOME/.claude.json
for Claude; config.toml/memories/skills for Codex) gets a direct RW bind
from host so reads and writes go bidirectionally; credentials + the
small identity file stay in per-container named volumes so logout in
container doesn't log out host.
Mount precedence does the heavy lifting: the named volume mounts at
/home/node/.<cli> first, then sub-path bind mounts overlay specific
sub-paths. Container's view at /home/node/.claude/plugins/ is the host
dir; container's view at /home/node/.claude/.credentials.json is the
named volume's file.
What this gives you:
- /plugin marketplace add in container = installed on host
- New skill on host = visible in container immediately (no rebuild)
- claude logout in container = host stays logged in
- compound-engineering plugin enabled on host = enabled in container
- Theme picker fires once (or never if host has theme set)
What it costs:
- Write-through: a compromised npm dep in workspace deps can write to
host ~/.claude/{plugins,skills,agents,memory,commands}/. Documented
trade-off; for personal dev, accepted. Credentials still per-container.
post-create.sh becomes much simpler — only syncs the four credential
files from host into the named volumes. No more symlink dance, no more
state-file merging.
ensure-host-config-dirs.cjs gains the new bind sources: the shareable
subdirs and settings.json/config.toml files get mkdir/touched on host
so Docker doesn't reject the mount when a CLI has never been used.
* fix(devcontainer): translate host plugin registry paths to Linux on rebuild
The previous topology bind-mounted the entire `~/.claude/plugins/`
directory from host. That brought through plugins, marketplaces, and
extracted cache content correctly — but ALSO brought through the
registry JSONs (`known_marketplaces.json`, `installed_plugins.json`,
`plugin-catalog-cache.json`) which carry absolute OS-native paths:
"installLocation": "C:\Users\gergo\.claude\plugins\marketplaces\X"
"installPath": "C:\Users\gergo\.claude\plugins\cache\Y\Z"
Claude in the Linux container fails to resolve these Windows paths and
reports `Marketplace X failed to load: cache-miss`.
Split the topology:
- `plugins/marketplaces/` (git clones) and `plugins/cache/` (extracted
plugin files) stay bidirectional RW binds — content is path-independent.
- Registry JSONs move into the per-container named volume. post-create.sh
reads host's versions, rewrites any absolute path ending in
`/.claude/plugins/<rest>` (Windows `C:\Users\...` and POSIX
`/Users/...` / `/home/...` patterns) to `/home/node/.claude/plugins/<rest>`,
and writes the translated result to the volume.
What this gets you:
- Plugin installed on host → next container rebuild has it (translated).
- Plugin installed inside container → lives in volume registry; lost on
rebuild (consistent with credentials model). Re-install on host for
persistence.
ensure-host-config-dirs.cjs now also creates `plugins/marketplaces/` and
`plugins/cache/` on host if absent (Docker rejects bind mounts whose
source doesn't exist).
* fix(devcontainer): clean stale plugin/skill symlinks from prior design before writes
A user upgrading from Option B (read-only host stage + symlinks) to the
current hybrid RW-bind topology hit EROFS in post-create.sh when the
plugin registry path-translator tried to write
`/home/node/.claude/plugins/known_marketplaces.json`. The named volume
still carried `/home/node/.claude/plugins -> /host/.claude/plugins`
(Option B's symlink). The new design's sub-path bind mounts at
`plugins/marketplaces` and `plugins/cache` overlay through the symlink,
but writes to the parent dir itself resolve via the symlink to the RO
host stage and fail.
Drop any leftover symlinks at known target paths early in step 2 so the
mkdir/writes that follow land in the volume.
* refactor(devcontainer): split workspace-deps to updateContentCommand
post-create.sh was doing two unrelated jobs: workspace dependency install
(four `npm install` runs in topological order) and AI CLI credential
sync. They have different lifecycle needs — deps should re-run when
lockfiles change, AI sync should run once per container — but both were
gated on container-create.
Per Dev Container spec lifecycle, `updateContentCommand` is the right
hook for workspace deps: runs at container-create AND on content
changes (lockfile updates). `postCreateCommand` is right for AI CLI
sync: container-create only.
Move steps 3-7 (husky cleanup + four `npm install` runs) into
install-deps.sh wired as `updateContentCommand`. Split the chown step
too — install-deps owns workspace-side dirs (node_modules volumes,
~/.npm), post-create owns AI-side dirs (~/.claude, ~/.codex, ~/.cursor,
/commandhistory, ~/.local). Each script now has one concern.
post-create.sh drops from ~187 lines to 148; install-deps.sh is 56 lines
new. Faster rebuilds when nothing about deps changed (the credential
sync + path translation work still runs every container-create, but the
npm install dance no longer does).
Research backing (no other simplification applies):
- Anthropic's reference devcontainer uses pure named volumes; no
host-state inheritance pattern is published.
- Path translation has no upstream fix (issues #21916, #10379 closed
without resolution). Our Node rewrite is the workaround.
- pnpm workspaces (`pnpm -r install`) would replace the four installs
with one command, but that's a real refactor (touches
gitnexus/scripts/build.js + 4 package.json files); deferred.
- `HUSKY=0` in containerEnv would drop the `rm -rf .husky/_` hack, but
would also stop pre-commit hooks from firing inside the container;
deferred.
* fix(devcontainer): drop single-file binds — fixes Codex `batchWrite failed in TUI`
On Docker Desktop Windows the named volumes are ext4 (`/dev/sdd`) while
single-file bind mounts from the Windows host land as 9p (drvfs).
Different filesystems → atomic config writes (write `foo.tmp`, then
rename onto `foo`) trip EXDEV `inter-device move failed` /
`Device or resource busy`.
Codex's TUI surfaces this as `config/batchWrite failed in TUI` when
saving model preference. Claude's writes to settings.json / .claude.json
fail the same way, silently.
Reproduction in container:
$ echo x > /tmp/foo.toml; mv /tmp/foo.toml /home/node/.codex/config.toml
mv: inter-device move failed: ... Device or resource busy
Fix: drop the three single-file bind mounts. Sync host's versions into
the named volume on container-create via `sync_from_host` (same pattern
already used for credentials). Atomic rename within the volume works
because everything is ext4.
Trade-off: container writes to these files no longer propagate to host;
they stay in the volume until next rebuild, which re-syncs from host.
Host is source of truth on rebuild — same model as credentials. Plugin/
skill/agent/memory/command DIRS still bind-mount bidirectionally (atomic
writes within a dir bind stay on one filesystem, no EXDEV).
Files affected:
- ~/.codex/config.toml
- ~/.claude/settings.json
- ~/.claude.json (HOME-level — added `/host/.claude.json` RO mount back
for sync_from_host to read)
* chore(autofix): apply prettier + eslint fixes via /autofix command
* feat(devcontainer): Codex + Cursor plugin/config host parity with Claude
Codex plugins installed in the container never reached the Windows host
because, unlike Claude, the Codex plugin tree wasn't bind-mounted —
only memories/ and skills/ were. Verified via live /proc/mounts: Claude
binds 6 shareable dirs (incl. plugins/marketplaces + plugins/cache),
Codex bound 2. So `codex plugin add` wrote into the ext4 named volume
and stayed there.
Codex changes:
- Bind the WHOLE ~/.codex/plugins dir + ~/.codex/prompts (plus existing
memories/skills). Strace of two real `codex plugin add` runs proved
the installer stages INSIDE plugins/cache/<marketplace>/ and renames
intra-dir, so a single 9p bind of plugins/ keeps the rename intra-fs —
no EXDEV (the bug that broke single-file binds). .tmp/ stays on the
volume (it's the cross-fs staging source). No path translation needed:
Codex enablement lives in config.toml as git URLs, not FS paths.
- Verified live: `codex plugin add compound-engineering@...` now writes
through to C:\Users\...\.codex\plugins\cache\ on the Windows host, and
host-created files appear in the container (bidirectional).
Cursor changes (review found cursor-agent has a real plugin surface, not
editor-only — Cursor 2.5 Marketplace shared by IDE + CLI):
- Bind plugins/marketplaces, plugins/local, rules, commands, agents,
skills (dir binds, EXDEV-safe).
- Copy-on-create mcp.json (single file → EXDEV-unsafe as bind), alongside
the existing cli-config.json.
- Translate plugins/installed_plugins.json (carries absolute Windows
paths like Claude's) — generalized the existing path-rewrite to run
for both Claude and Cursor.
- hooks.json deliberately NOT shared (runs shell commands → supply-chain
surface); documented as opt-in.
ensure-host-config-dirs.cjs pre-creates all new host bind sources.
post-create.sh defensive symlink cleanup extended to the new Codex/Cursor
paths. README updated with the accurate per-CLI share/sync/translate
matrix.
Design adversarially verified (straced installs, EXDEV primitive tests,
sqlite-under-bind check, path-encoding check) before implementing.
* fix(devcontainer): resolve ce-code-review findings (doc drift, chown scope, .cjs extraction, CI smoke)
Multi-agent review (9 reviewers) found the devcontainer files carried
comments + README from the abandoned read-only-symlink design, plus real
behavioral gaps. Resolved all actionable findings (no deferrals).
Documentation drift (the headline — stale comments described a security
model opposite to what shipped):
- README "Trust boundary" claimed a malicious dep "cannot write back …
the read-only /host mount blocks the write." FALSE — the shareable dirs
are RW-bound. Rewrote to document the bidirectional write-through, what
stays one-way (credentials never flow back), and how to close it.
- devcontainer.json mount group-1 comment described "selectively symlinks
… read-only eliminates write-through" — replaced with the RW-bind reality.
- Header "Windows-native is unsupported" -> supported (auto HOME setup).
- containerEnv comment "credentials persist in host-bind-mounted dirs" ->
they live in the named volumes.
- hooks.json exclusion documented honestly as a partial mitigation, not a
clean boundary (commands/agents/skills/rules are equally executing).
- ~/.local "named volume" -> image directory.
Behavioral fixes:
- chown -R recursed into the RW host binds (could rewrite host ownership /
EPERM-abort provisioning on non-UID-aligned Linux). Switched to
`find -xdev` per dir so chown stays on the volume filesystem.
- Cursor installer wrapped in `timeout 300` — its inner binary download
isn't covered by curl --max-time and could hang docker build forever.
- Removed dead CURSOR_VERSION ARG/ENV/build-arg (never consumed; "latest"
implied a pin the installer can't honor). Documented why Cursor is unpinned.
Extraction + tests (the two inline post-create.sh node heredocs were
unlintable and untestable; the path regex had had bugs):
- seed-claude-config.cjs — installMethod-strip seed, now with a non-object
guard (a bare-value/array host .claude.json could otherwise slip the
try/catch and silently re-trigger onboarding) and labeled write errors.
- translate-plugin-registries.cjs — plugin-registry path translation with
labeled errors.
- translate-plugin-registries.test.cjs — 12 tests (Windows/POSIX paths,
cross-CLI isolation, nested objects, non-object/empty-config guard).
- post-create.sh calls the modules via $SCRIPT_DIR.
CI:
- .github/workflows/ci-devcontainer.yml — runs the unit tests + shell
syntax checks + a `@devcontainers/cli build` smoke on .devcontainer/**
changes. Conforms to the repo concurrency convention (validator passes).
Documented (real gaps, fixes are honest docs since no correct auto-fix
exists): user-scope MCP servers with absolute host command paths don't
resolve in-container; user-scope config is copy-on-create so host edits
need a rebuild; in-container plugin installs get shadowed by an empty host
bind on rebuild (recovery noted); plugin installs are single-writer across
checkouts; gh/docker RW-vs-ssh/aws/azure-RO rationale.
Verified: fresh `@devcontainers/cli up` succeeds; installMethod stripped,
registry translated to Linux paths, credentials node:node, 12/12 tests pass.
* fix(devcontainer): set persist-credentials:false on CI checkouts + prettier
- zizmor `artipacked` (CodeQL/GitHub Advanced Security) flagged both
actions/checkout steps in ci-devcontainer.yml: checkout defaults to
persist-credentials:true, leaving GITHUB_TOKEN in .git/config where it
can leak into uploaded artifacts. Both jobs are read-only (run tests /
build smoke, never push), so persist-credentials:false is correct —
matches the repo convention in codeql.yml / ci-tests.yml.
- Ran prettier 3.8.0 over the new .cjs modules + test (single-quote/style
normalization to match the repo). JSON/YAML were already compliant;
README is in .prettierignore; .sh has no prettier parser. Behavior
unchanged — 12/12 transform unit tests still pass.
* fix(devcontainer): resolve adversarial review findings (pins, RO mounts, tests)
Resolves the blocking + actionable findings from the PR #1875 review:
- Pin base image by digest as bare name@digest [#1]. The :tag@digest form
trips the @devcontainers/cli image-name parser (which builds this image
in CI and in VS Code "Reopen in Container"); bare name@digest is the
parser-compatible form. Verified by a full local build.
- Pin Cursor by version + per-arch sha256 and fetch the artifact directly
instead of executing cursor.com/install; fail-closed on mismatch [#2].
- Mount ~/.config/gh and ~/.docker read-only so a compromised dep can't
rewrite the host GitHub token / Docker credHelper [#4].
- Pin @devcontainers/cli@0.87.0 in the CI smoke [#5].
- chown via find -xdev in install-deps.sh (symlink-safe; matches
post-create.sh) [#6].
- Add filesystem-I/O tests (translate/readHostConfig/seed main/ensurePaths)
and refactor ensure-host-config-dirs to be unit-testable [#7].
- Stop pre-creating settings.json/config.toml on the host; only the real
single-file bind source (.claude.json) is touched [#10].
- Add a prominent top-of-README security callout for the RW write-through
trade-off and reframe the deferred egress firewall as the key missing
compensating control [#3, #9].
Full devcontainer build verified locally (digest pull + pinned Cursor
download/extract/symlink). 24/24 config-transform tests pass.
* fix(devcontainer): resolve local adversarial-review findings (low/nit)
Follow-up to a local branch review (run after the cloud review crashed before
producing findings); all 5 confirmed findings were low/nit:
- chown via `find -xdev -exec chown -h`: add -h so chown acts on a symlink
ITSELF, not its target. Without it a dangling node_modules/.bin link aborted
provisioning under `set -e`, and a cross-fs symlink target could be
dereferenced/rewritten. Verified in a clean container (regular files still
chowned; dangling link no longer aborts; cross-fs target untouched). Applied
to install-deps.sh and post-create.sh; the inline comments are corrected to
describe -xdev (descent bound) and -h (no deref) as the two distinct guards.
- Reword the .cjs header claims from "lintable" to "unit-tested and
prettier-checked": ESLint applies no rules to .cjs in this repo; CI only
prettier-checks them.
- README: the initializeCommand is `node ensure-host-config-dirs.cjs`, which
creates the full bind-source set, not a bash `mkdir -p` of four dirs.
- ci-devcontainer.yml: document that the x64 runner exercises only the amd64
Cursor branch; the arm64 sha/URL is hash-pinned (verified against the
published artifact) but not built in CI.
- Make the seed chmod-644 test meaningful: pre-create dst at 0o600 so only the
explicit chmodSync can widen it (the prior assertion passed under the default
umask regardless of whether the chmod ran).
25/25 config-transform tests pass; arm64 + x64 Cursor artifacts verified.
* docs(devcontainer): rewrite code comments in plain English
The devcontainer comments had grown dense and jargon-heavy. Rewrite them
across all 9 files into short, plain-English sentences — same facts and
reasoning, just clearer wording.
Comments only; no code changed. Verified: the diff touches comment lines
only, 25/25 config-transform tests pass, devcontainer.json is still valid
JSONC with build.args + readonly mounts unchanged, shell scripts pass
`bash -n`, and prettier is clean.
* feat(devcontainer): persist AI CLI session state across container recreation
Add dedicated per-workspace named volumes (mount group 6) for the three
AI CLIs' session/resume state so `claude --resume`, `codex resume`, and
`cursor-agent resume` survive a rebuild, a full delete-and-recreate, and
the `docker volume rm <cli>-config-*` re-login fix:
- Claude -> ~/.claude/projects
- Codex -> ~/.codex/sessions
- Cursor -> ~/.cursor/chats + ~/.cursor/projects
The volumes are SEPARATE from the credential/config volumes and keyed
like the node_modules volumes (${localWorkspaceFolderBasename}-...-
${devcontainerId}), so wiping a config volume to force a re-login no
longer destroys session history. Session state already survived a plain
rebuild (it lived in the config volume); this closes the recreation,
volume-rm, and devcontainerId-change gaps.
Kept container-private (not host bind mounts) deliberately: transcripts
can contain pasted secrets, so a host bind would spill them to host
disk, widen the supply-chain write-through surface, and leak
cross-project transcripts. A commented-out opt-in host-bind block is
included for users who accept that trade-off.
post-create.sh: chown each new volume root explicitly (find -xdev stops
at the config-volume filesystem boundary and won't descend into them),
guarded with `[ -d ] || continue` so a missing root can't abort
provisioning under set -e.
README: document the topology, what survives vs not, the one-time
first-rebuild masking of pre-existing config-volume sessions, updated
rebuild/reset commands, and the trust-boundary impact.
* feat(devcontainer): isolate host AI-CLI config via seed-once copies + persist claude-mem
Replace the read-write host bind mounts for the AI-CLI shareable dirs
(Claude skills/agents/memory/commands/plugins; Codex plugins/prompts/
memories/skills; Cursor rules/commands/agents/skills/plugins) with a
seed-once copy from a read-only /host/.<cli> stage into the per-container
config volume. The container gets its own writable copy and can never
write back to the host, closing the write-through vector where a
compromised in-container dependency could drop a malicious agent, command,
skill, or plugin onto the host for the next host session to auto-load.
Add a per-container claude-mem named volume (claude-mem-${devcontainerId})
at /home/node/.claude-mem, seeded once from a read-only /host/.claude-mem
stage. claude-mem's multi-GB SQLite + Chroma store is kept off a host bind
(unreliable fcntl locking / corruption risk over 9p on Docker Desktop
Windows) while still surviving rebuilds.
- post-create.sh: seed shareable dirs (marker-gated, seed-once) and run
plugin-registry translation per seeded CLI; seed claude-mem behind a
completion-sentinel guard that self-heals an interrupted multi-GB copy;
chown the claude-mem volume only on first create.
- translate-plugin-registries.cjs: add selectRegistries() so translation
runs per-CLI seed-once instead of clobbering container-installed plugins.
- ensure-host-config-dirs.cjs: add ~/.claude-mem; drop the shareable
subdirs (no longer bind sources).
- devcontainer.json: drop the RW shareable binds; add the claude-mem
volume + read-only stage.
- README: rewrite trust-boundary, mount table, and rebuild/reset docs for
the copy model.
- tests: cover selectRegistries and the trimmed DIRS (30 pass).
* feat(devcontainer): add Bun 1.3.14, pinned via build arg
Installed by the official bun.sh/install script with the release tag
passed as the first positional arg, so the version is pinned even though
the install path itself is an unverified remote script (the one such
exception in the image — Cursor and the base image stay sha256/digest-
pinned). BUN_INSTALL is set in ENV so the binary lands at a known path
and the installer's rc-file edits don't matter. unzip is added to apt
since the Bun installer extracts a .zip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(devcontainer): persist gh auth via copy-into-volume model
Move ~/.config/gh from a read-only bind to the same read-only host
stage + per-container named volume pattern used for the AI CLI
credentials. post-create.sh seeds hosts.yml/config.yml from the
/host/.config/gh stage into the gh-config volume on create, so an
in-container `gh auth login` now persists across rebuilds while the
read-only stage still prevents any write-back to the host token.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(devcontainer): bump Claude Code to 2.1.156 for Opus 4.8
The pin was 2.1.153, which predates Opus 4.8 support (added in
2.1.154). With DISABLE_AUTOUPDATER=1 the container never updated past
the pin, so Claude Code only offered models up to 4.7. Bump to the
latest 2.1.156 so Opus 4.8 is available.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(audit): Centralizes heritage supertype matching so qualified, generic, scoped, and interface bases produce inheritance edges across all OO languages, with per-language configs and fixtures.
* fix(audit): Harden parsing for #1922 with per-parse timeouts, ERROR/partial parse flags, tree-sitter pinned to 0.21.1, and CI ABI checks for every grammar.
* fix: action lint passing
* fix: feedback from triage review
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(ingestion): skip File->Member DEFINES edges for class members
Previously every symbol — including Methods and Properties that belong
to a class — received a direct File->Symbol DEFINES edge. This caused
the radial-layout view to show File linking directly to all Properties
and Methods, bypassing their enclosing class node and flattening the
OOP hierarchy.
Fix: gate the DEFINES emission on the symbol having no owner. Class
members are already reachable through the File->Class DEFINES edge and
the Class->Member HAS_METHOD / HAS_PROPERTY edges, so the redundant
direct edge is unnecessary and misleading in the graph.
The same guard is applied in all four emission sites:
- parsing-processor.ts (sequential fallback path)
- parse-worker.ts (main symbol loop + routed-property branch)
- call-processor.ts (routed-property branch)
Pipeline-graph golden updated: DEFINES 21→20, relationships 74→73.
Closes#1944
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6
* fix(wiki): extend getFilesWithExports to include exported class members
The PR that removed File→Member DEFINES edges left getFilesWithExports()
under-reporting: its one-hop MATCH only reaches top-level symbols. Add a
UNION leg that follows File→DEFINES→Class→HAS_METHOD/HAS_PROPERTY→Member
so exported class methods and properties appear in wiki/cluster export
summaries again.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(csharp): eliminate global-namespace typeBindings O(files²) OOM (#1871)
Large C# solutions with tens of thousands of files in the global
(unnamed) namespace OOM'd / hung for hours at "Resolving types
(Csharp 2/3)". PR #1905 fixed the BindingRef twin of this via the
`workspaceFqnBindings` fast-path, but left the typeBindings
propagation loop in `populateCsharpNamespaceSiblings` untouched: it
copies every global file's module-scope return-type bindings into
every OTHER global file's `Scope.typeBindings`. With S files in the
`''` bucket and K distinct method names, that is O(S²) time and
O(S·K) memory — ~1.3B Map entries (~65-130 GB) at 36k files.
Measured on a concentrated global-namespace fixture: the per-file
copy went quadratic (1000→2000 files = 3.06× for 2× the files,
65s at 2000). Route global-namespace module typeBindings through a
new scope-independent `workspaceTypeBindings` channel populated ONCE
(O(K)) and consulted as a fallback by the typeBindings chain-walkers
(`findReceiverTypeBinding`, `followChainPostFinalize`), instead of the
per-file copy. After: 2000 files 6.3s, 4000 files 6.8s, heap linear.
This also makes resolution MORE correct, not just faster. The C#
spec makes the unnamed namespace a single declaration space whose
members are "available for use in a named namespace", so global types
are visible from every file. The old per-file copy only exposed them
to OTHER no-namespace files; named-namespace files never saw them.
Consulting the shared channel from every scope chain mirrors how
Roslyn resolves against a single `Compilation.GlobalNamespace` symbol
rather than copying symbols per file.
Strengthen csharp-pipeline-benchmark.test.ts so it would catch this:
give each file a unique method name (a shared name collapses the
module-typeBinding key and skips all copies, hiding the blow-up) and
raise the concentrated scales to 2000 so the sub-quadratic assertion
trips on the regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(csharp): generalize shared-channel resolution to concentrated named namespaces (#1871)
#1954 eliminated the namespace-siblings O(files²) OOM only for the global
('' / no-declared-namespace) bucket. A solution with all files under one
named namespace (e.g. file-scoped `namespace Company.Product;`, common in
modern .NET) still reproduced the #1871 blow-up — and in BOTH loops: the
BindingRef per-scope augmentation (#1905's twin) AND the typeBindings
per-file copy (#1954's twin) were each still O(N²) for a named bucket.
Generalize the shared-channel approach to named namespaces:
- Add namespace-keyed channels `namespaceFqnBindings` / `namespaceTypeBindings`
(the per-namespace analogues of `workspaceFqnBindings` / `workspaceTypeBindings`)
plus `accessibleNamespacesByScope`, populated ONCE per named bucket from the
existing `expandedNamespaces` derivation — O(defs), not O(files × defs).
- Make the shared walkers (`findReceiverTypeBinding`, `lookupBindingsAt`,
`followChainPostFinalize`) namespace-aware: after the per-scope chain and the
flat global channel miss, consult the per-namespace channels gated by the
caller module's accessible namespaces. Language-neutral — only the C# hook
populates the channels; the machinery names no language (AGENTS rule).
- Precedence preserved: local chain → named namespace → global. Named is
consulted before the flat global channel because pre-#1871 named siblings
lived in the chain / bindingAugmentations (above the workspace channel), so a
name in both a named and the global namespace must still resolve named-first.
- `using static` member exposure and the global '' fast-paths are unchanged.
Parity-neutral: `run-parity.ts --language csharp` passes (legacy DAG ==
registry-primary, 218 tests each); the C# resolver suite (386 tests) is green.
Measured: a concentrated named namespace at 500/1000/2000 files now scales
linearly (~0.57×) and ~5.6s at 2000 files, vs the quadratic blow-up before.
Tests:
- New always-run unit coverage for the walker fallbacks
(namespace-channel-lookup.test.ts): global `workspaceTypeBindings` (the #1954
channel previously covered only by a gated benchmark), namespace gating /
no-leak, named-before-global precedence, local shadowing, loop termination.
- Extend the immutability validator + invariant I8 to the new channels and
`workspaceTypeBindings`; update the `mkIndexes` factory.
- Add a concentrated-NAMED-namespace shape to the C# pipeline benchmark with
the sub-quadratic scaling assertion and an edge-count sanity check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(swift): migrate Swift to scope-based registry resolution (#937)
Ring 3 of RFC #909 — Swift is the final language migrated to the
scope-based registry resolution pipeline. Flips Swift into
MIGRATED_LANGUAGES so registry-primary call resolution is the production
default, with dual-mode parity proven: the resolver suite passes 77/77
under both the legacy DAG (REGISTRY_PRIMARY_SWIFT=0) and the
registry-primary path (REGISTRY_PRIMARY_SWIFT=1).
New language module src/core/ingestion/languages/swift/ (mirrors csharp/):
query, captures, interpret, import-decomposer, receiver-binding,
signature-bindings, arity (+metadata), merge-bindings, simple-hooks,
import-target, target-siblings, implicit-imports, sibling-type-bindings,
scope-resolver, cache-stats, index. Parse-time hooks wired into the
existing flat languages/swift.ts (coexists with the swift/ dir, like
kotlin) and the resolver registered in the scope-resolution registry.
tree-sitter-swift 0.7.1 specifics handled in the Swift module (not in
shared code):
- class / struct / extension all parse to class_declaration; extensions
are re-keyed onto the extended type so members hoist (like C# partial).
- if-let / guard-let have no if_let_binding node — the optional binding
is synthesized from if_statement / guard_statement.
- the name: field is reused for func name, param labels, param types and
return type, so param/return type-bindings are synthesized in code
(signature-bindings.ts) rather than via a multi-name query.
- no `new` keyword: Type(...) and Type.init(...) are synthesized into
constructor type-bindings.
Shared-pipeline additions are language-agnostic (AGENTS.md: no language
names in shared ingestion code):
- constructorCallTargetsClass on the ScopeResolver contract +
free-call-fallback option + run.ts wiring: when true, Type(...) links
to the Class def rather than its explicit init Constructor.
- pickUniqueGlobalClass: constructor-branch global fallback for
cross-file types absent from the call site's lexical bindings, deduped
by qualifiedName so extension/partial fragments aren't seen as
ambiguous.
- emitImplicitImportEdges: same-module File->File IMPORTS edges (Swift
whole-module visibility has no syntactic import to drive the generic
ImportEdge pipeline).
Import resolution: rewrote the O(n^2) module scan in
import-resolvers/configs/swift.ts with a WeakMap-memoized index.
Benchmarks & guards:
- Swift added to bench/scope-capture/measure.mjs + baselines.json
(fingerprint + 1.5x scaling budget); `--check` passes for all 7
languages, Swift scaling 0.98 (linear).
- golden capture-parity test
(test/unit/scope-resolution/swift/swift-captures-golden.test.ts +
fixtures/swift-captures-golden/) mirrors the csharp golden.
- O(n) scope-capture tripwire
(test/integration/swift-scope-capture-tripwire.test.ts).
Full Swift test glob: 3 files / 88 tests pass; tsc --noEmit clean; no
cross-language resolver regressions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
* fix(swift): green CI — Dart canary, cascade-safe availability test, prettier, comment/order nits (U1)
* test(swift): wire createResolverParityIt('swift') + empty legacy skip-set (U2)
* fix(swift): group same-module files by SPM target subtree in registry-primary hooks (U3)
* fix(swift): correct member-write, class-func self, multi-clause if-let, nested-extension (U4)
* perf(scope-resolution): build global class index once for pickUniqueGlobalClass (U5)
* fix(swift): re-baseline scope-capture fingerprint after member-write capture change (U4)
* style(swift): prettier-format pick-unique-global-class test (U5 follow-up)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(typescript): fix HOC pattern false positives and add export default HOC support
Fixes two related issues from #1876:
1. False positives: `const x = arr.map(a => ...)` was incorrectly classified
as a Function node. Split HOC patterns into identifier vs member_expression
variants and apply a #not-any-of? blocklist for 36 array methods
(map, filter, reduce, forEach, etc.) across all four query files.
Runtime safety-net in tsExtractFunctionName uses a module-level ARRAY_METHODS
constant (avoids per-call Set re-allocation).
2. Missing support: `export default defineEventHandler(async (e) => { ... })`
and similar HOC-wrapped default exports were invisible. Added 4
export_statement patterns (TS + JS, legacy + registry-primary) and extended
tsExtractFunctionName to derive the function name from the callee identifier.
Now correctly distinguishes:
- `const data = arr.map(account => ({...}))` → Const only (was Function+Const)
- `const Button = forwardRef(...)` → Function:Button (unchanged)
- `const Card = React.memo(...)` → Function:memo (unchanged)
- `export default defineEventHandler(...)` → Function:defineEventHandler (new)
Tests: add 2 fixture files and 4 test cases to typescript-hoc-wrapped suite
covering the export default HOC positive case and array method exclusion
negative case.
Closes#1876
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6
* fix(ingestion): tighten HOC callback attribution
Share the TypeScript and JavaScript HOC blocklists across query and runtime paths, suppress stale array-method and built-in export-default wrappers, and derive export-default HOC names from the file instead of the wrapper helper.
Also update the pinned unit and integration tests so CI reflects the new callback suppression contract.
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(workers): fail fast instead of silently degrading on worker-pool startup failure (#1741)
When an explicitly-sized worker pool (--workers <N>) fails to start because
every worker crashes during top-of-script init, the parse phase used to log a
swallowed `logger.warn` and silently fall back to the ~10x slower sequential
parser. In #1741 (rc99) that turned a worker-startup regression into a
123-minute "stuck" parse with no explanation.
This change:
- Surfaces the real crash: the pool now spawns workers with `{ stderr: true }`,
tees + captures each worker's stderr, and attaches the tail to its
readiness-failure messages (propagated via
WorkerPoolInitializationError.readinessFailures). "did not report ready"
now carries the underlying native-binding/import error.
- Gates the fallback: when --workers was explicit and fallback was not opted
into, a total startup failure throws an actionable error instead of
degrading. Auto-sized pools still fall back, but loudly (logger.error +
progress warning). New --allow-sequential-fallback flag (+ i18n) opts back in.
- Adds env-gated worker bootstrap-stage logging (GITNEXUS_WORKER_BOOTSTRAP /
--verbose): imports+grammars loaded -> ready sent -> first task received, so
a slow/crashing startup is diagnosable.
Tests: all-workers-failed gating (fatal vs loud degrade), stderr surfacing,
and the updated lazy-cache fallback contract (opt-in flag + fail-fast).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ingestion): always-on slow-file watchdog for deferred call resolution (#1741)
The original #1741 symptom is a run that appears stuck at "Resolving calls
(all chunks)... (9000/18066 files)" — the progress bar freezes inside a single
file's call resolution and nothing reaches the log. Rich per-file deferred
diagnostics already exist, but only behind --verbose / GITNEXUS_PROFILE_DEFERRED,
so a plain `analyze` run gives the user a frozen bar and silence.
Add an always-on (not verbose-gated) per-file watchdog in
processCallsFromExtracted: when a single file's call resolution exceeds
alwaysOnSlowFileWarnMs() (default 15s, override GITNEXUS_SLOW_FILE_WARN_MS,
0 disables) it emits a throttled logger.warn naming the culprit file and the
files-resolved-so-far — turning the silent stall into one actionable line.
Throttled (>=30s between warnings) so a genuinely slow repo can't storm the log.
The watchdog is observation-only; resolution behavior is unchanged.
Note: deliberately did NOT add a heritage child x parent product cap — the name
lookups are O(1) (type-registry Map.get) and the product is bounded, so the
heritage build is not the bottleneck; a cap would risk dropping real edges for
no measured gain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): worker-vs-sequential parity guard for binding/edge collapse (#1741)
rc99 produced almost no bindings/edges (13 bindings vs rc91's 106,305) because
a worker-path failure left extracted results unmerged while the run still
reported success. Rather than an arbitrary "implausibly low" runtime threshold
(which false-positives on legitimately low-binding repos/languages), pin the
invariant directly: for the same repo, worker mode and sequential mode must
produce the same graph.
The test runs the ts-simple cross-file fixture through worker mode
(workerPoolSize + lowered threshold) and sequential mode (skipWorkers), and
asserts: usedWorkerPool is true/false respectively (guards the test itself
against a silent fallback masking divergence), identical CALLS/IMPORTS/DEFINES/
HAS_METHOD edge sets and Class/Function/Method defs, and non-zero CALLS/IMPORTS
(the rc99 collapse signature).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(workers): arm fail-fast for env-sized pools + fix watchdog /0 denominator (#1741)
Addresses two review findings on the #1741 worker-startup PR:
- Fail-fast gate missed the env channel. `explicitWorkers` keyed only off
the `--workers` flag, so a pool sized via `GITNEXUS_WORKER_POOL_SIZE`
(with no `--workers`) silently degraded to sequential on a total
worker-startup crash — reproducing the original #1741 symptom for
env-channel operators. The gate now arms on a non-zero size from either
channel, via a single-source `envWorkerPoolSize()` helper exported from
worker-pool.ts (also rewired through resolveAutoPoolSize). The fatal
message now names the channel actually used instead of "--workers undefined".
- Always-on slow-file watchdog printed "Resolved N/0 files". `resolvedTotal`
was pre-counted only on the profile path, but the watchdog reads it on
every run, so a plain `analyze` showed a bogus /0 denominator on exactly
the unprofiled hang the watchdog exists to explain. Pre-count now runs
whenever its result is read (profile path OR watchdog active).
Tests: strengthened the watchdog test to assert "1/1" (not "/0"); added
env-channel fail-fast/degrade cases and made the gating suite hermetic
against an ambient GITNEXUS_WORKER_POOL_SIZE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(workers): self-healing worker pool replaces the fail-fast flag (#1741)
Replaces the interim --allow-sequential-fallback flag with automatic,
bounded self-healing in the worker pool — industry-standard supervision
(OTP restart-intensity, systemd StartLimit, circuit-breaker, AWS jittered
backoff) translated to the Node worker_threads pool.
worker-pool.ts — bounded startup self-heal (the missing layer):
- A worker that crashes during top-of-script init is now RETRIED with
capped, full-jitter backoff (BASE 250ms, CAP 2s) up to a small per-slot
budget, so a transient blip heals itself with no operator action. The
prior code dropped an unready initial slot on its first crash.
- A DETERMINISTIC crash-loop (>=2 fresh workers crash with the same
normalized signature before any reaches ready — the #1741 missing
native-binding case) is detected and short-circuited, so the pool gives
up in ~1s instead of burning every slot's budget. Correctness rests on
the STRUCTURAL signal (zero workers ever ready + budget exhausted), so a
missed signature only costs a few seconds, never a misfire; even a
stderr-less crash groups via its normalized "exited with code N" message.
- Backoff sleeps are cancellable (unref'd timer + abort on terminate), so
terminate() can't be wedged for the backoff duration.
- WorkerPoolInitializationError now carries a crashClass for an accurate,
flag-free message. The runtime respawn/breaker path is unchanged.
parse-impl.ts — collapse to automatic fail-fast:
- handleWorkerStartupFailure always logs the real cause then THROWS with
the captured crash + `--workers 0` as the explicit sequential escape.
No more degrade branch; no dependence on how the pool was sized. This is
reached only after the bounded self-heal is exhausted, so it can't
resurrect the #1741 silent 123-minute sequential grind. Construction
failure (broken install) also fails fast instead of degrading silently.
Removed --allow-sequential-fallback end to end (CLI, run-analyze, pipeline,
i18n). --workers 0 remains the explicit "parse sequentially" path; one flag
removed, none added. Grounded in a research+critique pass; the critique's
hazards (N-parallel race, empty-stderr timing, non-cancellable sleep,
runtime-breaker regression) are addressed or scoped out by design.
Tests: startup self-heal (transient recovers; deterministic fails fast
without burning the budget); gating test rewritten to the fail-fast-always
contract; obsolete degrade test removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(workers): ref + cancel startup backoff so transient retries aren't dropped (#1741 U1)
abortableSleep unref'd its backoff timer, so a transient startup retry could be
silently dropped if that timer was the last ref'd handle on the event loop —
the process could exit mid-recovery. Keep the timer ref'd (a pending retry is
necessary work) and register a cancel fn in a pool-scoped set; terminate() now
clears pending backoffs so it can't be wedged for the backoff cap. A normally
fired timer self-deregisters (clear-on-settle), so no timer lingers after a
slot's retry loop exits. Exposes pendingStartupTimers in getStats.
Tests: terminate-during-backoff cancels + spawns nothing after (R2); the
recovery test now asserts no startup timer lingers after settle (R1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(workers): route GITNEXUS_WORKER_POOL_SIZE=0 to sequential, not a phantom fail-fast (#1741 U2)
env=0 (no --workers) built a size-0 pool that threw a fabricated "retry budget
exhausted / native binding" crash. The shouldUseWorkers gate now routes env=0
to the sequential path before pool construction — but only when no explicit
--workers <N> was given, so an explicit positive size wins over an ambient
env=0. The route emits one log line so the undocumented (possibly accidental)
env=0 case is observable instead of a silent degrade.
envWorkerPoolSize is un-exported (module-internal sizing reader); a new
workerPoolDisabledByEnv() predicate serves the gate. Empty/whitespace env is
now treated as unset (auto formula), not 0 — an empty assignment is an accident,
not a request for zero workers. Reattached the detached resolveAutoPoolSize
JSDoc and corrected the stale docstring.
Tests: env=0 → sequential (no spawn); explicit --workers wins over env=0;
workerPoolDisabledByEnv unit (0=true, positive/empty/invalid=false); getStats
shape updated for pendingStartupTimers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(workers): make deterministic crash-loop detection conservative (#1741 U3)
The old tally counted crash EVENTS in a shared signature->count map, so a
simultaneous transient crash storm (e.g. spawn EAGAIN under fork pressure) or
a single slot crashing identically twice falsely tripped "deterministic" and
hard-aborted work that would have self-healed. Replace it: a crash counts
toward deterministic only after its signature REPRODUCES across a respawn on
the same slot, and the short-circuit fires once >=2 distinct slots reproduced
(or 1 for a size-1 pool). Every slot now gets >=1 self-heal attempt before any
short-circuit; the structural budget floor still bounds the worst case.
crashSignature now also collapses Windows backslash paths and bare (no-0x) hex
runs so the fast-path fires on those platforms; exported for unit testing.
Tests: simultaneous storm self-heals (the discriminator vs an attempt-0 rule);
distinct-per-attempt crashes classify transient-exhausted; single-slot
reproduction classifies deterministic; crashSignature normalization unit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(workers): class-aware startup failure hint + reattach detached JSDoc (#1741 U4)
The "often a missing/broken native binding" hint was appended to every
failure class, including a pool *construction* failure where no worker ever
ran (a missing build / bad worker path). Make the hint class-aware: keep it
for the readiness/init classes, use a construction-specific hint otherwise,
and surface the construction error (e.g. "Worker script not found: …")
verbatim. Reattach the waitForWorkerReady JSDoc that the stderr-capture block
had detached from its function. (The abortableSleep docstring was already
corrected in U1.)
Tests: construction message surfaces the real error + drops the native-binding
guess; deterministic/transient messages keep the hint (regression guard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ingestion): tree-sitter node-type/field validation gate + remove dead literals
Add a CI gate (test/integration/grammar-literal-validation.test.ts) validating
every node-type and field-name literal in the ingestion code layer against each
grammar's node-types.json, with a live `new Parser.Query` probe fallback for
literals the static JSON under-reports. Covers all three surfaces:
- legacy Call-Resolution DAG (type-extractors, *-extractors/configs) + the
ungated structure phase (field/method extractors, export-detection) — AST scan;
- registry scope-resolution captures + scope queries (Mode 3 compile);
- the registry RESOLUTION layer (scope-resolver/type-binding/receiver-binding/
interpret/arity/import-decomposer …) via a TS-TypeChecker discriminator that
collects a literal ONLY when its `.type` receiver is a tree-sitter SyntaxNode
(so resolved-symbol `.type` kinds like 'Class' are never mistaken for nodes).
Helpers: test/helpers/{grammar-introspection,literal-collectors}.ts.
Remove every existence-dead literal the gate surfaces (behavior-neutral
dead-branch/fallback deletions verified absent from the installed grammar),
spanning the legacy, structure-phase, and registry production paths:
reference_type/pointer_type/scoped_identifier/scoped_type_identifier/
rvalue_reference_declarator/variadic_parameter (C/C++), equals_value_clause/
identifier_name/simple_identifier/record_struct_declaration/record_class_declaration
(C#), generic_type/`type` field (Dart), nullable_type (PHP), method_call/symbol
(Ruby), method_call_expression/slice_type/shorthand_field_pattern (Rust),
struct_declaration/internal_name (Swift), comment (Java), parameter/
parameterized_type and dead childForFieldName('pattern'|'modifiers'|
'formal_parameters'|'declaration'|'default'|'return_value'|'alias_clause') /
class_expression fallbacks. Gate ships with an empty allowlist.
One behavior FIX (scope-resolution): PHP `findEnclosingTypeDeclaration` omitted
`anonymous_class`, so a method inside an anonymous class mis-bound `$this` to the
enclosing named class; add `anonymous_class` so it is correctly skipped.
Verified: tsc clean; gate green (empty allowlist); scope-resolution parity 26/26
on both REGISTRY_PRIMARY_*=0 and =1; resolver suite no new failures.
Issue #1920 (epic #1919).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): assert real grammar node types in #1920 dead-literal tests
Three tests asserted defensive handling of node types the installed
grammars never emit (verified via real tree-sitter parse), so they broke
once the dead literals were removed in af9d709f:
- parsing.test.ts isNodeExported / csharp: `record struct` and `record
class` both parse to `record_declaration` (kept in CSHARP_DECL_TYPES) —
tree-sitter-c-sharp emits no `record_struct_declaration` /
`record_class_declaration` node. Switch the two mock nodes to
`record_declaration`.
- extract-generic-type-args.test.ts: Java emits `generic_type` and Kotlin
`user_type`+`type_projection`; `parameterized_type` is produced by no
installed grammar, so the shared extractor returns [] for it. Convert the
case to a documented negative assertion (real paths already covered by the
generic_type cases).
No source behavior change: production export detection (record_declaration)
and generic type-arg extraction (generic_type / type_projection) were
already correct. Fixes the 3 CI failures on PR #1937.
Issue #1920 (epic #1919).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ingestion): keep parameterized_type generic-arg extraction (allowlisted)
Restore the `parameterized_type` branch in extractSimpleTypeName /
extractGenericTypeArgs (type-extractors/shared.ts) so a parameterized_type
node still yields its type arguments (List<User> -> [User]). Current
tree-sitter-java emits `generic_type` and tree-sitter-kotlin
`user_type`+`type_projection`, so this is a defensive alternate node kept
for grammar-version resilience; it is allowlisted in the node-type
validation gate with a documented justification rather than removed.
extract-generic-type-args.test.ts now asserts the User type argument is
captured from a parameterized_type node.
Issue #1920 (epic #1919).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ingestion): extract generic args from real grammar nodes, drop parameterized_type guess
extractGenericTypeArgs / extractSimpleTypeName special-cased `parameterized_type`,
a node type NO installed grammar emits (real parse: Java/TypeScript/Rust ->
generic_type, C# -> generic_name, Kotlin -> user_type). It was a guess masking a
real gap; remove it.
The genuine 'Kotlin alternate node type' is `user_type` (`List<User>` parses to
user_type > [type_identifier, type_arguments]), which the extractor returned []
for. Handle it: read a user_type's own type_arguments, else recurse into its
wrapped child (preserving the existing user_type > generic_type unwrap). No
production caller passes user_type today (Kotlin generics resolve via jvm.ts), so
this only makes the function's documented Kotlin contract correct — zero
behaviour change for current callers (Java/TS/C#/Rust pass generic_type/name).
Replace the mock parameterized_type test with REAL-PARSE coverage across
Java/TypeScript/C#/Rust/Kotlin (+ Java Map<String,User>) so a wrong node-type
guess can't silently pass again. Gate allowlist returns to empty.
Issue #1920 (epic #1919).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(test): wrap real-parse cases to prettier printWidth (CI format gate)
CI runs `prettier --check .` from the repo root (printWidth 100) and flagged the
new real-parse cases array's long single-line object literals. Wrap them.
Format-only; no behaviour change.
Issue #1920 (epic #1919).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): node-scoped field probe oracle for the literal gate (U1)
Add probeField(language, nodeType, field) — the node-scoped analogue of
probeNodeType: compiles `(<nodeType> <field>: (_)) @_` against the live
grammar and classifies TSQueryErrorStructure/Field -> dead, TSQueryErrorNodeType
(node absent here) -> unavailable, compile -> valid. Conservative-toward-valid
(supertype-typed fields make some wrong fields compile), so it never produces a
false positive. Add isFieldError classifier; make validateField's node-scoped
path membership-then-probe so node-types.json field under-reporting can't yield
a false `dead`.
Foundation for the node-scoped field validation gate (no gate behavior change
yet). Issue #1920 (epic #1919).
* test(ingestion): capture receiver node type + extend Mode-4 to type-env.ts (U2)
CollectedField gains receiverNodeType, captured conservatively by
receiverNodeTypeOf: only when a childForFieldName receiver is unambiguously
narrowed by a single enclosing positive guard (if (recv.type==='X') then-branch,
or switch case 'X') with no reassignment/shadowing of the receiver in the
enclosing function. Any uncertainty -> undefined (sound global fallback);
fail-safe (benign false negative, never a false positive).
Extend Mode-4's resolutionLayerFiles to include shared resolution files directly
under ingestion/ (type-env.ts), tagged with the full gated language set via
fileLanguages (valid-if-any). Entries now carry a language SET. Rename
Mode2Result -> ScanResult; fix the header doc (THREE -> FOUR modes).
Gate behavior unchanged until U3 consumes receiverNodeType. Issue #1920.
* feat(ingestion): node-scoped field gate + remove gate-flagged dead literals (U3, U4)
U3: the gate validates childForFieldName lookups node-scoped (validateField with
the captured receiverNodeType) and fails loudly on a degraded/vacuous run
(asserts resolutionLayerProgramOk, floors collected counts, requires
knownFailures empty).
U4: remove every dead field/literal the hardened gate flags — all behavior-neutral
(the dead disjunct never fired on reachable nodes; verified by real parse + the
type-extractor/resolution unit suites, 484 passing):
- type-env.ts: parameterized_type (emitted by no grammar) and switch_block_label
(real Java enhanced switch is switch_label/switch_rule) from the SyntaxNode .type sets
- languages/csharp/captures.ts: generic_name has no `name` field -> firstNamedChild
- type-extractors/jvm.ts: Kotlin property_declaration has no name/type fields
(positional children) -> findChild; drop the else-branch `pattern` fallbacks x2
- type-extractors/csharp.ts: drop the else-branch `pattern` fallback (parity with go/php/python/swift)
Gate green with node-scoped validation on; tsc clean. Closes the Mode-4
type-env coverage opened in U2. Latent follow-up: Java enhanced-switch arms
(switch_rule) are absent from NARROWING_BRANCH_TYPES — a separate behavior fix.
Issue #1920 (epic #1919).
* fix(java): exclude interleaved comments from call arity (U5)
tree-sitter-java emits block_comment/line_comment as named children of
argument_list; counting them inflated @reference.arity / @reference.parameter-
types / @reference.arg-names for any Java call with an inline comment, which
skews arity-based overload resolution (arity feeds call-processor symbol-ID
generation). Filter them at the single arg-list site (also corrects the
downstream args.map). The previously-removed `comment` literal never matched —
the real nodes are block_comment/line_comment (the #1920 gate lesson).
Isolated from the behavior-neutral gate units (U1-U4) since this changes
production graph output. Java resolver suite 178/178; new java-call-arity test
covers block/line comments, leading comment, constructor calls, and the
no-comment regression. Issue #1920 (epic #1919).
* test(ingestion): cover Kotlin/C# multi-arg generics + tighten probe assertions (U6)
- extract-generic-type-args: add real-parse Kotlin Map<String,User>
(user_type > type_arguments > type_projection) and C# Dictionary<string,User>
(generic_name > type_argument_list) multi-arg cases.
- grammar-introspection: the probeNodeType test now asserts 'dead' for a bogus
node on installed grammars (not merely not-throw), and documents the null-model
split (validateField -> unavailable; validateNodeType -> still probes the live
grammar). Issue #1920 (epic #1919).
* style(test): apply root prettier formatting (CI format gate)
CI runs `prettier --check .` from the repo root (printWidth 100); the gitnexus/
pre-commit hook formatted these two files differently. Format-only, no behavior
change. Issue #1920.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* bench(python-scope): build-free measure harness + baseline fingerprint for emitPythonScopeCaptures
ce-optimize scaffolding for the python-scope-capture run. Mirrors the Go
scope-capture harness (#1848): imports the .ts hotpath via tsx, times
emitPythonScopeCaptures on a synthetic DAO source at 250/800 entities, and
pins an order-independent sha256 capture fingerprint over the whole
lang-resolution/python-* corpus + a fixed 20-entity DAO as the correctness gate.
Baseline (current code) is O(n^2): 250->800 entities (3.2x) -> 10.7x time
(1062->11343ms), scaling_ratio 3.34.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* optimize(python-scope-capture): thread captured nodes to kill O(n^2) findNodeAtRange re-walks
emitPythonScopeCaptures re-derived each tree-sitter match's AST node via
findNodeAtRange(tree.rootNode, ...) on every match, scanning all of root's named
children per call -> O(matches x rootChildren) ~ O(n^2). The same #1848 bug Go
had (fixed in eaf0a305), mirrored in Python's captures.ts.
Thread the query-captured SyntaxNode (c.node) through a parallel tag->node map
and use it directly for all three sites (import / @scope.function /
@declaration.function). The Python scope query captures the full
statement/definition node, so the captured node IS the one the old code
re-derived by range — no ancestor walk needed (simpler than Go's import case).
Output is byte-identical: an order-independent sha256 capture fingerprint over
all 188 lang-resolution/python-* fixtures + a 20-entity DAO is unchanged.
800 entities: 11343ms -> 319ms (35.5x); 250: 1063ms -> 95ms (11.2x);
scaling_ratio 3.34 -> 1.05 (quadratic -> linear). tsc clean; 291 python
scope-resolution + resolver tests pass.
Adds a golden capture-parity test (forward-drift guard across the python-*
corpus + DAO shape) and a non-gated O(n^2) regression tripwire (400-entity
source, 346ms vs a 10s budget).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* optimize(python-scope-capture): index Python import resolution to kill O(imports x files) scans
resolvePythonImportTarget's fallback path scanned the entire repo file set on
every unresolved/external dotted import — once in hasRepoCandidate (package gate)
and once in resolveAbsoluteFromFiles (suffix match) — giving O(imports x files)
~ O(n^2) in the resolution phase (audit follow-up to the capture-phase #1848
mirror).
Add a per-file-set index (byBasename buckets + .py dir-prefix set + normalized
path set), memoized on the allFilePaths Set via a WeakMap so it is built once per
run and reused across every import. The two O(files) scans become O(1)/O(bucket)
lookups. The shared buildSuffixIndex is deliberately NOT reused: it keeps only a
single path per suffix (longest wins) and cannot reproduce Python's exact
fewest-segments-then-lexicographic tie-break across all candidates (see the
import-target.ts:72 rationale) — so a purpose-built index is used instead.
Output is identical: a resolver-output fingerprint over 10,021 cases (exhaustive
branch matrix — tie-breaks, gating, collisions, windows paths — plus a 400-repo
deterministic fuzz) is byte-for-byte unchanged
(e6ec1a59...). Worst-case scaling (k imports x k files): 500/1000/2000/4000 went
25/62/231/899ms -> 1.2/2.9/6.7/10.7ms (84x at 4000, quadratic -> linear).
tsc clean; 303 python scope-resolution + resolver tests pass; adds a 10-case
parity guard pinning the tie-break / gating / collision semantics the index
must preserve.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(python): land the import-index reuse on the registry-primary path (PR #1918 P1)
The PythonFileIndex WeakMap is keyed on allFilePaths Set identity, but
pythonScopeResolver.resolveImportTarget wrapped the orchestrator's stable
run-level set in `new Set(allFilePaths)` per import, handing a fresh key to
every import — so the index rebuilt on every import and the O(imports x files)
cost this index removed persisted on the production path (PR #1918 review P1).
Thread ReadonlySet<string> through the resolver chain (PythonResolveContext,
getPythonFileIndex, the WeakMap key, resolveAbsoluteFromFiles, hasRepoCandidate,
resolvePythonImportInternal, tryResolveWithExtensions — all read-only) and drop
the per-import copy so the stable set reaches the WeakMap key. Mirrors the C#
counterpart (csharp/import-target.ts), which already keys on ReadonlySet.
Guard it deterministically: an ungated index-build counter (index-stats.ts) +
a production-path integration test that drives pythonScopeResolver over 300
imports on a stable set and asserts the index is built ONCE (was 300 pre-fix).
tsc clean; resolver-output fingerprint unchanged (e6ec1a59); 369 python
scope-resolution + resolver tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(python): index only .py files in the import-resolution index (PR #1918 P3b)
getPythonFileIndex pushed every workspace file into byBasename (and normSet),
but Python import resolution only ever queries .py paths — module <seg>.py,
package <seg>/__init__.py, and .py directory prefixes. Non-.py files (.ts, .go,
…) could never match any lookup, so they were pure dead weight in the index on
polyglot monorepos.
Skip non-.py files at the top of the index builder. dirPrefixes was already
.py-gated; this extends the same guard to byBasename and normSet (both also
.py-only consumers), so it is behavior-preserving. Resolver fingerprint
unchanged (e6ec1a59); adds a polyglot parity case proving .ts/.go siblings
never affect resolution.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(python): parent-key the __init__ bucket to kill package-count skew (PR #1918 P2b)
The suffix fallback's package form looked up byBasename.get('__init__.py'),
which holds every __init__.py in the repo — so every multi-segment package
import (pkg.sub) iterated all N packages to find the one ending /sub/__init__.py.
Add byInitParent: __init__.py files keyed by their last two components
(<parentDir>/__init__.py). The package lookup now targets only same-named
package dirs (typically O(1)) and confirms the full suffix, so the final
candidate set and tie-break are unchanged. __init__.py files stay in byBasename
too, so the rarer explicit "pkg.__init__" import still resolves via the module
(<lastSeg>.py) lookup.
Resolver fingerprint unchanged (e6ec1a59); adds parity cases for a nested
package (same-parent noise filtered by the suffix confirm) and an explicit
pkg.__init__ import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(python): reproduce old startsWith gating for absolute paths + re-baseline (PR #1918 P3a)
getPythonFileIndex built dirPrefixes by split('/')+filter(Boolean), which drops
the leading empty component of an absolute path: "/repo/svc/x.py" yielded
{repo/, repo/svc/}. The old full-scan gate compared the whole normalized path,
where "/repo/svc/x.py".startsWith("repo/svc/") is false — so the index gate
PASSED where the old gate BLOCKED, an absolute-path-only divergence (production
paths are repo-relative, so this never fired in production).
Build dirPrefixes from every slash-terminated prefix of the full path instead
(including the leading "/" for absolute paths), so dirPrefixes.has(X) matches
exactly when the old f.startsWith(X) did. For repo-relative paths the prefix set
is identical, so production behavior is unchanged.
This is NOT cosmetic. Extending the fingerprint harness with absolute-path file
sets surfaced 12 fuzz cases (out of ~4000 new absolute cases) where the pre-fix
index resolved an import the old code left unresolved — e.g. `pkg.thing` over
{/repo/pkg/__init__.py, /repo/vendor/pkg/thing.py} from /repo/app/main.py
resolved to /repo/vendor/pkg/thing.py under the buggy gate but is null (old and
fixed). The fix removes those absolute-path false positives.
Re-baseline justification: the committed resolver fingerprint moves
e6ec1a59 -> d51ea9ed because the harness now adds ~4000 absolute-path cases
(branch matrix incl. the reviewer's exact case + a 200-repo absolute fuzz). The
relative-path subset is unchanged: the original 10,021-case relative corpus
still hashes to e6ec1a59 after the dirPrefixes fix (the fix only alters
absolute-path prefixes). The new baseline encodes the old-startsWith-equivalent
(correct) behavior, verified by diffing the fixed vs. pre-fix harness output.
Adds parity cases pinning the absolute false-positive (now null) and a
repo-relative control of the same shape (still resolves). tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(python-bench): add --check mode + REPS=7 to the scope-capture harnesses (PR #1918 P2a)
The bench harnesses were dev-only — nothing compared the committed fingerprints
or guarded the scaling, so an O(n^2) regression (or a P1-style cache miss) could
land silently.
Add a --check mode to both:
- measure.mjs: assert the capture fingerprint == baseline-fingerprint.txt AND
scaling_ratio < 1.5 (linear), exit non-zero on either. REPS bumped 3 -> 7 to
stabilize the median on shared CI runners.
- import-target-fingerprint.mjs: assert the resolver fingerprint ==
baseline-import-target-fingerprint.txt, exit non-zero on drift.
Without --check both still print JSON for dev use / deliberate re-baselining.
Verified: --check passes on the current tree (capture f2b4376f / scaling 1.04;
resolver d51ea9ed) and exits 1 with a clear message on a corrupted baseline.
Wired into CI by the dedicated benchmark job (next commit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(bench): add a dedicated benchmark job wiring in the gated cross-language suites
The cobol/csharp/rust/php/ruby *-pipeline-benchmark.test.ts suites are gated
behind GITNEXUS_BENCH, so the main coverage job skips them — their O(n^2)
scaling guards never actually ran in CI. Add a dedicated "benchmarks" job to the
Tests reusable workflow that runs them with GITNEXUS_BENCH=1, plus the Python
scope-capture and import-resolution fingerprint + scaling guards
(measure.mjs --check, import-target-fingerprint.mjs --check) from PR #1918.
Runs with --no-file-parallelism: the suites measure wall-clock and peak heap, so
parallel forks both skew the timings and OOM the worker pool (reproduced locally:
the parallel run crashes a worker; serial passes 5/5 in ~80s). The job is part of
the Tests workflow, so it gates the existing CI Gate required check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(bench): exclude go-pipeline-benchmark from the gated job (fork-pool instability)
Validation surfaced that go-pipeline-benchmark.test.ts's worker-pool (#1848)
suite spins a real worker pool that exits unexpectedly under vitest's fork pool,
crashing the run (1 of 3 tests, repeated). Including it would make the new
benchmark gate flaky. The other five language pipeline benchmarks
(cobol/csharp/rust/php/ruby) run clean serially (5/5, ~84s). Go is already
guarded by its non-gated O(n^2) tripwire (main coverage job) + golden parity
test, so coverage is preserved. Documented inline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(security): set persist-credentials false on all ci-tests checkouts (zizmor artipacked)
The new benchmarks job (and the pre-existing tests / cross-platform jobs) used
actions/checkout with the default persist-credentials, leaving the token in
.git/config. The tests job uploads a test-reports artifact, so that is the
literal credential-persistence-through-artifacts case zizmor's artipacked audit
flags; the others persist creds needlessly.
None of these jobs push — they run npm + vitest only — so persist-credentials:
false is safe (the packaged-install-smoke job already runs setup-gitnexus this
way). All four ci-tests.yml checkouts are now consistent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* bench(scope-capture): unified build-free measure harness for all benchmarked languages
Adds a single tsx harness that measures emit<Lang>ScopeCaptures for every
language with a pipeline benchmark (go, csharp, rust, php, ruby, cobol):
per-language synthetic-DAO scaling (250/800 entities) + an order-independent
sha256 fingerprint over each <lang>-* fixture corpus, with a --check mode gating
both against baselines.json.
It immediately surfaced that csharp, rust, php and ruby still carry the
O(matches x rootChildren) findNodeAtRange(tree.rootNode,...) root-walk that was
fixed for go (#1915) and python (#1918): scaling ratios 3.13 / 3.31 / 3.04 /
3.07 (vs ~1.0 for the fixed go and cobol). They are flagged known_quadratic in
baselines.json so CI guards drift + worsening until each gets the threaded-node
fix (following commits).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(ruby): linearize scope-capture (thread captured nodes + dedup set)
emitRubyScopeCaptures re-derived each match's node via findNodeAtRange(tree.
rootNode,...) per match (import / scope.function / declaration.function /
heritage / attr / call-arity), and the constructor-return pass ran out.some(...)
once per method over the growing output array — two O(n^2) shapes (measured
scaling 3.07).
Thread the query's captured node (c.node) through a nodeMap and resolve each
anchor with a type-guarded lookup (nodeIfType), and precompute the YARD-return
dedup keys into a Set. Output byte-identical (capture fingerprint over the
ruby-* fixture corpus + DAO unchanged); scaling 3.07 -> 1.11 (linear). 127 ruby
resolver tests pass; tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(php): linearize scope-capture (thread captured nodes)
emitPhpScopeCaptures re-derived each match's node via findNodeAtRange(tree.
rootNode,...) per match (import / scope.function / declaration / call-arity),
giving O(matches x rootChildren) ~ O(n^2) (measured scaling 3.04).
Thread the query's captured node (c.node) through a nodeMap and resolve each
anchor with a type-guarded lookup (nodeIfType), mirroring go #1915 / python
#1918. Output byte-identical (capture fingerprint over the php-* fixture corpus
+ DAO unchanged); scaling 3.04 -> 1.03 (linear). 205 php resolver tests pass;
tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(rust): linearize scope-capture (thread captured nodes)
emitRustScopeCaptures re-derived each match's node via findNodeAtRange(tree.
rootNode,...) per match (import / scope.function / declaration / type-binding
return-hoist / call-arity), giving O(matches x rootChildren) ~ O(n^2) (measured
scaling 3.31 — the worst of the four).
Thread the query's captured node (c.node) through a nodeMap and resolve each
anchor with a type-guarded lookup (nodeIfType), mirroring go #1915 / python
#1918. Output byte-identical (capture fingerprint over the rust-* fixture corpus
+ DAO unchanged, incl. the impl-block return-type hoist path); scaling
3.31 -> 1.05 (linear). Rust resolver tests pass; tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(csharp): linearize scope-capture (thread captured nodes)
emitCsharpScopeCaptures re-derived each match's node via findNodeAtRange(tree.
rootNode,...) per match at 7 sites (import / read.member / scope.function /
declaration / call-arity / primary-constructor class+record), giving
O(matches x rootChildren) ~ O(n^2) (measured scaling 3.13).
Thread the query's captured node (c.node) through a nodeMap and resolve each
anchor with a type-guarded lookup (nodeIfType), mirroring go #1915 / python
#1918. Output byte-identical (capture fingerprint over the csharp-* fixture
corpus + DAO unchanged); scaling 3.13 -> 0.99 (linear). C# resolver tests pass;
tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(bench): tighten scope-capture budgets to linear + gate all 6 languages in CI
All six benchmarked languages now thread the captured node, so update
baselines.json: drop known_quadratic and set scaling_budget 1.5 (linear) for
csharp/rust/php/ruby (go/cobol already linear). Fingerprints are unchanged —
every fix was byte-identical.
Wire the unified build-free guard into the benchmarks job:
'node --import tsx bench/scope-capture/measure.mjs --check' asserts the capture
fingerprint and linear scaling for go/csharp/rust/php/ruby/cobol on every run.
Build-free (no worker pool), so unlike the go pipeline benchmark it is stable in
CI. measure --check passes locally for all six (scaling 0.86-1.10).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(ingestion): address PR #1918 tri-review — shared nodeIfType, duck-typed guard, docs
Tri-review follow-ups (no behavior change — all capture fingerprints + the
resolver fingerprint are byte-identical, verified via the bench --check gates):
- maintainability (M1): extract the `nodeIfType` helper (copy-pasted into 4
captures.ts files) to ast-helpers.ts as a generic `nodeIfType<T extends
SyntaxNode>`. csharp/php keep their local SyntaxNode aliases (used elsewhere);
the generic signature accepts them.
- P2 (latent): duck-type the `resolvePythonImportTarget` shape-guard instead of
`instanceof Set`. The context type was widened to ReadonlySet<string>; an
`instanceof Set` check would reject a legitimate non-Set ReadonlySet and
silently drop all Python import edges. Now checks `.has` + `[Symbol.iterator]`.
- P3 (ruby dedup): document the snapshot-vs-live `out.some`→Set behavior — the
one narrow corner (two same-named methods one row apart, both ending in
Const.new) where output differs from the pre-PR code, and why the new
behavior (emit both) is intended.
- harness cross-ref: note in python-scope/measure.mjs that Python's capture
scaling is guarded there (not the unified scope-capture harness) so neither
is removed assuming the other covers Python.
tsc clean; scope-capture --check passes (6 languages, unchanged + linear);
resolver fingerprint unchanged; 300 python/ruby/rust tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ingestion): golden + O(n^2) tripwire tests for ruby/rust/php/csharp scope-capture
Addresses the PR #1918 tri-review test-gap consensus (testing + adversarial +
maintainability): the four newly-linearized languages had no committed
correctness/scaling lock in the standard unit-test job — only the
bench/scope-capture/measure.mjs --check fingerprint, which runs in the separate
benchmarks CI job.
Per language, mirroring the existing go/python tests:
- test/unit/scope-resolution/<lang>/<lang>-captures-golden.test.ts — ORDER-
SENSITIVE golden (modeled on go-captures-golden.test.ts; catches emission
reordering the order-independent bench fingerprint misses) over the whole
lang-resolution/<lang>-* corpus + a 20-entity synthetic DAO, with UPDATE_GOLDEN
regeneration. Runs in the normal unit-test job (fast-fail).
- test/integration/<lang>-scope-capture-tripwire.test.ts — non-gated O(n^2)
regression tripwire (400-entity source, <10s budget), like python's.
The ruby golden also pins the snapshot-dedup behavior (two same-named methods
both ending in Const.new emit BOTH @type-binding.return bindings — PR #1918 P3),
and the rust golden exercises the impl-block return-type hoist path.
41 tests pass; tsc clean. Goldens generated against the (byte-identical) current
output.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(go): add #1848 Go pipeline + worker-pool benchmark
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* optimize(go-scope-capture): thread captured nodes to kill O(n^2) findNodeAtRange re-walks
emitGoScopeCaptures re-derived each match's AST node via findNodeAtRange from
the tree root on every query match, giving O(matches x rootChildren) ~ O(n^2)
behaviour (the #1848 root cause: a 250-struct generated DAO took ~10.8s, 800
structs ~100s+ — long enough to trip the worker sub-batch idle timeout and get
quarantined). Thread the query-captured SyntaxNode (c.node) through a parallel
tag->node map and use it directly (or via a bounded local parent walk for the
import_declaration ancestor case) instead of re-walking from root.
Output is byte-identical (capture fingerprint over the DAO file + all 89 go-*
fixtures unchanged; capture_groups=13501). 250 entities: 10835ms -> 114ms (95x).
800 entities: ~100s -> 384ms. Go resolver + scope-resolution suites: 165/165 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(go-scope-capture): address code-review findings
Self-review (ce-code-review) polish on the #1848 fix + benchmark:
- benchmark: tighten the scaling guard from timeRatio/fileRatio < 3 to < 1.5.
At the 2.5x/2x scale steps, a quadratic regression yields ratio == fileRatio
(2.5, 2.0), which < 3 waved through — the guard could not detect the O(n^2)
it exists for. Measured O(n) ratios are 0.45/0.59, so < 1.5 has headroom.
- benchmark: add a non-gated O(n^2) regression tripwire that calls
emitGoScopeCaptures on a 400-struct source directly (no worker, no
GITNEXUS_BENCH gate) so the regression is actually guarded in CI.
- benchmark: clearTimeout the Promise.race timer in finally (no lingering
rejection); set the worker-suite env vars inside the try so finally always
restores them.
- captures.ts: clarify the isRawMultiAssignTypeBinding comment to name both
var-form cases (assertion + call-return). Comment-only.
Left as-is: resolveImportNode's defensive range-equality branch — deleting it
as dead code would remove the self-documentation of the grammar invariant the
threaded-node logic depends on (reviewer tension; a wash).
Verified: tsc clean; 165/165 Go resolver + scope tests; new tripwire passes
(237ms); scaling suite passes at <1.5; #1848 worker suite still green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(go): golden capture-parity guard for emitGoScopeCaptures (#1848 U1)
Pins emitGoScopeCaptures output across all 89 go-* fixtures + a synthetic DAO
shape as a committed golden (test/fixtures/go-captures-golden/expected-captures.json),
so future drift in the Go scope-capture path fails CI instead of only the coarse
perf tripwire. Match-grouped, order-independent sha256 canonicalization; regenerate
intentionally with UPDATE_GOLDEN=1. Mirrors test/integration/pipeline-graph-golden.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(go): cover func_literal, var-form bindings, single import, generics (#1848 U2)
Adds smoke cases for the Go shapes the #1915 captured-node refactor reasons
about but no lang-resolution fixture exercised: func_literal under @scope.function
(no receiver synthesized), var-form @type-binding.assertion and .call-return (not
dropped by isRawMultiAssignTypeBinding), a single unparenthesized import through
resolveImportNode, and a generic function declaration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(go): tighten O(n^2) tripwire budget 10s -> 5s (#1848 U3)
The fixed path is ~250ms; a quadratic regression at 400 structs is ~25s. 5s keeps
~20x headroom over the fixed path while tripping a ~20x regression (vs the prior
~40x). Correctness is guarded separately by the U1 golden test, so this stays a
pure perf tripwire.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
* test(go): fail on a missing golden in CI via a pure resolveGoldenAction helper (#1848 U1)
Extracts the golden test's missing-file gate into a pure
resolveGoldenAction({update,exists,isCI}) -> regenerate|compare|fail helper, so
a missing golden no longer self-heals + passes in CI (Codex F2). The rule is
unit-tested directly across all combos with no filesystem mutation (can't corrupt
the committed golden). CI detection uses a truthy check (!!process.env.CI) so it
fires on any runner. Locally a missing golden still regenerates as first-run convenience.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(go): make the golden digest order-sensitive (#1848 U2)
Drops the cross-match .sort() in digestCaptures so the digest reflects emission
order — a true byte-identical guard that catches a reordering refactor (Codex F1),
not just a set-equality check. Safe because emitGoScopeCaptures output is
deterministic. Within-match key order stays normalized (a CaptureMatch is a Record).
Replaces the order-independence test with an order-sensitivity assertion and
regenerates expected-captures.json under the new scheme (all 90 digests).
Trade-off: a tree-sitter-go grammar bump that reorders matches now requires a
deliberate UPDATE_GOLDEN=1 regen — intentional (a tree-shape change deserves a look).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(go): strengthen func_literal smoke case to a positive receiver assertion (#1848 U3)
The old case used a closure-only source and only asserted ABSENCE of
@type-binding.self, so it would pass even if the method_declaration receiver
branch regressed (Codex F3). The fixture now has both a method and a closure, and
positively asserts exactly one @type-binding.self from the method (name=u,
type=User — the type also confirms *User pointer-stripping) and none from the closure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(test): remove TOCTOU file-system race in golden test + format
CodeQL flagged a high-severity 'potential file system race condition': the golden
test did fs.existsSync(GOLDEN_FILE) then later writeFileSync/readFileSync on it.
Replace the existsSync-then-use with a single race-free read (ENOENT => missing),
reusing the read content for the compare path. Behaviour is unchanged (the pure
resolveGoldenAction helper still decides regenerate/compare/fail). Also applies
prettier formatting to the file (fixes the quality/format check).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-30 13:05:08 +01:00
1098 changed files with 1448948 additions and 29485 deletions
"description":"Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
@@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
# GitNexus CLI Commands
All commands work via `npx` — no global install required.
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
## Commands
### analyze — Build or refresh the index
```bash
npx gitnexus analyze
node .gitnexus/run.cjs analyze
```
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
@@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
### status — Check index freshness
```bash
npx gitnexus status
node .gitnexus/run.cjs status
```
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
@@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a
### clean — Delete the index
```bash
npx gitnexus clean
node .gitnexus/run.cjs clean
```
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
@@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
### wiki — Generate documentation from the graph
```bash
npx gitnexus wiki
node .gitnexus/run.cjs wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
@@ -66,7 +68,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
### list — Show all indexed repos
```bash
npx gitnexus list
node .gitnexus/run.cjs list
```
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
`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:
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`.
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
A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI, Cursor CLI, and Bun alongside the GitNexus native build chain. Supported hosts: **macOS, Linux, Windows 11 (native), and Windows 11 via WSL2.** Windows-native needs a **one-time `HOME` env var setup** — handled automatically by the `initializeCommand` on first run (see [Windows 11 setup](#windows-11-setup)).
> ### ⚠️ Read this before using it on a work machine
>
> This devcontainer **does not write to your host AI-CLI config.** Your skills, agents, commands, plugins, memory, prompts, and rules are **copied once** from a read-only host stage into a per-container volume on first create; the container edits its own copy and can never write back. So a compromised workspace dependency running in the container **cannot** drop a malicious agent, command, skill, or plugin onto your host for your next host CLI session to load — the write-through vector earlier versions had is closed. Your **credentials** (Claude/Codex/Cursor logins, plus `gh`) likewise stay in per-container volumes and are never written back, and `~/.ssh`, `~/.aws`, `~/.azure`, and `~/.docker` are mounted **read-only**.
>
> What is **still** exposed: the read-only host stages (`/host/.claude`, `/host/.codex`, `/host/.cursor`, `/host/.claude-mem`) and the read-only credential mounts are all **readable** inside the container. A compromised dependency can therefore READ your host CLI config, memory, SSH/cloud credentials, and GitHub token — and there is **no egress firewall yet**, so it has the network to exfiltrate what it reads. Read-only protects you from tampering and write-back, not from disclosure.
>
> The trade-off of the copy model: host and container config **diverge after first create.** A skill or plugin you add on the host later won't appear in the container until you wipe the config volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)). Edits you make inside the container persist across rebuilds but never reach the host.
## Quick start
1. Install [Docker Desktop](https://docs.docker.com/desktop/) (Windows/macOS) or Docker Engine (Linux).
2. Install [VS Code](https://code.visualstudio.com/) with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
3. Install [Node.js](https://nodejs.org/) on the **host** (Node 18+). This is the only host-side toolchain dependency beyond Docker and VS Code — the devcontainer's `initializeCommand` runs `node .devcontainer/ensure-host-config-dirs.cjs` to set up the bind-mount source directories before container create. If you already use Claude Code or another Node-based CLI on the host, you're already set.
4. Open the repo in VS Code → Command Palette → **Dev Containers: Reopen in Container**.
5. Wait for the first build (~3–6 minutes) and `postCreateCommand` to finish installing workspace dependencies.
6. Authenticate the three CLIs once — see [First-time CLI authentication](#first-time-cli-authentication) below.
## Windows 11 setup
### Windows-native (one-time setup, then "just works")
The host bind mounts use `${localEnv:HOME}/.claude` (and `.codex`, `.cursor`, `.ssh`, `.config/git`, `.config/gh`, `.gitconfig`). VS Code resolves `${localEnv:HOME}` by reading its own process env, and Windows doesn't set `HOME` by default — it uses `USERPROFILE`. So the bind mounts can't resolve until you tell Windows to also expose your profile as `HOME`.
The `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`) handles this automatically:
1.**First time you Reopen in Container**, the script detects the missing `HOME`, runs `setx HOME "%USERPROFILE%"` (which writes to your user-level Windows env — no admin needed), prints a one-time setup banner, and exits.
2.**Close all VS Code windows** (File → Exit) and reopen. VS Code picks up the new `HOME` at startup.
3.**Reopen in Container again.** The script now sees `HOME=C:\Users\<you>`, skips the setup block, creates the bind-mount source dirs, and Docker brings the container up.
Subsequent rebuilds work normally with no extra steps. The `HOME` env var is set persistently in your Windows user environment, so it'll be there for every future VS Code session (and any other tool that wants `HOME`).
If you'd rather set it manually before opening the container:
```powershell
setxHOME"%USERPROFILE%"
# Close & reopen VS Code
```
### Known trade-offs of Windows-native vs WSL2
Windows-native works, but Docker Desktop's Windows bind-mount layer has rough edges that WSL2 avoids:
- **File watchers can miss events.** Vite / jest `--watch` running inside the container watching workspace files mounted from `D:\...` may miss changes — chokidar polling (`CHOKIDAR_USEPOLLING=true`) is the usual workaround.
- **`npm install` is 3-5× slower** through the Windows-to-Linux bind-mount translation than on a WSL2-native filesystem.
- **Permission edge cases.** The husky `.husky/_/h` EPERM class we hit earlier in this PR is specific to Windows-side bind mounts changing UID ownership between container runs. `post-create.sh` clears the cache defensively to keep this from being fatal, but it's still a real source of friction.
If you hit any of those and want to migrate to WSL2 later, the steps are below.
### WSL2 (faster, fewer edge cases)
To clone and open the repo inside WSL2:
```bash
# 1. Install WSL2 and a Linux distro if you haven't already.
wsl --install -d Ubuntu
# 2. Enter WSL.
wsl
# 3. Clone the repo inside your WSL2 home directory.
# 4. Launch VS Code from inside WSL — this opens VS Code attached to the WSL2
# filesystem, so `${localEnv:HOME}` resolves to the WSL user's home and
# subsequent "Reopen in Container" uses the WSL2-side path.
code .
```
Then run **Dev Containers: Reopen in Container**. The workspace will be bind-mounted from `\\wsl$\Ubuntu\home\<user>\GitNexus`, which is fast and gives reliable file-system events. **Make sure Docker Desktop's WSL integration is enabled** for your distro: Docker Desktop → Settings → Resources → WSL Integration → toggle on the distro you cloned into.
## macOS
Open the repo folder in VS Code → **Reopen in Container**. The image is multi-arch; on Apple Silicon you'll pull the `linux/arm64` variant automatically.
## Linux
Same as macOS — open in VS Code and reopen in container. `updateRemoteUserUID: true` (default) shifts the container's `node` user UID/GID to match your host user, so bind-mounted files stay writable without extra setup.
## How CLI state flows from your host
### AI CLIs (Claude Code, Codex, Cursor): copy-once from a read-only host stage + per-container credentials
The three AI CLIs use a **copy-from-read-only-stage topology**: the host's `~/.<cli>` folders (and `~/.claude-mem`) are mounted **read-only** at `/host/.<cli>`, and `post-create.sh` copies out of them into per-container named volumes. Credentials, identity, and single config files are copied on **every** create; the shareable subdirs (plugins, skills, agents, memory, commands, prompts, rules) are copied **once** on first create and then owned by the container. Nothing is bind-mounted read-write into the host's CLI config, so the container can never modify your host setup. Session sub-paths overlay the config volume via their own named volumes (Docker mount precedence — more specific path wins).
| **claude-mem store** | _named volume_`claude-mem-${devcontainerId}` | `/home/node/.claude-mem` | rw | claude-mem's SQLite DB + Chroma vector store; **seeded once** from `/host/.claude-mem`, then container-private — see note below |
| Host Claude state, read-only stage | `$HOME/.claude` | `/host/.claude` | **read-only** | `post-create.sh` reads credentials + identity from here on container-create |
| claude-mem store, read-only stage | `$HOME/.claude-mem` | `/host/.claude-mem` | **read-only** | `post-create.sh` seeds the claude-mem volume from here on first create |
| Host Codex state, read-only stage | `$HOME/.codex` | `/host/.codex` | **read-only** | Same purpose for Codex |
| Host Cursor state, read-only stage | `$HOME/.cursor` | `/host/.cursor` | **read-only** | Same purpose for Cursor |
| **Claude shareable subdirs** | _seeded into the config volume from_`$HOME/.claude/{plugins/marketplaces,plugins/cache,skills,agents,memory,commands}` | same under `/home/node/.claude/` | n/a (copy) | **Seed-once** copy from the read-only stage; container owns its copy after |
| **Codex shareable subdirs** | _seeded from_`$HOME/.codex/{plugins,prompts,memories,skills}` | same under `/home/node/.codex/` | n/a (copy) | **Seed-once** copy (whole `plugins/` dir — no path-bearing registry inside it) |
| **Cursor shareable subdirs** | _seeded from_`$HOME/.cursor/{plugins/marketplaces,plugins/local,rules,commands,agents,skills}` | same under `/home/node/.cursor/` | n/a (copy) | **Seed-once** copy of the Cursor 2.5 plugin/rules/commands surface |
**What gets seeded once from the host (copy, not bind):**
On the **first** container-create, `post-create.sh` copies each of these out of the read-only `/host/.<cli>` stage into the per-container config volume, then writes a `.devcontainer-shareable-seeded` marker. On every later rebuild the marker is present, so the copy is skipped and the container keeps whatever it has accumulated. A plugin/skill/agent you install **inside** the container persists across rebuilds; one you add on the **host** after first create won't appear in the container until you remove the config volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)). Nothing here is writable back to the host — `/plugin marketplace add` inside the container installs into the container's own volume copy, not your host `~/.<cli>/plugins/`.
**Single config files are copied on container-create, not bind-mounted** — on Docker Desktop Windows a single-file bind is 9p while the named volume is ext4, and atomic config writes (`tmp` → rename onto target) trip EXDEV (this is what caused Codex's `config/batchWrite failed in TUI`). So these are synced from host on rebuild and the container rewrites its own copy until the next rebuild: `settings.json` + `$HOME/.claude.json` (Claude), `config.toml` (Codex), `cli-config.json` + `mcp.json` (Cursor). `hooks.json` (Cursor) is deliberately **not** synced — Cursor hooks execute shell commands, so sharing them would widen the supply-chain attack surface; add it yourself if you want it.
**Plugin registry files with absolute paths are translated, not copied verbatim** — Claude's `known_marketplaces.json` / `installed_plugins.json` / `plugin-catalog-cache.json` and Cursor's `installed_plugins.json` bake in `C:\Users\…` (Windows) or `/Users/…` (macOS) install paths. `post-create.sh` rewrites those to `/home/node/.<cli>/plugins/…` and writes the result into the named volume, so plugins resolve inside Linux instead of failing with `cache-miss`. This translation is **also seed-once per CLI** — it runs only for a CLI being seeded that create (`translate-plugin-registries.cjs claude cursor`), so it stays consistent with the seed-once `cache/` copy and won't overwrite a plugin you installed inside the container on a later rebuild. Codex needs no translation — its enablement registry is `config.toml` (git URLs + logical keys, no filesystem paths), so its whole `plugins/` dir is copied as-is.
**What stays per-container (in the named volume) and is synced from host on container-create:**
-`~/.claude/.claude.json` (Claude's identity-only file: `userID`, `oauthAccount`, migration tracking) — kept per-container so logging in via container doesn't overwrite host's stored identity
`post-create.sh` runs on every container-create, copies host's credentials into the volume if present, then container manages refresh from there. Sync is "always overwrite if host has the file, otherwise leave container alone". So:
- Host has credentials → container starts logged in.
- Host has no credentials → `claude login` / `codex login --device-auth` / `cursor-agent login` inside container; credentials stay in the named volume across rebuilds (volume is keyed by `${devcontainerId}`, stable for the workspace path).
**Why CLAUDE_CONFIG_DIR is intentionally NOT set:** Claude's default `~/.claude` matches the named-volume mount target, so the env var added no behavior — but setting it changed which file Claude reads `hasCompletedOnboarding` from. With it set, Claude reads `$CLAUDE_CONFIG_DIR/.claude.json` (the small identity-only file) and re-onboards every container; without it, Claude reads `$HOME/.claude.json` (copied from the read-only `/host/.claude.json` stage on container-create via `seed-claude-config.cjs`, with `hasCompletedOnboarding: true`).
**Host CLI config is protected from write-through.** The shareable dirs are copied out of a **read-only** stage into the container's own volume, so a compromised npm package in the workspace dep tree — running inside the container — **cannot** write a malicious agent, command, skill, or plugin back to `~/.claude/`, `~/.codex/`, or `~/.cursor/` on the host. The earlier design bind-mounted these read-write and accepted that write-through as the cost of live sync; this design closes it. An even earlier alternative (read-only stage + symlinks) made `/plugin marketplace add` inside the container fail with EROFS; copying into a writable volume avoids that, because the container writes to its own copy rather than a read-only mount. What a compromised dependency can still do is **read** the read-only host stages (`/host/.<cli>`, `/host/.claude-mem`) and the read-only credential mounts and exfiltrate them — there is [no egress firewall yet](#whats-not-included-yet). The cost of the copy model is **divergence**: host edits made after first create don't reach the container until you wipe the config volume and rebuild.
**Refresh-token divergence between rebuilds.** Container's credentials match host's at container-create time; after that, container manages its own refresh until the next rebuild. Anthropic rotates refresh tokens on every use, so an unattended container that hasn't talked to the API in weeks can hit a silent 401 if the host has refreshed since. Re-run `claude login` inside the container, or rebuild, to recover.
**claude-mem is seeded once, then container-private.** The [claude-mem](https://github.com/thedotmack/claude-mem) store (`$HOME/.claude-mem` — a multi-GB SQLite DB `claude-mem.db` + `-wal`/`-shm`, plus a Chroma vector store `chroma/chroma.sqlite3` and its HNSW index binaries) is the one shareable-looking folder that is **deliberately not a host bind**, for the same SQLite reason as sessions below: a multi-GB WAL database over the 9p/virtiofs bind risks unreliable `fcntl` locking and corruption — sharply so if claude-mem ran on the host and in the container against the same DB at once. So it gets its own per-container named volume (`claude-mem-${devcontainerId}`), and `post-create.sh`**seeds it once** from the read-only `/host/.claude-mem` stage _only when the volume has no DB yet_. The first container-create copies the host's store in (a one-time copy, possibly several GB); every later rebuild keeps whatever the container accumulated and skips the copy. The container's memory and the host's **diverge from that seed point** — writes do not flow back — which is the price of keeping SQLite off a shared bind. To re-seed from the host's current store, remove the volume (`docker volume rm claude-mem-<id>`) and rebuild. `ensure-host-config-dirs.cjs` creates an empty `~/.claude-mem` on hosts that never installed claude-mem, so the read-only stage bind always resolves; the seed then finds no DB and the container simply starts with empty memory.
### Session resume across container recreation
`claude --resume`, `codex resume`, and `cursor-agent resume` all read **local** transcript files. Those live _inside_ each CLI's config dir, which is a per-container named volume — so they already survive an ordinary **Rebuild Container**. What they did _not_ survive were the very things this README tells you to do: `docker volume rm <cli>-config-${devcontainerId}` to force a re-login or clear an `EACCES`, a `${devcontainerId}` change, or a full delete-and-recreate. Each of those drops the config volume and takes your session history with it.
So the resume/transcript directories get their **own** named volumes (mount group 6 in `devcontainer.json`), keyed like the `node_modules` volumes (`${localWorkspaceFolderBasename}-…-${devcontainerId}`) and mounted _over_ the config volume at the session sub-paths:
| `claude --resume` / `--continue` | `…-claude-sessions-…` → `~/.claude/projects` | `<encoded-cwd>/<uuid>.jsonl` transcripts + `sessions-index.json`. Container cwd is always `/workspace`, so only that slice is stored. Pure JSONL/JSON — no SQLite. |
| `codex resume` / `resume --last` | `…-codex-sessions-…` → `~/.codex/sessions` | `YYYY/MM/DD/rollout-*.jsonl`. The `state_5.sqlite` thread index stays on the config volume (a single WAL file we don't split out); when it's absent after a recreation, Codex rebuilds it from these rollouts on the next start (a one-time backfill). |
| `cursor-agent resume` / `ls` | `…-cursor-sessions-…` → `~/.cursor/chats`; `…-cursor-projects-…` → `~/.cursor/projects` | `chats/{hash}/{uuid}/store.db` (one SQLite db per session, each in its own dir) + `projects/.../agent-transcripts`. cursor-agent's layout is reverse-engineered, so treat this as best-effort. |
Because these are **separate** volumes from `<cli>-config-${devcontainerId}`, the re-login fix (`docker volume rm claude-config-…`) no longer destroys your sessions — that was the point.
**Survives:** Rebuild Container, Rebuild Without Cache, a full delete-and-recreate of the container, and the `docker volume rm <cli>-config-…` re-login / `EACCES` fix.
**Does _not_ survive** (same durability tier as the `node_modules` volumes): `docker volume prune`, a `${devcontainerId}` change (moving the checkout to a new path, or switching between Windows-native and WSL2), or moving to a new machine. To deliberately wipe sessions, remove the session volumes too — see [Rebuild / reset](#rebuild--reset). Two checkouts with the **same folder name** on one host would share session volumes only if they also share a `${devcontainerId}`; they don't, so they stay separate.
**First rebuild after adopting this, one-time:** if a container created _before_ these volumes existed already had sessions on the config volume (`~/.claude/projects`, `~/.codex/sessions`, …), the new empty session volume mounts _over_ that sub-path and **masks** the old content — same Docker-precedence shadowing described for plugins above. The old sessions are hidden, not deleted. To carry them forward once, copy them out of the config volume into the session volume; or just start fresh — new sessions land on the session volume from then on.
**Why sessions are container-private and not even seeded from the host.** The shareable config dirs are _seeded once_ from the host (you want your skills/agents/plugins in the container). Sessions are deliberately _not_ seeded and never touch the host, because a transcript can contain anything you pasted or the agent read — API keys, file contents, connection strings. Binding or copying them to/from the host would (a) spill that to host disk, (b) add a write-through surface a compromised dependency can reach (there's still [no egress firewall](#whats-not-included-yet)), and (c) leak _every other project's_ transcripts into the container (Codex `sessions/` and Cursor `chats/` aren't project-scoped). Container-private volumes avoid all three while still surviving recreation. And Claude/Codex transcripts embed the container cwd (`/workspace`), so even if you _did_ bind them to the host, the host CLI wouldn't natively `--resume` them — its encoded-cwd folder differs.
**Opt in to host-shared sessions anyway.** If you want transcripts visible/portable on the host and accept the trade-offs above, uncomment the host-bind block in `devcontainer.json` (just below the group-6 volumes) and add the matching source dirs to `ensure-host-config-dirs.cjs`'s `DIRS` so Docker can resolve the binds. That block scopes Claude to `/workspace`'s encoded subdir to limit the cross-project leak; the Codex and Cursor stores can't be scoped that way, so they expose every project's transcripts.
| `~/.config/gh` | `$HOME/.config/gh` | **copy → volume** | `gh` CLI auth (PR/issue create, checks) — seeded from your host login on create into a per-container volume; in-container `gh auth login` persists across rebuilds and never writes back to the host |
| `~/.docker` | `$HOME/.docker` | **read-only** | Container registry auth + buildx config (inert until you add Docker CLI via a Feature) |
**Why `ssh`/`aws`/`azure`/`docker` are read-only, and why `gh` is copied into a volume:**`ssh`/`aws`/`azure` are consumed read-only by their clients (the SSH client and the AWS/Azure SDKs only read their credential files), so a one-way mount loses nothing. `docker`_can_ write its own state (`docker login` / buildx write `config.json`), but a read-write host bind would let a compromised in-container dependency rewrite your host `~/.docker/config.json` (point a `credHelper` at an attacker-controlled binary) — a credential-takeover vector. The common case is _reading_ an existing host login, so `docker` stays **read-only**: registry pulls/pushes using your host creds work, only a `docker login` inside the container won't persist back. `gh` used to be read-only for the same reason, but that meant an in-container `gh auth login` had nowhere to write and silently failed. So `gh` now uses the **copy-into-volume** model (the same one the AI-CLI credentials use): the host `~/.config/gh` is a read-only _stage_ at `/host/.config/gh`, and `post-create.sh` copies `hosts.yml`/`config.yml` out of it into the per-container `gh-config` volume on create. The container gets a **writable** copy — `gh auth login` / `gh auth refresh` inside the container now work and persist across rebuilds — while the read-only stage guarantees nothing is ever written back to the host's token. If you want `docker` to behave the same way, give it the same treatment (a `/host/.docker` stage + a docker-config volume + a copy step in `post-create.sh`).
`~/.gitconfig` is **not** bind-mounted — VS Code's Dev Containers extension auto-copies the host's gitconfig into the container at attach time (this is built-in behavior, not something this devcontainer configures). The bind-mount approach conflicts with that auto-copy mechanism, so we let VS Code own it. The end result is the same: your host's `user.name` / `user.email` are available inside the container.
If a host source dir doesn't exist when the container is first created, the `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`) creates it empty — so the bind mount always has a valid source.
### Per-CLI quirks worth knowing
- **Claude Code on macOS** stores credentials in the system Keychain, not in `~/.claude/.credentials.json`. The sync silently no-ops; run `claude login` inside the container once and the named volume persists it.
- **Codex on macOS / Linux with `cli_auth_credentials_store = "keyring"`** stores auth in the OS keyring (Keychain / Secret Service), so `~/.codex/auth.json` may not exist on host. Same fallback: `codex login --device-auth` inside the container.
- **Cursor CLI inside containers** has [known upstream auth issues](https://forum.cursor.com/t/cursor-agent-authentication-issue-inside-docker/143995) — even with a correctly-synced `cli-config.json`, you may need to re-run `cursor-agent login` inside the container.
- **Stale named volumes from old rebuilds can carry forward.** If you delete and re-create the same workspace, or if a prior container left interim state with a different `userID`, deleting the named volumes before rebuild guarantees a clean sync: `docker volume rm claude-config-${devcontainerId} codex-config-${devcontainerId} cursor-config-${devcontainerId}` (look them up with `docker volume ls | grep -config-`).
- **User-scope MCP servers with absolute host paths won't resolve in-container.** `~/.claude.json` (Claude), `~/.codex/config.toml` (Codex), and `~/.cursor/mcp.json` (Cursor) are copied from host on container-create, so their user-scope `mcpServers` entries come along. But an entry whose `command` is an absolute host path (`C:\tools\foo.exe`, `/usr/local/bin/foo`) points at a binary that doesn't exist in the container — that server silently fails to launch. Only registry/`npx`-based servers (like this repo's `.mcp.json`, which uses `npx -y gitnexus@latest mcp`) and remote/URL servers work unchanged. The path-translation pass only rewrites `*/.<cli>/plugins/*` registry paths, **not** arbitrary `mcpServers` command paths (there's no correct container target for a host-local binary). Install such MCP servers inside the container, or use `npx`/remote ones.
- **Host config is seeded once per devcontainer, then diverges — this now applies to everything.** A `mcpServers` entry, setting, plugin, skill, agent, or command you add **on the host after** the container was created is not visible in the container until you remove the config volume and rebuild. Single config files (`mcpServers`, `settings.json`, …) are copy-on-create; the shareable dirs (plugins/skills/agents/memory/commands/prompts/rules) are copy-on-**first**-create (they persist across ordinary rebuilds and aren't even re-copied). Both diverge from the host after their copy. To pull host-side changes in, wipe the relevant volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)).
- **Plugins/skills/agents installed in-container persist; they do not reach the host.** A `/plugin marketplace add` (or `codex plugin add`, or a new skill/agent) inside the container writes to the container's own config volume and survives ordinary rebuilds. It never appears on the host — the host dirs are read-only sources, not bind targets. To get a plugin onto the host, install it on the host (then wipe + rebuild to seed it into the container).
- **No cross-checkout plugin contention.** Because each container copies plugins into its own per-`${devcontainerId}` volume rather than sharing one host bind source, two containers (or checkouts) installing plugins at the same time no longer interleave git clones/extractions against a shared host dir. Each writes only its own copy.
### What you still don't have inside the container
These are commonly-needed CLIs that aren't installed by default — adding them would be follow-up work, not in this PR's scope:
- **Docker CLI** (for `docker push` / `docker build` from inside the container). Add via `ghcr.io/devcontainers/features/docker-outside-of-docker:1` to the `features` block — `~/.docker/` is already mounted **read-only**, so your host `docker login` state works immediately for pulls/pushes; an in-container `docker login` won't persist to the host (drop `,readonly` on that mount if you need it to).
- **AWS CLI / Azure CLI / gcloud / kubectl** — same pattern: add the matching Feature, the host config dirs already flow through.
- **Private npm registry auth** (`~/.npmrc`) — you don't have a global one on this host. If you ever start using private packages, add `source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonly` to the mounts.
That means:
- **Authentication is shared.** If you're already logged in on the host (`claude login`, `codex login`, `cursor-agent login`, `gh auth login`), you're already logged in inside the container. No second login step.
- **Plugins, skills, agents, memory, and commands are seeded from the host once, then container-private.** On first create the container copies your host's plugins/skills/agents/memory/commands (and Codex prompts/memories, Cursor rules) into its own volume. After that they're independent: install or edit inside the container and it stays in the container (persists across rebuilds); add a plugin or agent on the host and the container won't see it until you wipe the config volume and rebuild. Nothing the container does reaches the host. (`settings.json` and the user-scope `~/.claude.json` are copy-on-create the same way; `~/.claude/projects/` is container-local by design.)
- **Git identity comes from the host.** Commits from inside the container use your host's `user.name` / `user.email` — VS Code's Dev Containers extension auto-copies your `~/.gitconfig` into the container at attach time. Any XDG-style config under `~/.config/git/` flows through via the read-only bind mount. To change git identity, edit `~/.gitconfig` on the host (container-side `git config --global` writes to a container-local file that's discarded on rebuild).
- **SSH keys flow through (read-only).** Push over SSH remotes and SSH commit signing work inside the container using your host keys. The mount is read-only so container code can't exfiltrate or modify private keys — agent-perspective, this means you get git operations but the keys stay vendor-side.
- **`gh` auth is shared, and in-container logins persist.** If you're logged in on the host, `gh pr create`, `gh pr checks`, `gh issue create` work inside the container without re-authenticating. If you're not, run `gh auth login` inside the container once — because `gh` config lives in a writable per-container volume (seeded from the host stage), that login persists across rebuilds and never touches the host's token.
- **No per-workspace duplication.** All your devcontainers across all your projects see the same host CLI state, just like all your host shells do.
The bind mount source directories are guaranteed to exist by the `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`), which runs on the host before container create. It's a Node script (not a shell one-liner) so the same command works on Windows `cmd.exe` and POSIX shells. It creates the top-level bind-mount source dirs — `~/.claude`, `~/.codex`, `~/.cursor`, `~/.claude-mem`, plus `~/.ssh`, `~/.docker`, `~/.aws`, `~/.azure`, `~/.config/{gh,git}`. It deliberately does **not** pre-create the shareable subdirs (skills/agents/plugins/…): those are no longer bind sources (they're copied out of the whole-`~/.<cli>` read-only stage), and pre-creating empty ones would needlessly write into the host of someone who never used that CLI.
### Trust boundary, concretely
Host and container share a single trust boundary by design — fine for personal-dev, but the consequence is concrete. Any malicious npm package or `postinstall` script in the workspace dep tree, running inside the container, has direct **read** access to:
- **Host AI CLI state** — the read-only stage at `/host/.claude`, `/host/.codex`, `/host/.cursor`, `/host/.claude-mem`, which exposes your **entire** host `~/.<cli>` tree (credentials, identity, AND the shareable skills/agents/plugins/memory/commands) for _reading_. The container copies what it needs out of this stage; a compromised dep can read all of it. It is read-only, so none of it can be written back
- The **container's own credential snapshots** at `/home/node/.claude/.credentials.json` etc. (copied from host on container-create)
-`~/.claude/memory/` / per-project memory (which may contain user-stored secrets if you've used the `/remember` skill)
- The **current container's own session transcripts** (`~/.claude/projects`, `~/.codex/sessions`, `~/.cursor/chats`/`projects` — the group-6 volumes), which can hold anything pasted into or read during a session. These are container-private (see one-way note below), so this is read access to _this_ container's sessions only, not the host's or other projects'
- Your **`gh` token** (`~/.config/gh`)
- Your **SSH private keys** (`~/.ssh/`)
- Docker registry tokens in **`~/.docker/config.json`** (if you've `docker login`-ed)
- AWS/Azure CLI credentials if you've populated `~/.aws/` or `~/.azure/`
It does **not** have write-through to the host's CLI config. The shareable dirs are copied out of the read-only stage into the container's own volume, so a compromised in-container dep **cannot** write into your host `~/.claude/{plugins,agents,skills,commands,memory}/`, `~/.codex/{plugins,prompts,memories,skills}/`, or `~/.cursor/{plugins,rules,commands,agents,skills}/`. The persistence vector earlier versions had — drop a malicious auto-loaded agent/command/skill/rule onto the host, have it run in your next **host** session — is closed: there is no writable path from the container to those host folders. (Cursor's `hooks.json` is still additionally withheld from even the _container's_ copy, because hooks fire without an agent invoking them.) The boundary is now one-way for **all** of the host CLI config, not just credentials.
**What stays one-way (genuinely protected):** everything. Credentials never flow back to host — `.credentials.json` / `auth.json` / `cli-config.json` live only in the per-container named volumes, and the `/host/.<cli>` stage they're copied from is mounted **read-only**, so the snapshot can't be overwritten back. The shareable AI-CLI dirs (skills/agents/plugins/memory/commands/prompts/rules) are now copy-on-create from that same read-only stage, so they have the one-way property too — readable for the copy, never writable back. `~/.ssh`, `~/.config/git`, `~/.aws`, `~/.azure`, and **`~/.docker`** are read-only binds with the same property — a compromised dep can _read_ your registry tokens but cannot _rewrite_ them to hijack your future host auth. **`~/.config/gh`** is now a read-only _stage_ copied into a per-container volume, so it keeps that same one-way property: the container reads it once to seed its own writable copy, and the read-only stage means an in-container `gh auth login` can never overwrite your host token. **Session transcripts** live in per-workspace named volumes (mount group 6) and are never seeded from or written back to the host, and the container can't see any _other_ project's transcripts. The opt-in host-bind block in `devcontainer.json` reverses that for sessions only — enable it only if you accept transcripts on host disk; see [Session resume across container recreation](#session-resume-across-container-recreation).
**The egress firewall is the key compensating control that is still missing.** It's deferred (see "What's not included (yet)" below), so a compromised package currently has unrestricted outbound network to exfiltrate anything in the read list above. Until it lands, treat that read surface as exposed to any code you run in the container — don't use this devcontainer on a machine whose host credentials you couldn't afford to rotate. The isolated-volume setup below removes host AI-CLI config/credentials from that surface entirely.
**If a workspace dep is ever found compromised**, rotate credentials at the vendor side — local file deletion is insufficient because tokens may have already left:
- Anthropic: [console.anthropic.com → Settings → Keys](https://console.anthropic.com/settings/keys), revoke the OAuth session under Account
- OpenAI / Codex: [platform.openai.com/api-keys](https://platform.openai.com/api-keys), revoke session under Profile
- GitHub: `gh auth refresh` or revoke the token at github.com/settings/tokens
For high-trust enterprise environments where the container should not even be able to **read** host CLI state, remove the three read-only stage binds (`/host/.claude`, `/host/.codex`, `/host/.cursor`) — plus `/host/.claude-mem` and `/host/.claude.json` — from `.devcontainer/devcontainer.json`. With no stage to copy from, `post-create.sh`'s seed and credential-sync steps quietly do nothing (their `[ -f ]` / `[ -d ]` guards), and each devcontainer starts with empty, fully isolated config and credentials (Anthropic's reference pattern). You give up seeding your host setup into the container in exchange for removing host config/credentials from the container's read surface entirely; log in inside each container instead.
## First-time CLI authentication
Each CLI works either way:
- **Log in on host first** → the container picks it up automatically on the next rebuild (`sync_from_host` copies the credential file into the named volume during `post-create.sh`). Host stays the source of truth.
- **Log in inside the container** → credentials write to the named volume. They persist across ordinary rebuilds (volume is keyed by `${devcontainerId}`, which is stable for a given workspace folder). The host's credentials are untouched.
You can mix and match per-CLI. A common setup is "Claude logged in on host, Codex/Cursor logged in inside container".
### Claude Code
```bash
claude login
```
Opens a browser auth flow. VS Code's port forwarding handles the OAuth callback automatically. After auth, `~/.claude/` is populated and visible from both host and container. The `DISABLE_AUTOUPDATER=1` env var prevents the in-container CLI from auto-updating — rebuild the container to pick up a newer Claude Code.
### OpenAI Codex CLI
```bash
codex login --device-auth
```
The device-code flow prints a URL and a one-time code. Visit the URL on your host browser, paste the code, and the CLI authenticates without needing a callback listener — this is the most reliable path inside containers. Credentials land in `~/.codex/auth.json` (shared with host).
`codex login` (browser-callback variant) also works but can be flaky in some headless contexts; prefer `--device-auth`.
### Cursor CLI
```bash
cursor-agent login
```
Opens a browser auth flow; VS Code's port forwarding handles the callback. Credentials persist in `~/.cursor/cli-config.json` (shared with host).
Verify any time with `cursor-agent status`.
## Alternative: API key authentication (CI / headless)
For non-interactive use (CI runners, automated scripts), all three CLIs accept API keys via env vars:
These env vars are intentionally **not** injected into the container from the host. `${localEnv:VAR}` resolves an unset host variable to an empty string, and some CLIs (Cursor in particular) treat a set-but-empty key as "use this key" rather than "fall back to stored login" — which would silently break the login flow for everyone who hasn't pre-set the host var.
To use an API key inside the container, export it in your terminal session:
```bash
exportANTHROPIC_API_KEY=sk-ant-...
# or OPENAI_API_KEY, or CURSOR_API_KEY
```
For persistence across container shells, carry the export via your VS Code [dotfiles repository](https://code.visualstudio.com/docs/devcontainers/containers#_personalizing-with-dotfile-repositories). VS Code clones the dotfiles repo into the container on attach and runs your install command, so the export lands in `~/.bashrc` / `~/.zshrc` per your own setup — and your API keys stay out of this repo's committed `devcontainer.json`.
A non-empty API key env var takes precedence over stored login credentials for each CLI.
VS Code's Ports panel shows forwarded ports once their listener starts.
## Known gotchas
- **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/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`.
## Rebuild / reset
- **Rebuild Container** (Command Palette) — re-runs the Dockerfile build and `postCreateCommand` against the existing named volumes (auth, history, **and sessions** persist).
- **Rebuild Container Without Cache** — fresh image layers, same volumes.
- **To force a re-login / clear an `EACCES`** — remove the per-container _config_ volumes and rebuild. As of the session-volume change this **no longer drops your `--resume` history** (sessions are on separate volumes — see [Session resume](#session-resume-across-container-recreation)):
```bash
docker volume ls | grep -- -config- # the credential / identity volumes
⚠️ Since the shareable dirs are now seeded into the config volume (not bind-mounted), wiping `<cli>-config` **also discards any plugin/skill/agent/command you installed _inside_ the container** and re-seeds those dirs from the host on the next rebuild. That is the intended way to pull host-side config changes in, but if you have in-container-only plugins you want to keep, reinstall them after the rebuild (or install them on the host first so the re-seed brings them along).
- **To also wipe session history** (a true clean slate) — remove the session volumes too (`<name>` is your workspace folder name):
```bash
docker volume ls | grep -E -- '-(sessions|cursor-projects)-' # the group-6 volumes
- **To re-seed claude-mem from the host** (the container's memory has diverged and you want the host's current store back) — remove the claude-mem volume and rebuild; `post-create.sh` copies the host store in again on the next create:
```bash
docker volume rm claude-mem-<id>
```
## Bumping CLI versions
Bump the version pins in `.devcontainer/devcontainer.json` `build.args` and rebuild — all three are real, fail-loud pins. Claude Code installs via `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}` and Codex via `npm install -g @openai/codex@${CODEX_VERSION}`. **Cursor is pinned too:** bump `CURSOR_VERSION` **and** both `CURSOR_SHA256_X64` / `CURSOR_SHA256_ARM64` together — the Dockerfile downloads the pinned `downloads.cursor.com/lab/<version>/linux/<arch>/agent-cli-package.tar.gz` artifact directly (no remote install script) and fails the build on a sha256 mismatch. Re-hash each arch with `curl -fSL <url> | sha256sum`. To stop Cursor from auto-updating in the running container, don't call `cursor-agent update`.
## What's not included (yet)
- **Egress firewall — the most important hardening still outstanding.** The original plan included an opt-in iptables/ipset firewall adapted from Anthropic's reference devcontainer. It was deferred to a follow-up PR — `runArgs` is static in `devcontainer.json`, so toggling NET_ADMIN/NET_RAW capabilities cleanly requires either a separate `devcontainer-firewall.json` profile or an `initializeCommand`-generated overlay. Until it lands, the read surface in [§ Trust boundary](#trust-boundary-concretely) has no network containment — anything readable can be exfiltrated. Track at the project's issue tracker if you need this.
- **Codespaces tuning.** The current config works in Codespaces incidentally (no privileged capabilities, no host-mount assumptions), but isn't actively tested there.
- **Playwright e2e support.** `gitnexus-web`'s `npm run test:e2e` needs Chromium libs that the base image doesn't ship. Use the host for e2e until a Playwright layer is added.
| `GitNexus devcontainer one-time Windows setup` banner from `initializeCommand` | First-time Windows-native Reopen-in-Container; `HOME` env var was missing | The script just ran `setx HOME "%USERPROFILE%"` for you. Close ALL VS Code windows (File → Exit) and reopen — see [Windows 11 setup](#windows-11-setup) |
| `bind source path does not exist: /.claude` (or similar) from Docker | Windows-native `HOME` env var is still missing even after one rebuild — `setx` may have failed or VS Code wasn't fully restarted | Run `setx HOME "%USERPROFILE%"` in a Windows shell manually, fully exit VS Code (check Task Manager that no `Code.exe` remains), reopen |
| `EACCES` / `EPERM` writing into `~/.claude`, `~/.codex`, or `~/.cursor` inside the container | Stale state from a previous container with a different effective UID | Move the affected dir aside and let the CLI rebuild it (`mv ~/.claude ~/.claude.bak` and log in again). Long-term: WSL2 setup, which doesn't hit this class of issue |
| `EPERM: operation not permitted, copyfile ... '.husky/_/h'` in `postCreateCommand` | Leftover `.husky/_/` from a previous container run on a Windows-side bind mount | `post-create.sh` already runs `rm -rf .husky/_` defensively. If you hit this on an older config, delete `.husky/_/` on the host and rebuild. Long-term: clone in WSL2 |
| Vite never hot-reloads | Repo cloned on Windows side, not WSL2 | Re-clone inside WSL2 |
| `gitnexus-web` can't reach the backend | `4747` was remapped or backend isn't running | Verify the Ports panel shows `4747` forwarded with no remap; start the backend with `cd gitnexus && npx gitnexus serve` |
| `npm install` fails on tree-sitter-swift / proto / dart | Native build toolchain missing | This shouldn't happen in the devcontainer — verify the apt layer installed `python3 make g++`. If iterating, set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` to skip the vendored grammars |
| Integration tests fail with `database busy` | LadybugDB single-writer constraint | Don't run host-side `gitnexus analyze` while the container is also analyzing the same repo; choose one writer |
| API key env vars not visible inside the container | They are intentionally not auto-propagated from the host (so an empty/stale host var can't silently break `*-login` for everyone else) | `export ANTHROPIC_API_KEY=...` / `OPENAI_API_KEY=...` / `CURSOR_API_KEY=...` inside the container shell, or carry it via your VS Code [dotfiles repo](https://code.visualstudio.com/docs/devcontainers/containers#_personalizing-with-dotfile-repositories) for persistence |
| `git commit` produces commits with empty author | `~/.gitconfig` is missing or empty on the host (VS Code's auto-copy had nothing to copy) | Set `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` from the host shell, then rebuild the container |
| `gh: not logged in` inside the container | Not logged in on the host (nothing to seed), or the `gh-config` volume is empty | Just run `gh auth login` **inside the container** — `gh` config lives in a writable per-container volume, so the login persists across rebuilds. (Logging in on the host instead also works: it seeds in on the next container create.) |
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. ─
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.');
- **Call-resolution DAG (legacy path):** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver`/`selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`.
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (see `registry-primary-flag.ts`). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR.
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro`→`MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below.
@@ -77,22 +76,22 @@ commits, or posts.
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## 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({search_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
@@ -174,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`.
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.
**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`.
- **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.
Typed 6-stage pipeline in `call-processor.ts` (inside the `parse` phase) that resolves method/function calls and emits CALLS edges. Language behavior plugs in at two `LanguageProvider` hook points (stages 3–4); shared code names no languages. Scope: call resolution only — import resolution, type extraction, heritage, and symbol-table population live in other phases.
`SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). The scope-resolution pipeline reads from here: `findOwnedMember`, `pickOverload`, and `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`.
-`primary: 'owner-scoped'` — MRO walk from receiver's type; used when receiver type is known.
-`fallback: 'free-arity-narrowed'` — after owner-scoped miss, search free-call candidates by arity only (Ruby uses this for implicit-self calls that miss their owner's MRO).
-`ancestryView: 'singleton'` — walk singleton/class ancestry instead of instance ancestry (Ruby `def self.foo` bodies, so `extend`-ed methods are found).
### Adding language behavior
1.**Implicit receivers** — implement `inferImplicitReceiver`: return null if call already has a receiver; otherwise use `findEnclosingClassInfo` (`ast-helpers.ts`) to find the enclosing context, return `ImplicitReceiverOverride` with `receiverSource: 'implicit-self'`, and optionally set `hint` for `selectDispatch`.
2.**Custom dispatch** — implement `selectDispatch`: inspect `receiverSource` and `hint`, return `DispatchDecision` with `primary`, optional `fallback`, optional `ancestryView`; return null to keep shared defaults.
3.**MRO strategy** — confirm `mroStrategy` is `'first-wins'`, `'c3'`, `'ruby-mixin'`, or `'none'`; consumed by `lookupMethodByOwnerWithMRO`.
**Ruby example** (`languages/ruby.ts` + `utils/ruby-self-call.ts`): `inferImplicitReceiver` rewrites bare-identifier calls to `self.method` and sets `hint` to `'instance'`/`'singleton'`; `selectDispatch` uses hint for `ancestryView` and adds `fallback: 'free-arity-narrowed'` for implicit-self calls.
### Code references
| Module | Purpose |
|--------|---------|
| `core/ingestion/call-types.ts` | DAG types: `ReceiverEnriched`, `DispatchDecision`, `ImplicitReceiverOverride` |
| `core/ingestion/model/resolve.ts` | `lookupMethodByOwnerWithMRO`: stage 5 MRO walk |
| `core/ingestion/languages/ruby.ts` | Both hooks + `mroStrategy: 'ruby-mixin'` |
| `core/ingestion/utils/ruby-self-call.ts` | Bare-call rewrite for `inferImplicitReceiver` |
### Coexistence with the scope-resolution pipeline
The Call-Resolution DAG is the **legacy path**. RFC #909 Ring 3 introduces a parallel **scope-resolution pipeline** (next section) that replaces stages 1–6 with a scope-indexed registry lookup. Both paths ship side-by-side and are gated per-language via `MIGRATED_LANGUAGES` + the `REGISTRY_PRIMARY_<LANG>` env var.
- **Unmigrated language** → Call-Resolution DAG runs; scope-resolution phase is a no-op.
- **Migrated language** (currently: Python, C#) → scope-resolution owns CALLS/ACCESSES/USES emission; the legacy DAG gates off for that language via `isRegistryPrimary(lang)` checks in `call-processor.ts` and `import-processor.ts`.
-`import-processor` still populates `importMap` for migrated languages — heritage's `ctx.resolve` reads it to disambiguate parent classes. Only edge emission is gated.
- CI runs BOTH paths for every migrated language on every PR (`.github/workflows/ci-scope-parity.yml`); both must pass.
#### Same-graph guarantee
Edges emitted by the scope-resolution pipeline and edges emitted by the legacy DAG are indistinguishable to downstream consumers (MCP tools, HTTP API, embeddings, group bridge):
- **Node identity** — both paths use `generateId(...)` from `lib/utils.ts`, the same qualified-name keyspace, and the same node labels (`File`, `Folder`, `Class`, `Method`, `Function`, …). Overload disambiguation suffixes `parameterTypes` into the id consistently — see `scope-resolution/graph-bridge/ids.ts` and the legacy emitter in `call-processor.ts`.
- **Edge vocabulary** — both paths emit the same reasons: `'import-resolved' | 'global' | 'local-call' | 'same-file' | 'interface-dispatch' | 'read' | 'write'`. Migrating a language must not change which reasons consumers see for previously-resolved edges.
- **Confidence tier** — both paths attach a numeric `confidence` to each edge using the same scale.
The CI parity workflow (`.github/workflows/ci-scope-parity.yml`) runs both paths against every migrated language's fixture corpus and fails on any divergence.
#### Semantic-model source of truth
Two independent invariants.
**ParsedFile = the AST-level truth.**`ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact both resolution paths consume. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
**SemanticModel = the symbol-level truth.**`SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). Both paths read from here:
- Legacy Call-Resolution DAG → `call-processor` Tier 1/2/3 via `model.symbols.lookupExactAll`, `model.methods.lookupMethodByName`, `model.types.lookupClassByName`, `lookupMethodByOwnerWithMRO`.
`ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact the scope-resolution pipeline consumes. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` for `Scope`-valued lookups (`classScopeByDefId`, `moduleScopeByFile`) that `SemanticModel` structurally cannot hold. No symbol-indexed duplicates exist outside `SemanticModel`.
**Write / read phase contract.** The model is mutable during three ordered phases and read-only afterward:
```
Phase 1: legacy parse ──► symbolTable.add fans into types/methods/fields
Phase 1: parse ──► symbolTable.add fans into types/methods/fields
`runScopeResolution` narrows `MutableSemanticModel` → `SemanticModel` at the phase boundary so downstream passes physically cannot mutate the model even accidentally.
**Transitional: reconciliation pass.**`reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose legacy extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose legacy extractor already carries `ownerId` (C#).
**Reconciliation pass.**`reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose parse-time extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose extractor already carries `ownerId` (C#).
The architectural end state is for every language's parse-time extractor to emit the correct `ownerId` directly, making reconciliation a no-op (tracked as a follow-up refactor). The dev-mode validator `validateOwnershipParity` surfaces any drift via `onWarn` under `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`.
Language-agnostic registry-primary resolver. Replaces the Call-Resolution DAG for migrated languages. Adding a language is one interface implementation (`ScopeResolver`) plus two registrations — no changes to shared code, no new pipeline phase.
Language-agnostic scope-resolution resolver. This is the resolution path for every language — it owns CALLS/ACCESSES/USES emission and inheritance edges. Adding a language is one interface implementation (`ScopeResolver`) plus one registration in the `SCOPE_RESOLVERS` map — no changes to shared code, no new pipeline phase. (RING4-1 #942 removed the legacy call-resolution DAG and the per-language `MIGRATED_LANGUAGES` flag, so `SCOPE_RESOLVERS` registration is all that's needed.)
### Pipeline stages
@@ -286,7 +202,11 @@ Language-agnostic registry-primary resolver. Replaces the Call-Resolution DAG fo
```
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates `SCOPE_RESOLVERS ∩ MIGRATED_LANGUAGES`, 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
@@ -312,7 +232,6 @@ Single interface a language implements to plug into the pipeline. Contract fully
1. Implement `ScopeResolver` in `languages/<lang>/scope-resolver.ts`.
2. Add entry to `SCOPE_RESOLVERS` in `scope-resolution/pipeline/registry.ts`.
3. Add the language to `MIGRATED_LANGUAGES` in `registry-primary-flag.ts` when the shadow-harness corpus parity ≥ 99% fixtures / ≥ 98% corpus.
CI auto-discovers the set via `tsx`. No workflow edit required.
@@ -328,7 +247,6 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
@@ -337,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.
@@ -351,7 +269,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
Language Providers (import semantics, type config, export checker, MRO strategy)
↑
@@ -376,7 +294,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
### Unified capture tags
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@heritage.extends`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@reference.inherits`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
`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`
**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).
All languages emit unified `ExtractedHeritage` (child, parent, `EXTENDS`/`IMPLEMENTS`). MRO phase walks the heritage graph using per-language strategy:
### 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:
- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks instead (see AGENTS.md).
- **Call & inheritance resolution:** See ARCHITECTURE.md § Scope-Resolution Pipeline. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` / `ScopeResolver` hooks instead (see AGENTS.md). (The legacy call-resolution DAG was removed in #942.)
- **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
## Changelog
@@ -58,22 +58,22 @@ See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.m
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## 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({search_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.
@@ -13,11 +13,17 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
## Development setup
**Prerequisites:** Node.js — `gitnexus/` requires `>=22.0.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
4. Run tests as described in [TESTING.md](TESTING.md).
### Containerized development (optional)
If you prefer an isolated environment with Claude Code, OpenAI Codex CLI, and Cursor CLI pre-installed, open the repo in VS Code with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) and run **Dev Containers: Reopen in Container**. See [`.devcontainer/README.md`](.devcontainer/README.md) for first-time auth flows and Windows WSL2 setup.
## Branch and pull requests
- Use short-lived branches off the default branch of the repo you are targeting.
@@ -151,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
That's it. This indexes the codebase, installs agent skills, registers Claude Code hooks, and creates `AGENTS.md` / `CLAUDE.md` context files — all in one command.
> **On npm 11.x?** `npx` can crash during install with `Cannot destructure property 'package' of 'node.target'` (an npm/arborist bug, before GitNexus runs). Use pnpm instead — it builds the native deps explicitly:
> Or install globally (`npm install -g gitnexus@latest`) and run `gitnexus analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
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`.
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
@@ -246,15 +262,64 @@ 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
`gitnexus analyze --embeddings` generates semantic search vectors with a default 50,000-node safety cap to protect memory on large repositories. Override the cap when you know the host has enough memory for a larger graph, or disable it entirely for a one-off full embeddings run.
```bash
# Generate embeddings with the default 50,000 node safety cap
gitnexus analyze --embeddings
# Disable the safety cap entirely
gitnexus analyze --embeddings 0
# Use a custom cap
gitnexus analyze --embeddings 100000
```
If embeddings are skipped on a large repository, the indexed graph likely exceeds the default safety cap. Re-run with `gitnexus analyze --embeddings 0` to remove the cap, or `gitnexus analyze --embeddings <n>` to choose a higher limit while still keeping memory bounded.
#### Project config (`.gitnexusrc`)
Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `analyze` options per project, instead of re-passing the same flags every run. It is read from the resolved repo root (not `.gitnexus/`, which is gitignored index storage). **CLI flags always override `.gitnexusrc`.**
```jsonc
{
// Default branch used in the generated regression-compare example (base_ref).
// Use this so a project on `develop`/`master` doesn't get "main" rewritten
// over its fix on every analyze. (Alias: "branch".)
"defaultBranch":"develop",
"skipContextFiles":true,// alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
- The default branch is resolved as: `--default-branch` > `.gitnexusrc``defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`.
-`skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection.
- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
#### Environment variables
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.
| `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. |
@@ -268,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)
@@ -282,7 +347,7 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
@@ -295,7 +360,7 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
| `group_query` | Search execution flows across all repos in a group | — |
| `group_status` | Check staleness of repos in a group | — |
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({search_query: "auth", repo: "my-app"})`.
**Resources** for instant context:
@@ -638,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.
| `cli-e2e` | `skills-e2e.test.ts` | Sequential | CLI process spawning requires serial execution |
| `default` | Everything else | Parallel | Fast execution for pure logic and parser tests |
When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to the `lbug-db` project's explicit include list and the `default` project's exclude list.
@@ -74,21 +74,11 @@ When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to
- **Resolver / parity** — Language-specific call-resolution tests in `test/integration/resolvers/`.
- **E2E (web)** — Critical user paths only; prefer `data-testid` attributes for stable selectors. Tests run against real backend (`gitnexus serve`) and Vite dev server.
## Scope-resolution parity
## Scope-resolution tests
Migrated languages (listed in `MIGRATED_LANGUAGES` in `src/core/ingestion/registry-primary-flag.ts`) are tested in both legacy and registry-primary modes on every PR.
Every language resolves calls and inheritance through the scope-resolution pipeline — the legacy call-resolution DAG and the per-language `REGISTRY_PRIMARY_<LANG>` flag were removed in RING4-1 (#942). Each language's resolver test lives at `test/integration/resolvers/<slug>.test.ts` and runs once, on the single scope-resolution path, as part of the normal `tests` job (`vitest test/**/*.test.ts`).
For each migrated language, CI runs the resolver test file twice:
Both must pass. Known legacy gaps are listed in `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES` in `test/integration/resolvers/helpers.ts` and are automatically skipped in legacy mode.
Adding a language to `MIGRATED_LANGUAGES` automatically enrolls it in parity — no workflow or config edit needed. The test file must exist at `test/integration/resolvers/<slug>.test.ts`.
Run parity locally: `cd gitnexus && npm run test:parity`
Run for a single language: `cd gitnexus && npx tsx scripts/run-parity.ts --language python`
Adding a language: register its `ScopeResolver` in `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`) and add the resolver test file — no workflow or config edit needed.
## Cross-platform testing
@@ -120,12 +110,12 @@ To check the cross-platform list is up to date, run `npm run test:cross-platform
| `ci-tests.yml` | ubuntu/coverage, cross-platform (Win/Mac), packaged-install-smoke | Full suite + coverage on Ubuntu; platform-sensitive subset on Win/Mac |
| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages |
"description":"Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
@@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
# GitNexus CLI Commands
All commands work via `npx` — no global install required.
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
## Commands
### analyze — Build or refresh the index
```bash
npx gitnexus analyze
node .gitnexus/run.cjs analyze
```
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
@@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
### status — Check index freshness
```bash
npx gitnexus status
node .gitnexus/run.cjs status
```
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
@@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a
### clean — Delete the index
```bash
npx gitnexus clean
node .gitnexus/run.cjs clean
```
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
@@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
### wiki — Generate documentation from the graph
```bash
npx gitnexus wiki
node .gitnexus/run.cjs wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
@@ -68,7 +70,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
### list — Show all indexed repos
```bash
npx gitnexus list
node .gitnexus/run.cjs list
```
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
`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:
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`.
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
@@ -40,7 +40,7 @@ If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array
### Verify
1. Index the project: `npx gitnexus analyze`
1. Index the project: `npx gitnexus analyze` (on npm 11.x, `npx` can crash during install — use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze` instead; see [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939))
2. Reload the Cursor window so it picks up the new hook config.
3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result.
4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.