Merge branch 'main' into feature/ci-setup-wizard

This commit is contained in:
Gergo Magyar 2026-06-14 08:02:16 +00:00
commit 8666342b9f
197 changed files with 22709 additions and 1349 deletions

View file

@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Workflow
```
1. query({query: "<error or symptom>"}) → Find related execution flows
1. query({search_query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@ -51,7 +51,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
**query** — find code related to error:
```
query({query: "payment validation error"})
query({search_query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
@ -75,7 +75,7 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. query({query: "payment error handling"})
1. query({search_query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError

View file

@ -18,7 +18,7 @@ description: "Use when the user asks how code works, wants to understand archite
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. query({query: "<what you want to understand>"}) → Find related execution flows
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@ -50,7 +50,7 @@ description: "Use when the user asks how code works, wants to understand archite
**query** — find execution flows related to a concept:
```
query({query: "payment processing"})
query({search_query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
@ -68,7 +68,7 @@ context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. query({query: "payment processing"})
2. query({search_query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. context({name: "processPayment"})

View file

@ -36,10 +36,12 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `check` | Check graph invariants such as circular imports |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` |
| `check` | Check graph invariants such as circular imports |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
@ -72,6 +74,25 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
### 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`.
### Control & data dependence (`pdg_query`)
`pdg_query` reads the control/data-dependence layers `gitnexus analyze --pdg` records (CDG + REACHING_DEF, basic-block granular) — the control/data analog of `explain`. It is **always anchored** (a `target` file path or symbol, resolved like `context`) and has two modes:
- `pdg_query { mode: "controls", target: "..." }` — CDG: "under what condition does X run?". Each edge is a controlling predicate block → dependent block with the branch sense (`'T'`/`'F'`) in `reason`; an edge into an early `return`/`throw` is flagged `guard: true` (guard-clause discovery — the sense depends on the predicate, so don't filter guards by a fixed label).
- `pdg_query { mode: "flows", target: "...", variable?: "..." }` — REACHING_DEF def→use edges within the function; pass `variable` to trace one binding.
A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown" when the layer can't be confirmed). Intra-procedural only — cross-function flow is taint's domain (`explain`). The raw CDG/REACHING_DEF edges are also queryable via `cypher`. See the `gitnexus-pdg-query` skill for the full query surface.
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:

View file

@ -0,0 +1,89 @@
---
name: gitnexus-pdg-query
description: "Use when querying or extending GitNexus's PDG control/data-dependence surface (the `pdg_query` MCP tool, CDG/REACHING_DEF edges), or reasoning about \"what controls X\" / \"where does Y flow\" / guard clauses. Examples: \"what guards this statement?\", \"trace this variable within the function\", \"why is the pdg_query result empty?\", \"add a CDG query\"."
---
# PDG query surface with GitNexus
Expert knowledge for the `pdg_query` MCP tool and the control/data-dependence
edges it reads — the opt-in `--pdg` program-dependence layers. Read this before
touching `gitnexus/src/mcp/local/local-backend.ts` (`_pdgQueryImpl`) or the
`pdg_query` tool def, or when explaining a `pdg_query` result.
## When to Use
- "Under what condition does this statement run?" (guarding predicates).
- "Where does this variable flow inside the function?" (def→use).
- Guard-clause discovery (early-return guards — subsumes the #559 heuristic).
- Extending or reviewing `pdg_query` / the CDG / REACHING_DEF read path.
- Debugging an empty or surprising `pdg_query` result.
## The layered substrate (build order)
`pdg_query` runs **on** the same graph taint runs on. Each layer is opt-in
behind `--pdg`; a default `analyze` run records none of them (byte-identical).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L5 CDG Ferrante control dependence (post-dominators) (M5 #2085)
```
All three are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table
(keyed by the `type` property). There is **no** `Function → BasicBlock` edge.
## The two modes
- `pdg_query({ mode: 'controls', target })` — CDG. For the anchored function,
each edge: controlling predicate block → dependent block + branch sense in
`label` (`'T'` = predicate's true/taken arm, `'F'` = false/fall-through). An
edge into an early-return/throw block is flagged `guard: true`.
- `pdg_query({ mode: 'flows', target, variable? })` — REACHING_DEF def→use
edges; `variable` filters to one binding.
`target` is **required** — a file path or a symbol/function name (resolved like
`context()`). There is no anchorless mode (see below).
## The corrected guard-clause Cypher
The RFC #567 §2 form (`[:CDG {label:'F'}]`) does **not** run as written. Edges
are values of the single `CodeRelation` table's `type` property, and the branch
sense is in `reason`, NOT a `label` column:
```cypher
MATCH (pred:BasicBlock)-[r:CodeRelation {type: 'CDG'}]->(dep:BasicBlock)
WHERE dep.text STARTS WITH 'return' OR dep.text STARTS WITH 'throw'
RETURN pred.startLine, r.reason AS branch, dep.startLine, dep.text
```
`r.reason` is the sense the predicate took to reach the early exit. For
`if (!ok) return;` the return rides the predicate's **true** arm (`'T'`) and the
protected body rides the **false** arm (`'F'`) — polarity depends on the guard,
so don't hard-code one sense.
## Gotchas (the load-bearing ones)
- **Always anchored + LIMIT-bounded.** LadybugDB has no rel-property index, so
an unanchored `[:CDG*]`/`[:REACHING_DEF*]` path scan is unbounded. `pdg_query`
requires `target` and bounds the page; raw `cypher` callers must anchor on a
file id-prefix or symbol span themselves.
- **BasicBlock↔symbol join is reconstructed.** No `Function→BasicBlock` edge:
the block is matched by its id-prefix (`BasicBlock:<file>:<fnStartLine>:…`)
plus `startLine` within the symbol's span. BasicBlock `startLine` is **1-based**
while the symbol node's `startLine`/`endLine` are **0-based**, so **both** bounds
are shifted `+1` (`[symStart+1, symEnd+1]`): the upper `+1` keeps a guard/def/use
on the function's **final line**, the lower `+1` excludes an adjacent function's
block on the line directly **above**. Same-line / nested functions anchor coarsely.
- **No PDG layer ⇒ a note, not an error.** If the repo wasn't indexed with
`--pdg` the tool returns `{ results: [], note: "no PDG layer …" }` (cheap meta
probe on `RepoMeta.pdg.maxCdgEdgesPerFunction` / `maxReachingDefEdgesPerFunction`).
- **CDG labels are binary in M5/M6.** Every `switch`-case arm is `'T'`; per-case
conditions are not yet distinguished.
- **Intra-procedural only.** Cross-function flow is taint's domain (`explain`).
## Mirror, don't fork
`_pdgQueryImpl` is the front half of `_explainImpl` (WAL wrapper, meta no-layer
probe, limit validation, `resolveSymbolCandidates` anchoring) with CDG/
REACHING_DEF instead of TAINTED — and none of taint's path-codec / interproc
`TAINT_PATH` machinery. Reuse those shared helpers; do not re-implement them.

View file

@ -17,7 +17,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
2. query({search_query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```

View file

@ -0,0 +1,178 @@
---
name: gitnexus-taint-analysis
description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
---
# CFG & Taint Analysis with GitNexus
Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
graphs, reaching definitions, and intra- + inter-procedural taint. Read this
before touching `gitnexus/src/core/ingestion/cfg/**` or
`gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
## When to Use
- "How does the taint engine work / why is this flow (not) reported?"
- Adding a source, sink, or sanitizer to the model.
- Extending or reviewing the CFG / reaching-defs / taint / summary code.
- Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
- Debugging a false positive or false negative in `--pdg` output.
## The layered substrate (build order)
Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
and a default `analyze` run is **byte-identical** (the golden parity gate is the
hard floor for every change here).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
```
- **Worker-built, main-thread-solved.** The parse worker builds each function's
CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
(plain, structured-clone-safe data — never AST nodes). The main thread runs
the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
OOM).
- **In-phase emit (KTD1).** L1–L4-harvest all run INSIDE the scope-resolution
pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
because the disk-backed ParsedFile store is cleared when that phase ends — a
standalone post-`mro` phase would read empty data. The cross-function fixpoint
(L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
small plain summary data threaded out via `ScopeResolutionOutput`.
- **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
`harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
(no graph, no I/O, no logger; sorted outputs). Snapshot tests and
content-derived edge ids depend on it.
## Intra-procedural taint (L3)
Forward reachability over RD facts from matched **sources** to matched **sinks**,
killed by **sanitizers**. Key design points worth internalizing:
- **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
`exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
precise.
- **Kind-set sanitizer model.** A taint carries a set of *neutralized*
`SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
kill would be a suppressed live injection (the forbidden FN direction).
`path.basename(t)` neutralizes path-traversal only, not command-injection.
- **Statement-level finding identity.** NOT block-pair (block conflation drops
distinct findings; `exec(req.body, req.query)` is two findings).
- Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
`reason` column via the shared versioned codec (`taint/path-codec.ts`).
## Interprocedural taint (L4) — the functional/summary method
The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
reduced to a compact **summary**, and summaries are composed over the already-
resolved `CALLS` graph.
**Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
| Edge | Meaning | Analogue |
|------|---------|----------|
| `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
| `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
| `param→sink` | a param reaches a modelled sink | partial/triggered sink |
| `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
| `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
| `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
**The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
parameter, source)`. Seed from `source→callee-arg`, propagate via
`param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
- **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
lattice (`fn × param × source`), so the worklist converges — a recursive call
just re-proposes an already-visited entry. SCC condensation would only refine
processing order; correctness/termination don't require it.
- **Source-discriminated state (load-bearing).** Key the state by the SOURCE
too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
tainted by source A is marked visited and a later flow from source B is dropped
before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
- **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
site) is fragile; the callee identity is exact and context-insensitivity
taints the callee's param identically at every call site.
- Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
in `reason` via the same codec; confidence < the intra-procedural 1.0.
**Context-insensitivity** is the accepted trade-off at this tier: one summary
per function, return/call-site merging accepted (security-conservative). Expect
some FP from merging; the bigger FN sources are unmodeled features (below).
## Known false-negative classes (documented, deferred)
The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
into a callback is dropped without per-library models (true of CodeQL's JS libs
too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
flows, promise/async-await threading, and **destructured/rest params before a
tainted simple param** (the summary port index is the binding ordinal, not the
formal arg position — needs a formal-param index threaded from the worker
`BindingEntry`). The interprocedural join is also context-insensitive: when one
caller invokes two distinct **same-named callees**, a flow into one
over-attributes to both (sound — over-report, never a missed flow). Absence of a
finding is NOT proof of safety.
## GitNexus-specific gotchas
- **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
`Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
ambiguous → drop (the summary driver counts `unresolved`) rather than
cross-wire.
- **No rel-property index (S1).** Kuzu has no secondary index on relationship
properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
traversed live; `explain` reads it source-anchored + LIMIT-guarded.
- **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
schema (pinned in `security.test.ts`). `explain` enumerates both layers
(cross-function findings carry `interprocedural: true`).
- **One shared codec.** Both the emit path and `explain` import
`taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
fork it. New metadata extends the format WITHIN the version when writer +
reader ship together.
- **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
full writeback. Model content rides `taintModelVersion`.
## Adding a source / sink / sanitizer
Edit the language model in `taint/typescript-model.ts` (registered via the
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
finding (or its absence) in `test/unit/taint/` (real-source harness:
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
`test/integration/cfg/`.
## Validation checklist for any `--pdg` change
```
1. tsc clean (schema additions are exhaustiveness-checked; watch the
api.ts getNodeQuery runtime read-path if a node label is added).
2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
test/integration/cfg) — verify by ISOLATION, not full-suite exit
(known load-flakes). `node scripts/build.js` before worker/integration runs.
3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
5. detect_changes() before commit; impact({direction:'upstream'}) before
editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
```
## Prior art (for deeper design questions)
Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
notation, incl. callback ports); Infer (content-keyed incremental summaries).

View file

@ -22,26 +22,10 @@
# --format '{{json .Manifest.Digest}}'
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:7c2e711a4f7b02f32d2da16192d5e05aa7c95279be4ce889cff5df316f251c1d
# Build args. We deliberately set no version defaults here. devcontainer.json
# `build.args` is the single source of truth for versions. A standalone
# `docker build .devcontainer/` (for example, a CI smoke test) must pass each
# version with --build-arg. Without a default, the build fails loudly instead of
# silently drifting from the version pinned in devcontainer.json.
ARG CLAUDE_CODE_VERSION
ARG CODEX_VERSION
# Cursor is pinned by version plus a per-arch tarball sha256 hash. The install
# step below verifies that hash. All three values live in devcontainer.json
# build.args. They follow the same rule as the others: one source of truth, and
# no default so the build fails loudly if a value is missing.
ARG CURSOR_VERSION
ARG CURSOR_SHA256_X64
ARG CURSOR_SHA256_ARM64
# Bun is installed via the official remote script (bun.sh/install), pinned by
# version. UNLIKE Cursor and the npm packages, this install path runs an
# UNVERIFIED remote script — there is no tarball-hash check. Chosen explicitly
# at request time over the pin-by-sha256 alternative for install-script
# simplicity. To harden later, switch to a pinned tarball + per-arch sha256 in
# the Cursor style (release artifacts at github.com/oven-sh/bun/releases).
# version. Claude Code and Cursor also use official install scripts (no version
# to pin). To harden Bun: switch to a pinned tarball + per-arch sha256
# (release artifacts at github.com/oven-sh/bun/releases).
ARG BUN_VERSION
ARG TZ=UTC
ARG USERNAME=node
@ -50,10 +34,7 @@ ARG USERNAME=node
# read them. We deliberately do not set CLAUDE_CONFIG_DIR here. Its one true
# value lives in devcontainer.json `containerEnv`, and the runtime value wins
# anyway.
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
CODEX_VERSION=${CODEX_VERSION} \
CURSOR_VERSION=${CURSOR_VERSION} \
BUN_VERSION=${BUN_VERSION} \
ENV BUN_VERSION=${BUN_VERSION} \
BUN_INSTALL=/home/${USERNAME}/.bun \
TZ=${TZ} \
DEVCONTAINER=true \
@ -86,51 +67,19 @@ RUN mkdir -p \
USER ${USERNAME}
# Install Claude Code and the Codex CLI globally, as the `node` user. The base
# image sets /usr/local/share/npm-global as the npm-global prefix and makes the
# `npm` group writable by `node`. So `npm install -g` works without sudo. Both
# versions come from build args. To upgrade, bump them in devcontainer.json and
# rebuild.
RUN npm install -g \
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
@openai/codex@${CODEX_VERSION}
# Install Claude Code via the official native installer. Downloads the latest
# self-contained binary for the running platform and places it at
# ~/.local/bin/claude — no Node.js runtime dependency, no version to pin.
RUN curl -fsSL https://claude.ai/install.sh | bash
# Install the Cursor CLI. It is pinned and hash-verified, and we run no remote
# script. The cursor.com/install script just detects os/arch, downloads a
# versioned tarball from
# downloads.cursor.com/lab/<version>/<os>/<arch>/agent-cli-package.tar.gz,
# extracts it, and symlinks `agent`/`cursor-agent` into ~/.local/bin. We do that
# ourselves against a PINNED version plus a per-arch sha256 hash. So the build
# runs no unverified remote code. This matches how we pin the base image and npm
# packages by digest (issue #1451). The download is fail-closed: if the hash
# does not match, the build aborts.
#
# To bump: set CURSOR_VERSION and both CURSOR_SHA256_* in devcontainer.json
# build.args. Get each arch's hash with:
# curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
#
# TARGETARCH is the per-platform build arg that BuildKit sets automatically. It
# must be (re)declared in this stage to be visible. When the build is a
# non-BuildKit `docker build`, TARGETARCH is unset, so we fall back to `dpkg
# --print-architecture`.
ARG TARGETARCH
RUN set -eux; \
arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \
case "$arch" in \
amd64) cursor_arch=x64; cursor_sha="${CURSOR_SHA256_X64}";; \
arm64) cursor_arch=arm64; cursor_sha="${CURSOR_SHA256_ARM64}";; \
*) echo "unsupported architecture for Cursor: $arch" >&2; exit 1;; \
esac; \
url="https://downloads.cursor.com/lab/${CURSOR_VERSION}/linux/${cursor_arch}/agent-cli-package.tar.gz"; \
curl -fSL --retry 3 --max-time 120 -o /tmp/cursor.tgz "$url"; \
echo "${cursor_sha} /tmp/cursor.tgz" | sha256sum -c -; \
dir="/home/${USERNAME}/.local/share/cursor-agent/versions/${CURSOR_VERSION}"; \
install -d "$dir" "/home/${USERNAME}/.local/bin"; \
tar --strip-components=1 -xzf /tmp/cursor.tgz -C "$dir"; \
test -x "$dir/cursor-agent"; \
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/agent"; \
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/cursor-agent"; \
rm -f /tmp/cursor.tgz
# Install the Codex CLI globally via npm. No version pinned — @latest at build
# time. (Codex has no native binary installer; npm is the canonical method.)
RUN npm install -g @openai/codex
# Install the Cursor agent CLI via the official install script. Downloads the
# latest agent-cli-package for the running platform and places `cursor-agent`
# and `agent` into ~/.local/bin — no version or hash to pin.
RUN curl -fsSL https://cursor.com/install | bash
# Install Bun via the official remote installer, pinned by version. The first
# positional arg to `bash` is the release tag (`bun-vX.Y.Z`), so a specific

View file

@ -14,16 +14,6 @@
"dockerfile": "Dockerfile",
"context": ".",
"args": {
"CLAUDE_CODE_VERSION": "2.1.156",
"CODEX_VERSION": "0.134.0",
// Cursor: a pinned version plus one sha256 hash per CPU arch. The
// Dockerfile checks the tarball against the hash at build time, so it
// never runs a remote install script. Bump all three values together.
// Re-hash each arch with:
// curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
"CURSOR_VERSION": "2026.05.28-a70ca7c",
"CURSOR_SHA256_X64": "7f8b6a09393e0b84b288cc6952b292fc98d15775f644cc01b0b9aa4f04b268df",
"CURSOR_SHA256_ARM64": "05a0ab361e038729aba25fe7f407531b3e8432912e499d0bffdf1dda0e7833e9",
// Bun: pinned by version. Installed by the official bun.sh/install
// script, which accepts the release tag as its first positional arg
// (`bash -s bun-vX.Y.Z`). UNLIKE Cursor, the install path runs an
@ -324,17 +314,6 @@
// dependency explicit instead of silently following the default.
"containerEnv": {
"CODEX_HOME": "/home/node/.codex",
"DISABLE_AUTOUPDATER": "1",
// post-create.sh removes `installMethod` from the seeded ~/.claude.json so
// the npm-global binary detects its own install method. This is a backup
// safeguard for Claude Code issue #17289. The install-checks routine probes
// ~/.local/bin/claude just because that directory EXISTS. It does exist
// here, because Cursor drops agent and cursor-agent symlinks there. So even
// when installMethod is non-native, the routine reports a false "claude
// command not found at ~/.local/bin/claude". DISABLE_AUTOUPDATER does NOT
// turn that routine off. DISABLE_INSTALLATION_CHECKS is its dedicated kill
// switch.
"DISABLE_INSTALLATION_CHECKS": "1",
"HISTFILE": "/commandhistory/.zsh_history"
},

View file

@ -141,15 +141,12 @@ sync_from_host /host/.codex/config.toml /home/node/.codex/config.toml 644
# Seed $HOME/.claude.json from the host, but NOT as a straight copy. That file
# mixes two kinds of state. Some is portable account and onboarding state we
# want to keep: hasCompletedOnboarding, oauthAccount, userID, projects,
# tipsHistory. The rest describes how Claude is installed on the host, and that
# part is never valid here. This image installs Claude with `npm install -g`,
# but the host's `installMethod` (for example "native") makes Claude look for
# ~/.local/bin/claude and fail with
# "claude command not found at /home/node/.local/bin/claude". The fix strips the
# machine-specific fields and forces hasCompletedOnboarding, while handling a
# host file that isn't a JSON object. That logic lives in seed-claude-config.cjs
# so it can be unit-tested and prettier-checked
# (translate-plugin-registries.test.cjs).
# tipsHistory. The rest describes how Claude is installed on the HOST, and that
# part is never valid here — for example the host's `installMethod` value only
# makes sense for the host's binary. The fix strips the machine-specific fields
# and forces hasCompletedOnboarding, while handling a host file that isn't a
# JSON object. That logic lives in seed-claude-config.cjs so it can be
# unit-tested and prettier-checked (translate-plugin-registries.test.cjs).
node "$SCRIPT_DIR/seed-claude-config.cjs"
# Codex auth. Some hosts store credentials in the OS keyring instead of on disk

View file

@ -1,27 +1,14 @@
#!/usr/bin/env python3
"""Monitor tree-sitter 0.25 upgrade readiness.
"""Monitor tree-sitter 0.25 upgrade readiness — two things Dependabot can't see:
Tracks two things Dependabot cannot see:
1. Peer-dep compatibility: when every grammar's *latest npm release* accepts
tree-sitter@0.25.0 (so we can upgrade without --legacy-peer-deps).
2. Vendored upstream drift: whether a vendored grammar's upstream parser.c moved.
1. Peer-dep compatibility. Each tree-sitter-* grammar declares a peer
dependency on the tree-sitter runtime. We want to know when every
grammar's *latest npm release* satisfies tree-sitter@0.25.0 so we
can upgrade without --legacy-peer-deps.
2. Vendored upstream drift. vendor/tree-sitter-proto/ is a snapshot of
coder3101/tree-sitter-proto's parser.c. When upstream moves, we want
to know whether we can pick it up.
Invoked from .github/workflows/tree-sitter-upgrade-readiness.yml daily.
Runs locally too:
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py
Outputs Markdown to stdout. Exit 0 when every grammar is upgrade-ready
and the vendored proto is in sync. Exit 1 when blockers remain (the
workflow uses this to open or update a tracking issue).
No external deps -- stdlib only, so it runs on any vanilla runner.
Invoked daily from tree-sitter-upgrade-readiness.yml; runs locally too. Outputs
Markdown to stdout; exit 1 when blockers remain (the workflow upserts a tracking
issue). stdlib-only — runs on any vanilla runner.
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py [--offline | --assert-current]
"""
from __future__ import annotations
@ -38,6 +25,11 @@ import urllib.request
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
GITNEXUS_DIR = REPO_ROOT / "gitnexus"
# Offline mode (--offline flag or GITNEXUS_TS_READINESS_OFFLINE=1): skip ALL network
# so the script + tests run hermetically. npm columns render "n/a (offline)";
# vendored ABIs are still read from the repo. The read-path mirror of --assert-current.
OFFLINE = os.environ.get("GITNEXUS_TS_READINESS_OFFLINE", "") not in ("", "0", "false")
# ── Upgrade target ──────────────────────────────────────────────────────
# The runtime version we want to upgrade TO. Update this when the goal
# changes (e.g. once 0.25 lands and we target 0.26).
@ -78,15 +70,11 @@ GRAMMARS: dict[str, tuple[str, str, str]] = {
"tree-sitter-proto": ("coder3101/tree-sitter-proto", "main", "src/parser.c"),
}
# Grammars deliberately held below npm latest. The readiness report surfaces
# these so reviewers can tell intentional pins apart from drift, and so the
# context for each pin (which issue motivated it) is visible at a glance.
# Add an entry whenever you pin a grammar below npm latest.
# npm-installed grammars deliberately held below npm latest (surfaced so reviewers
# can tell intentional pins from drift). Add an entry when you pin an npm grammar.
# VENDORED grammars carry their hold in .github/vendored-grammars.json instead, so a
# vendored grammar's hold lives in one place — tree-sitter-c's is there, not here.
INTENTIONAL_PINS: dict[str, str] = {
"tree-sitter-c": (
"#1242 — last release built against the tree-sitter@0.21 ABI; "
"tree-sitter-c@0.23.x prebuilds segfault on Windows under tree-sitter@0.21.1"
),
"tree-sitter-cpp": (
"#1242 — last 0.23.x release before tree-sitter-cpp added a runtime "
"dep on the broken-ABI tree-sitter-c@^0.23.1; pinning here removes "
@ -95,6 +83,56 @@ INTENTIONAL_PINS: dict[str, str] = {
}
def load_vendored_manifest() -> dict[str, dict]:
"""Load the shared vendored-grammar manifest (.github/vendored-grammars.json).
The single source of truth — shared with update-vendored-grammars.mjs — for
which grammars are *vendored* (shipped from gitnexus/vendor/<name>, not npm)
and any policy ``hold`` (e.g. tree-sitter-c, #1242/#858). Membership routes a
grammar to the vendored branch, which reads its ABI from the repo instead of
node_modules (the #858 source of the old bare ``?``). Returns
``{ name: {"hold": str | None} }``; upstream-drift coords stay in ``GRAMMARS``.
"""
manifest_path = REPO_ROOT / ".github" / "vendored-grammars.json"
# Fail loud with a pointer, not a bare traceback: this runs at module import,
# so a missing/corrupt manifest would otherwise crash both the script and any
# test that imports it with an opaque FileNotFoundError/JSONDecodeError.
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise SystemExit(
f"vendored-grammars manifest not found at {manifest_path}. "
f"It is the shared source of truth for vendored grammars "
f"(see CONTRIBUTING.md → CI automation contracts)."
) from exc
except json.JSONDecodeError as exc:
raise SystemExit(
f"vendored-grammars manifest at {manifest_path} is not valid JSON: {exc}."
) from exc
out: dict[str, dict] = {}
for key, g in (data.get("grammars") or {}).items():
name = g.get("name")
if not name:
raise SystemExit(
f"vendored-grammars manifest entry {key!r} is missing a 'name' field "
f"({manifest_path})."
)
# Defense-in-depth (#2187): `name` is joined into gitnexus/vendor/<name>, so
# reject anything not a plain grammar name before it can traverse ("../etc").
if not re.fullmatch(r"tree-sitter-[a-z0-9-]+", name):
raise SystemExit(
f"vendored-grammars manifest entry {key!r} has an invalid grammar "
f"name {name!r} (must match tree-sitter-[a-z0-9-]+)."
)
out[name] = {"hold": g.get("hold")}
return out
# Vendored set + holds, keyed by full grammar name (e.g. "tree-sitter-c").
VENDORED: dict[str, dict] = load_vendored_manifest()
VENDORED_NAMES: frozenset[str] = frozenset(VENDORED)
# ── Helpers ─────────────────────────────────────────────────────────────
def _load_package_json() -> dict:
@ -134,6 +172,8 @@ def npm_view_json(pkg: str) -> dict | None:
being available (it's a batch file on Windows which complicates
subprocess calls).
"""
if OFFLINE:
return None
url = f"https://registry.npmjs.org/{pkg}/latest"
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
@ -190,6 +230,8 @@ def fetch_text(url: str, timeout: int = 8) -> str | None:
Adds an Authorization header for github.com URLs when GITHUB_TOKEN is
set (raises the rate limit from 60 to 5 000 requests/hour).
"""
if OFFLINE:
return None
headers: dict[str, str] = {}
# Parse the URL and check the hostname rather than substring-matching
# on the full URL string (CodeQL py/incomplete-url-substring-sanitization).
@ -231,14 +273,8 @@ def md_h(text: str, level: int = 2) -> str:
def _first_sentence(text: str) -> str:
"""Return the leading sentence of a free-form rationale string.
Vendor package.json `_vendoredBy` fields often look like
"<reason>. <install-script breadcrumb>. Do NOT <warning>." — the
first sentence is what reviewers actually want to read; the rest is
noise in this context. Match a sentence-ending '.' followed by
whitespace; fall back to the whole string if nothing matches.
"""
"""Return the leading sentence of a `_vendoredBy` rationale (the rest tails off
into install-script breadcrumbs); fall back to the whole string."""
text = text.strip()
match = re.search(r"\.\s+[A-Z]", text)
return text[: match.start() + 1] if match else text
@ -262,21 +298,26 @@ def range_includes(spec: str | None, version: str) -> bool:
return spec.strip() == version.strip()
def is_vendored_pin(spec: str | None) -> bool:
return bool(spec) and spec.startswith(("file:", "git", "http"))
def vendored_abi_from_repo(name: str, parser_path: str) -> int | None:
"""Read a vendored grammar's ABI directly from gitnexus/vendor/<name>.
Local-only (no network) — the offline half of ``vendored_drift_summary``,
factored out so the hermetic ``--assert-current`` gate can introspect vendored
ABIs without triggering the upstream-drift fetches it never uses (#858 review).
"""
vendor_dir = GITNEXUS_DIR / "vendor" / name
vendored_parser = vendor_dir / parser_path
if not vendored_parser.is_file():
vendored_parser = vendor_dir / "src" / "parser.c"
return extract_language_version(vendored_parser)
def vendored_drift_summary(
name: str, upstream_repo: str, upstream_branch: str, parser_path: str
) -> dict:
"""Inspect a vendored grammar under gitnexus/vendor/<name>.
Returns the vendored package.json's ``version`` and ``_vendoredBy``
fields (which carry the human rationale for vendoring), the vendored
parser's ABI, and a comparison against upstream main. We deliberately
rely on ``_vendoredBy`` rather than a parallel registry in this
script: the rationale belongs next to the vendored sources, not in
a daily-running CI script.
"""Inspect a vendored grammar under gitnexus/vendor/<name>: returns its
package.json ``version`` + ``_vendoredBy`` (the rationale, kept next to the
sources), the vendored ABI, and a comparison against upstream main.
"""
vendor_dir = GITNEXUS_DIR / "vendor" / name
pkg: dict = {}
@ -290,7 +331,7 @@ def vendored_drift_summary(
vendored_parser = vendor_dir / parser_path
if not vendored_parser.is_file():
vendored_parser = vendor_dir / "src" / "parser.c"
vendored_abi = extract_language_version(vendored_parser)
vendored_abi = vendored_abi_from_repo(name, parser_path)
upstream_url = (
f"https://raw.githubusercontent.com/{upstream_repo}/"
@ -302,10 +343,13 @@ def vendored_drift_summary(
sha_text = fetch_text(
f"https://api.github.com/repos/{upstream_repo}/commits/{upstream_branch}"
)
upstream_sha = "?"
# Labeled fallback rather than a bare "?": in CI this fetch succeeds, but
# offline (or on a transient API miss) the report should say *why* it's
# blank instead of leaving a placeholder (#858).
upstream_sha = "unknown"
if sha_text:
try:
upstream_sha = json.loads(sha_text).get("sha", "?")[:12]
upstream_sha = json.loads(sha_text).get("sha", "unknown")[:12]
except json.JSONDecodeError:
pass
@ -321,7 +365,9 @@ def vendored_drift_summary(
return {
"name": name,
"vendored_version": pkg.get("version", "?"),
# Labeled fallback, never a bare "?": a vendor package.json should always
# carry a version, but if one is missing the report says so plainly (#858).
"vendored_version": pkg.get("version") or "unknown",
"vendored_by": pkg.get("_vendoredBy"),
"vendored_abi": vendored_abi,
"upstream_repo": upstream_repo,
@ -336,27 +382,14 @@ def vendored_drift_summary(
def assert_current() -> int:
"""Assert every grammar's ABI is loadable by the CURRENT runtime.
Unlike the readiness report (which probes the npm registry + upstream
main for the *target* runtime), this mode is hermetic and offline: it
reads only what's checked out / installed locally and asserts each
grammar's compiled ABI lies within the current runtime's
``RUNTIME_ABI_RANGES`` window. It is the static half of the #1922 ABI
gate; the runtime load-smoke (`parser-loader-abi.test.ts`) is the
dynamic half.
Coverage, reusing the existing helpers:
- npm-installed grammars: ABI from node_modules/<name>/<parser.c>.
- vendored grammars (dart/proto/swift): ABI via ``vendored_drift_summary``.
- Swift is prebuilt-only (no parser.c) → not introspectable here;
treated as "covered by the runtime load-smoke", not asserted.
- INTENTIONAL_PINS are honored: a pinned grammar is expected to sit at
an ABI the current runtime loads (that's *why* it's pinned), so it is
asserted like any other rather than skipped.
"""Assert every grammar's compiled ABI loads on the CURRENT runtime.
The hermetic/offline static half of the #1922 ABI gate (the runtime
load-smoke is the dynamic half): reads only local files — npm ABIs from
node_modules/<name>, vendored ABIs from gitnexus/vendor/<name> via
``vendored_abi_from_repo`` (no network). A prebuilt-only vendor (no
parser.c) is skipped; INTENTIONAL_PINS are asserted like any other grammar.
Returns 0 when every introspectable grammar is in range, 1 otherwise.
Prints a plain-text (non-Markdown) report so CI logs stay readable.
"""
current_runtime = read_current_runtime()
abi_range = RUNTIME_ABI_RANGES.get(current_runtime)
@ -380,13 +413,20 @@ def assert_current() -> int:
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
pinned_spec = pinned_versions.get(name, "—")
pin_note = f" [intentional pin: {pinned_spec}]" if name in INTENTIONAL_PINS else ""
if name in VENDORED_NAMES and VENDORED[name].get("hold"):
pin_note = " [vendored, held]"
elif name in INTENTIONAL_PINS:
pin_note = f" [intentional pin: {pinned_spec}]"
else:
pin_note = ""
if is_vendored_pin(pinned_spec):
v = vendored_drift_summary(name, upstream_repo, upstream_branch, parser_path)
abi = v["vendored_abi"]
# Vendored grammars: ABI read locally from the repo via vendored_abi_from_repo
# (NOT vendored_drift_summary, which fetches upstream — this gate is hermetic),
# so the offline #1922 gate covers them instead of skipping them (#858/#2187).
if name in VENDORED_NAMES:
abi = vendored_abi_from_repo(name, parser_path)
if abi is None:
# Prebuilt-only vendor (e.g. tree-sitter-swift): no parser.c to
# Prebuilt-only vendor (e.g. a binary-only grammar): no parser.c to
# introspect. The runtime load-smoke covers it instead.
skipped.append(f"{name} (vendored, prebuilt — covered by load-smoke)")
continue
@ -453,32 +493,18 @@ def _classify_grammar(
) -> dict:
"""Decide a single primary disposition + a separate bump-now hint.
Buckets are mutually exclusive and ordered by what a reviewer should
look at first:
- fetch_failed : npm registry fetch failed (treat as blocker, but
surface separately so reviewers don't confuse it
with an upstream block)
- intentional : pinned in INTENTIONAL_PINS — explicit choice
- ready : npm-latest peer dep already accepts the target
runtime; nothing to do
- waiting : main has a fix (ABI 15 or relaxed peer) but no
published npm release yet
- blocked : peer dep too tight on both npm and main
Independently of bucket, `bump_now` reports whether reviewers can
move the pin forward today without touching the runtime — we only
suggest it when npm-latest's peer dep also accepts our *current*
runtime, otherwise the bump would break `npm install`.
Mutually-exclusive buckets, ordered by reviewer priority: ``fetch_failed``
(npm fetch failed — surfaced apart from upstream blocks), ``intentional``
(in INTENTIONAL_PINS), ``ready`` (npm-latest peer accepts the target),
``waiting`` (a fix on main, unpublished), ``blocked`` (peer too tight on
both). ``bump_now`` is independent: True only when npm-latest's peer also
accepts our *current* runtime (else the bump would break ``npm install``).
"""
is_vendored = is_vendored_pin(pinned_spec)
behind_latest = (
not is_vendored
and npm_version != "?"
and not range_includes(pinned_spec, npm_version)
)
# Intentional pins must never appear as actionable bumps — by definition
# we're holding them back on purpose. The pin can only be lifted by
# editing INTENTIONAL_PINS and package.json together.
# Only npm-path grammars reach this function — vendored grammars are routed
# to the vendored branch in main() and `continue` before classification.
behind_latest = npm_version != "?" and not range_includes(pinned_spec, npm_version)
# Intentional pins are never actionable bumps (held on purpose; lifted only by
# editing INTENTIONAL_PINS + package.json together).
bump_now = behind_latest and current_compat and name not in INTENTIONAL_PINS
if fetch_failed:
@ -496,6 +522,9 @@ def _classify_grammar(
"name": name,
"pinned_spec": pinned_spec or "—",
"npm_version": npm_version,
# Display form for the disposition prose, laundering a "?" (a malformed 200
# npm response lacking `version`) so it never shows bare, like the matrix cell.
"npm_version_label": "unknown" if npm_version == "?" else npm_version,
"peer_range": peer_range,
"target_compat": target_compat,
"current_compat": current_compat,
@ -503,15 +532,105 @@ def _classify_grammar(
"behind_latest": behind_latest,
"bump_now": bump_now,
"bucket": bucket,
"is_vendored": is_vendored,
}
def _render_vendored_section(
vendored_grammars: list[dict],
target_abi_range: tuple[int, int],
blockers: dict[str, str],
) -> list[str]:
"""Render the 'Vendored parsers' prose block. Appends any runtime-side blocker
(upstream ABI beyond the target range) to ``blockers`` in place; returns the
markdown lines (empty when nothing is vendored). Extracted from main() so that
function coordinates named render phases rather than inlining them (#2187)."""
if not vendored_grammars:
return []
# Hoisted out of the list literal below: an implicit string concatenation
# inside a list display trips CodeQL py/implicit-string-concatenation-in-list
# (it reads as a possibly-missing comma between elements).
intro = (
"These grammars ship from `gitnexus/vendor/` rather than the npm "
"registry. Their compatibility is governed by the **vendored "
"ABI** (must lie in the target runtime's range), not by a peer-"
"dep negotiation. The rationale for each vendored copy lives in "
"its own `package.json` `_vendoredBy` field."
)
lines = [md_h(f"Vendored parsers ({len(vendored_grammars)})", 2), intro, ""]
for v in sorted(vendored_grammars, key=lambda v: v["name"]):
sync_label = "in sync with upstream" if v["in_sync"] else "diverged from upstream"
if v["abi_state"] == "in_range":
abi_label = f"ABI `{v['vendored_abi']}` (in target range)"
elif v["abi_state"] == "prebuilt":
abi_label = "ABI `prebuilt` (binary-only vendor, source not introspectable)"
else:
abi_label = (
f"ABI `{v['vendored_abi']}` (**outside** target range "
f"{target_abi_range[0]}..{target_abi_range[1]})"
)
# Never a bare "?": when upstream parser.c can't be read (generated at build,
# or a transient fetch miss), use the neutral `n/a` token (#858).
upstream_abi_str = (
f"ABI `{v['upstream_abi']}`" if v["upstream_abi"] is not None else "ABI `n/a`"
)
lines.append(
f"- **`{v['name']}`** `{v['vendored_version']}` — {abi_label}, "
f"upstream `{v['upstream_repo']}@{v['upstream_sha']}` "
f"{upstream_abi_str} · {sync_label}"
)
if v.get("hold"):
lines.append(f" - **Held:** {v['hold']}")
if v["vendored_by"]:
# First sentence only — vendor _vendoredBy fields tail off into noise.
lines.append(f" - **Why vendored:** {_first_sentence(v['vendored_by'])}")
# Action: regen iff upstream ABI exceeds vendored AND stays within target;
# beyond target is a runtime-side blocker. Prebuilt-only vendors get a
# manual-refresh action driven by the in-sync flag instead.
if v["abi_state"] == "prebuilt":
if not v["in_sync"]:
lines.append(
" - **Action:** check whether upstream has shipped a new "
"prebuilt release; this vendor ships binary-only artefacts."
)
elif v["upstream_abi"] and v["vendored_abi"] and v["upstream_abi"] > v["vendored_abi"]:
if v["upstream_abi"] <= target_abi_range[1]:
lines.append(
f" - **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, "
f"regenerate `parser.c` from upstream `{v['upstream_sha']}`."
)
else:
lines.append(
f" - **Action:** wait for a runtime supporting ABI "
f"{v['upstream_abi']}; current target ({TARGET_RUNTIME}) only "
f"goes up to ABI {target_abi_range[1]}."
)
blockers[f"vendored-{v['name']}-abi"] = (
f"vendored {v['name']}: upstream ABI {v['upstream_abi']} outside target range"
)
elif not v["in_sync"]:
lines.append(
" - **Action:** review upstream changes; vendored copy may "
"need a refresh (no ABI bump required)."
)
lines.append("")
return lines
def main() -> int:
blockers: dict[str, str] = {}
lines: list[str] = []
# Label for npm/upstream values we couldn't determine: in --offline mode the
# fetch was deliberately skipped (not "failed"), so say so honestly.
miss_label = "offline" if OFFLINE else "fetch failed"
lines.append(md_h("Tree-sitter 0.25 upgrade readiness", 1))
lines.append("")
if OFFLINE:
lines.append(
"> **Offline mode** — npm registry + upstream GitHub checks were skipped. "
"npm-installed grammars show as unverified; vendored-grammar ABIs are read "
"from `gitnexus/vendor/`."
)
lines.append("")
current_runtime = read_current_runtime()
current_abi_range = RUNTIME_ABI_RANGES.get(current_runtime, (0, 0))
@ -525,10 +644,9 @@ def main() -> int:
)
lines.append("")
# First pass: gather raw data + classification per grammar. We render
# the human-friendly buckets first, then the raw matrix in a <details>
# block at the end. Status text in the matrix is preserved verbatim
# so the workflow's row-diff change-detection keeps working.
# First pass: gather + classify per grammar. Human buckets render first, then
# the raw matrix in a <details> block (Status text preserved verbatim so the
# workflow's row-diff change-detection keeps working).
grammar_rows: list[dict] = []
raw_matrix: list[str] = [
"| Grammar | Pinned | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |",
@ -540,17 +658,17 @@ def main() -> int:
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
pinned_spec = pinned_versions.get(name, "—")
# Vendored grammars don't have an "npm latest" we install from —
# we ship our own copy under gitnexus/vendor/<name>. Treat them
# as a separate kind of artefact: their readiness for the runtime
# upgrade depends on the vendored ABI being in the target range,
# not on a peer-dep negotiation.
if is_vendored_pin(pinned_spec):
# Vendored grammars are classified by manifest membership (NOT a file: pin
# heuristic — they aren't in package.json at all, the #858 misrouting bug).
# Their readiness is governed by the vendored ABI, read from the repo, not a
# peer-dep negotiation. npm-latest columns get sentinels.
if name in VENDORED_NAMES:
v = vendored_drift_summary(name, upstream_repo, upstream_branch, parser_path)
v["pinned_spec"] = pinned_spec
# Three-state classification: in-range, out-of-range, or
# not-introspectable (e.g. tree-sitter-swift ships only
# prebuilt .node binaries, no parser.c — assume compatible).
hold = VENDORED[name].get("hold")
v["hold"] = hold
# Three-state ABI classification: in-range, out-of-range, or
# not-introspectable (e.g. a prebuilt-only vendor with no parser.c).
if v["vendored_abi"] is None:
v["target_compat"] = True
v["abi_state"] = "prebuilt"
@ -567,13 +685,37 @@ def main() -> int:
f"vendored `{name}`: ABI {v['vendored_abi']} outside target range "
f"{target_abi_range[0]}..{target_abi_range[1]}"
)
# A held vendored grammar (e.g. tree-sitter-c, #1242/#858) is frozen below
# a runtime upgrade: in-range ABI or not, keep it a blocker until the hold
# (from the manifest) is lifted — same treatment as npm INTENTIONAL_PINS.
if hold:
v["target_compat"] = False
status = "Vendored — held"
# Compose with any out-of-range reason rather than overwriting it:
# both share the blockers[name] key, and the ABI-out-of-range
# detail would otherwise be lost from the blockers summary.
hold_reason = f"vendored `{name}` held: {hold}"
prior = blockers.get(name)
blockers[name] = f"{prior}; {hold_reason}" if prior else hold_reason
# Cell sentinels: never emit a bare "?". A vendored grammar's ABI is
# the real LANGUAGE_VERSION when introspectable, else a labeled token.
vendored_abi_cell = (
str(v["vendored_abi"]) if v["vendored_abi"] is not None else "prebuilt"
)
# A None upstream ABI means the upstream parser.c couldn't be read —
# either it is generated at build time (e.g. swift) or the fetch
# missed. We can't tell which here, so use a neutral label rather
# than asserting "generated at build". Never a bare "?".
upstream_abi_cell = (
str(v["upstream_abi"]) if v["upstream_abi"] is not None else "n/a"
)
# Keep vendored grammars in the raw matrix so the workflow's
# row-diff change-detection picks up status transitions on
# them too. npm-only columns get sentinels.
# row-diff change-detection picks up status transitions on them too.
# npm-only columns get sentinels.
raw_matrix.append(
f"| `{name}` | {pinned_spec} | (vendored) | (vendored) | "
f"{'Yes' if v['target_compat'] else '**No**'} | "
f"{v['vendored_abi'] or '?'} | {v['upstream_abi'] or '?'} | {status} |"
f"{vendored_abi_cell} | {upstream_abi_cell} | {status} |"
)
vendored_grammars.append(v)
continue
@ -593,7 +735,7 @@ def main() -> int:
peer_optional = ts_meta.get("optional", False) if peer_range else True
if fetch_failed:
peer_display = "? (fetch failed)"
peer_display = f"n/a ({miss_label})"
target_compat = False
current_compat = False
else:
@ -609,7 +751,9 @@ def main() -> int:
# Fallback to default location.
installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c"
installed_abi = extract_language_version(installed_parser)
abi_display = str(installed_abi) if installed_abi else "?"
# Labeled sentinel, never a bare "?": CI's `npm ci` populates node_modules,
# but if it's absent say so plainly rather than leaving a placeholder (#858).
abi_display = str(installed_abi) if installed_abi else "n/a (not installed)"
# Check upstream (main/master branch) ABI for unreleased work.
upstream_url = (
@ -618,23 +762,19 @@ def main() -> int:
)
upstream_text = fetch_text(upstream_url)
upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None
upstream_abi_display = str(upstream_abi) if upstream_abi else "?"
upstream_abi_display = str(upstream_abi) if upstream_abi else "n/a"
# Status text + upstream-progress detection. The Status column
# values are preserved as-is to keep the workflow's row-diff
# change-detection working on the raw matrix below.
upstream_progress: str | None = None
if fetch_failed:
status = "Unknown (fetch failed)"
blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep"
status = f"Unknown ({miss_label})"
reason = "checks skipped (offline)" if OFFLINE else "npm registry fetch failed"
blockers[name] = f"`{name}`: {reason} — could not verify peer dep"
elif name in INTENTIONAL_PINS:
# An intentional pin is, by definition, a held-back grammar:
# whatever npm-latest's peer dep says, our shipped version is
# the one whose ABI/peer must accept the target runtime, and
# the pin entry exists precisely because it does not. Treat
# it as a blocker until the pin is lifted (entry removed from
# INTENTIONAL_PINS), at which point this grammar falls back
# to standard classification on the next run.
# A held-back grammar: treated as a blocker until the pin is lifted
# (entry removed from INTENTIONAL_PINS), then reclassified next run.
status = "Intentionally pinned"
blockers[name] = (
f"`{name}` intentionally pinned at `{pinned_spec}` "
@ -675,8 +815,11 @@ def main() -> int:
pinned_spec = pinned_versions.get(name, "—")
compat_icon = "Yes" if target_compat else "**No**"
# "?" stays the internal fetch-failed sentinel (compared above); render a
# labeled token in the matrix so the report never shows a bare "?" (#858).
npm_version_cell = f"n/a ({miss_label})" if npm_version == "?" else npm_version
raw_matrix.append(
f"| `{name}` | {pinned_spec} | {npm_version} | {peer_display} | "
f"| `{name}` | {pinned_spec} | {npm_version_cell} | {peer_display} | "
f"{compat_icon} | {abi_display} | {upstream_abi_display} | {status} |"
)
@ -726,7 +869,8 @@ def main() -> int:
lines.append(f"- {len(by_bucket['waiting'])} waiting on an upstream npm release")
lines.append(f"- {len(by_bucket['blocked'])} blocked on upstream (no fix even on main)")
if by_bucket['fetch_failed']:
lines.append(f"- {len(by_bucket['fetch_failed'])} could not be checked (npm registry unreachable)")
why = "checks skipped in offline mode" if OFFLINE else "npm registry unreachable"
lines.append(f"- {len(by_bucket['fetch_failed'])} could not be checked ({why})")
if bump_now:
lines.append(
f"- **{len(bump_now)} bump candidate(s) you can take TODAY** (npm-latest "
@ -745,7 +889,7 @@ def main() -> int:
lines.append("")
for r in sorted(bump_now, key=lambda r: r["name"]):
lines.append(
f"- `{r['name']}`: `{r['pinned_spec']}` → `{r['npm_version']}` "
f"- `{r['name']}`: `{r['pinned_spec']}` → `{r['npm_version_label']}` "
f"(peer `{r['peer_range'] or 'none'}`)"
)
lines.append("")
@ -768,7 +912,7 @@ def main() -> int:
"These grammars' npm-latest peer dep already accepts the target runtime. No action needed for the upgrade.",
by_bucket["ready"],
lambda r: (
f"- `{r['name']}` — pinned `{r['pinned_spec']}`, npm latest `{r['npm_version']}`"
f"- `{r['name']}` — pinned `{r['pinned_spec']}`, npm latest `{r['npm_version_label']}`"
+ (" _(also a bump candidate — see above)_" if r["bump_now"] else "")
),
)
@ -784,7 +928,7 @@ def main() -> int:
reason = INTENTIONAL_PINS.get(r["name"], "(no rationale recorded)")
lines.append(
f"- `{r['name']}` pinned at `{r['pinned_spec']}` "
f"(npm latest `{r['npm_version']}`)\n {reason}"
f"(npm latest `{r['npm_version_label']}`)\n {reason}"
)
lines.append("")
@ -794,7 +938,7 @@ def main() -> int:
"We can move forward as soon as upstream cuts a release.",
by_bucket["waiting"],
lambda r: (
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`. "
f"- `{r['name']}@{r['npm_version_label']}` — peer `{r['peer_range'] or 'none'}`. "
f"_{r['upstream_progress']}_"
),
)
@ -804,90 +948,23 @@ def main() -> int:
"Peer dep is too tight on both the latest npm release and on upstream main. "
"These need an upstream issue/PR before we can proceed.",
by_bucket["blocked"],
lambda r: (
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`"
+ (" _(vendored)_" if r["is_vendored"] else "")
),
lambda r: f"- `{r['name']}@{r['npm_version_label']}` — peer `{r['peer_range'] or 'none'}`",
)
_emit_bucket(
"Could not check",
"npm registry fetch failed for these grammars. Re-run the workflow to retry.",
(
"Checks were skipped because the report ran in `--offline` mode. "
"Re-run online to verify these grammars."
if OFFLINE
else "npm registry fetch failed for these grammars. Re-run the workflow to retry."
),
by_bucket["fetch_failed"],
lambda r: f"- `{r['name']}` (pinned `{r['pinned_spec']}`)",
)
# ── Vendored parsers ────────────────────────────────────────────
if vendored_grammars:
lines.append(md_h(f"Vendored parsers ({len(vendored_grammars)})", 2))
lines.append(
"These grammars ship from `gitnexus/vendor/` rather than the npm "
"registry. Their compatibility is governed by the **vendored "
"ABI** (must lie in the target runtime's range), not by a peer-"
"dep negotiation. The rationale for each vendored copy lives in "
"its own `package.json` `_vendoredBy` field."
)
lines.append("")
for v in sorted(vendored_grammars, key=lambda v: v["name"]):
sync_label = (
"in sync with upstream" if v["in_sync"] else "diverged from upstream"
)
if v["abi_state"] == "in_range":
abi_label = f"ABI `{v['vendored_abi']}` (in target range)"
elif v["abi_state"] == "prebuilt":
abi_label = "ABI `prebuilt` (binary-only vendor, source not introspectable)"
else:
abi_label = (
f"ABI `{v['vendored_abi']}` (**outside** target range "
f"{target_abi_range[0]}..{target_abi_range[1]})"
)
upstream_abi_str = (
f"ABI `{v['upstream_abi']}`" if v["upstream_abi"] else "ABI `?`"
)
lines.append(
f"- **`{v['name']}`** `{v['vendored_version']}` — {abi_label}, "
f"upstream `{v['upstream_repo']}@{v['upstream_sha']}` "
f"{upstream_abi_str} · {sync_label}"
)
if v["vendored_by"]:
# Show the first sentence — vendor package.json fields tend
# to start with the rationale and tail off into install-
# script breadcrumbs that aren't useful in this report.
rationale = _first_sentence(v["vendored_by"])
lines.append(f" - **Why vendored:** {rationale}")
# Action computation: needs regen iff upstream ABI exceeds
# vendored AND is still within target range. If upstream ABI
# exceeds the target, that's a runtime-side blocker. For
# prebuilt-only vendors we can't drive this from source ABI;
# the action is a manual upstream-binary refresh, surfaced
# via the in-sync flag instead.
if v["abi_state"] == "prebuilt":
if not v["in_sync"]:
lines.append(
" - **Action:** check whether upstream has shipped a new "
"prebuilt release; this vendor ships binary-only artefacts."
)
elif v["upstream_abi"] and v["vendored_abi"] and v["upstream_abi"] > v["vendored_abi"]:
if v["upstream_abi"] <= target_abi_range[1]:
lines.append(
f" - **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, "
f"regenerate `parser.c` from upstream `{v['upstream_sha']}`."
)
else:
lines.append(
f" - **Action:** wait for a runtime supporting ABI "
f"{v['upstream_abi']}; current target ({TARGET_RUNTIME}) only "
f"goes up to ABI {target_abi_range[1]}."
)
blockers[f"vendored-{v['name']}-abi"] = (
f"vendored {v['name']}: upstream ABI {v['upstream_abi']} outside target range"
)
elif not v["in_sync"]:
lines.append(
" - **Action:** review upstream changes; vendored copy may "
"need a refresh (no ABI bump required)."
)
lines.append("")
lines.extend(_render_vendored_section(vendored_grammars, target_abi_range, blockers))
# ── Raw matrix (for completeness + workflow row-diff) ────────────
lines.append(md_h("Full grammar matrix", 2))
@ -911,6 +988,11 @@ if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
except Exception:
pass
# `--offline` skips all network so the readiness report renders hermetically
# (vendored ABIs from the repo; npm columns marked unverified). Useful for
# air-gapped runs and deterministic tests.
if "--offline" in sys.argv[1:]:
OFFLINE = True
# `--assert-current` is the offline CI gate (#1922): assert every grammar's
# ABI loads on the CURRENT runtime. Bare invocation keeps the original
# target-runtime readiness report behaviour.

View file

@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""Tests for check-tree-sitter-upgrade-readiness.py.
Stdlib-only (``unittest`` + ``unittest.mock``) to match the script under test,
which is deliberately dependency-free so it runs on any vanilla runner. Run with:
python3 -m unittest .github/scripts/test_check_tree_sitter_upgrade_readiness.py
(pytest also discovers ``unittest.TestCase`` classes, so a future pytest CI job
picks these up unchanged.)
These tests lock in the #858 fix: the 5 vendored grammars
(c/swift/kotlin/dart/proto) are classified from the shared manifest
(.github/vendored-grammars.json), their ABI is read from gitnexus/vendor/<name>,
and the report never renders a bare ``?`` placeholder. All network is mocked.
"""
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import pathlib
import re
from unittest import TestCase, main, mock
# ── Load the hyphenated script as a module ───────────────────────────────
_SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent
_SCRIPT = _SCRIPTS_DIR / "check-tree-sitter-upgrade-readiness.py"
_REPO_ROOT = _SCRIPTS_DIR.parents[1]
_MANIFEST = _REPO_ROOT / ".github" / "vendored-grammars.json"
_spec = importlib.util.spec_from_file_location("readiness_under_test", _SCRIPT)
readiness = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(readiness) # type: ignore[union-attr]
# The exact row-diff regex the workflow's change-detection bot uses
# (.github/workflows/tree-sitter-upgrade-readiness.yml) — byte-identical so a matrix
# format change that would silently break change-detection fails here. Group 2 is
# ONLY the Status cell ([^|]+? before the final `|$`).
_ROW_DIFF_RE = re.compile(r"\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$", re.M)
def _physical_vendor_grammars() -> set[str]:
vendor = _REPO_ROOT / "gitnexus" / "vendor"
return {
p.name
for p in vendor.iterdir()
if p.is_dir() and p.name.startswith("tree-sitter-")
}
def _render_report() -> tuple[str, int]:
"""Run main() with network mocked to mirror PRODUCTION; return (md, exit_code).
- npm grammars resolve to a permissive "Ready" peer dep, so the ONLY blocker
left is the held vendored tree-sitter-c — letting us assert the hold is
load-bearing (exit code stays non-zero because of it).
- npm_view_json records its calls so we can prove vendored grammars are never
npm-queried.
- fetch_text mirrors the real workflow: upstream parser.c resolves to a real
ABI (committed upstream), commit endpoints return a sha — EXCEPT swift's
upstream, whose parser.c is generated at build time and so is unreachable
(None). That single miss exercises the labeled-sentinel path; every other
cell must be a real value, never a bare '?'.
"""
npm_calls: list[str] = []
def fake_npm_view_json(pkg: str):
npm_calls.append(pkg)
return {"version": "9.9.9", "peerDependencies": {"tree-sitter": "^0.25.0"}}
def fake_fetch_text(url: str, timeout: int = 8):
if "parser.c" in url:
# swift's upstream parser.c is generated at build time → unreachable;
# the others ship a committed parser.c.
if "alex-pinkus" in url:
return None
return "#define LANGUAGE_VERSION 14\n#define STATE_COUNT 1\n"
if "/commits/" in url:
return json.dumps({"sha": "0123456789abcdef"})
# package.json (relaxed-peer probe) etc. — not needed for these assertions.
return None
buf = io.StringIO()
with mock.patch.object(readiness, "npm_view_json", side_effect=fake_npm_view_json), \
mock.patch.object(readiness, "fetch_text", side_effect=fake_fetch_text), \
contextlib.redirect_stdout(buf):
code = readiness.main()
report = buf.getvalue()
_render_report.last_npm_calls = npm_calls # type: ignore[attr-defined]
return report, code
class ManifestClassification(TestCase):
def test_manifest_matches_physical_vendor_dirs(self):
"""Consistency guard: the manifest set == the gitnexus/vendor/tree-sitter-*
dirs. Vendoring a grammar without a manifest entry (or vice-versa) fails —
this is what keeps the two tree-sitter workflows aligned (#858)."""
manifest_names = {
g["name"]
for g in json.loads(_MANIFEST.read_text())["grammars"].values()
}
self.assertEqual(manifest_names, _physical_vendor_grammars())
def test_vendored_names_loaded_from_manifest(self):
self.assertEqual(set(readiness.VENDORED_NAMES), _physical_vendor_grammars())
# npm-installed grammars must NOT be classified vendored.
self.assertNotIn("tree-sitter-cpp", readiness.VENDORED_NAMES)
self.assertNotIn("tree-sitter-go", readiness.VENDORED_NAMES)
def test_c_carries_a_hold_cpp_does_not(self):
self.assertTrue(readiness.VENDORED["tree-sitter-c"]["hold"])
self.assertNotIn("tree-sitter-c", readiness.INTENTIONAL_PINS)
# cpp stays an npm intentional pin.
self.assertIn("tree-sitter-cpp", readiness.INTENTIONAL_PINS)
def test_vendored_names_are_a_subset_of_GRAMMARS(self):
# The report + --assert-current iterate the hardcoded GRAMMARS dict for
# upstream-drift coords. A vendored grammar present in the manifest but
# missing from GRAMMARS would be silently dropped from both — re-creating
# the cross-workflow divergence the manifest exists to kill (#858). Guard it.
missing = set(readiness.VENDORED_NAMES) - set(readiness.GRAMMARS)
self.assertEqual(missing, set(), f"manifest grammars missing from GRAMMARS: {missing}")
def test_missing_manifest_raises_a_clear_error(self):
import pathlib
import tempfile
with tempfile.TemporaryDirectory() as d:
with mock.patch.object(readiness, "REPO_ROOT", pathlib.Path(d)):
with self.assertRaises(SystemExit) as ctx:
readiness.load_vendored_manifest()
self.assertIn("vendored-grammars manifest", str(ctx.exception))
def test_malformed_manifest_raises_a_clear_error(self):
import pathlib
import tempfile
with tempfile.TemporaryDirectory() as d:
gh = pathlib.Path(d) / ".github"
gh.mkdir()
(gh / "vendored-grammars.json").write_text("{ not valid json", encoding="utf-8")
with mock.patch.object(readiness, "REPO_ROOT", pathlib.Path(d)):
with self.assertRaises(SystemExit) as ctx:
readiness.load_vendored_manifest()
self.assertIn("not valid JSON", str(ctx.exception))
def test_path_traversal_grammar_name_is_rejected(self):
import pathlib
import tempfile
bad = '{"grammars": {"evil": {"name": "../etc"}}}'
with tempfile.TemporaryDirectory() as d:
gh = pathlib.Path(d) / ".github"
gh.mkdir()
(gh / "vendored-grammars.json").write_text(bad, encoding="utf-8")
with mock.patch.object(readiness, "REPO_ROOT", pathlib.Path(d)):
with self.assertRaises(SystemExit) as ctx:
readiness.load_vendored_manifest()
self.assertIn("invalid grammar name", str(ctx.exception))
class AssertCurrent(TestCase):
"""The offline #1922 ABI gate (--assert-current) must stay hermetic — it reads
vendored ABIs from the repo, never the network. (Regression guard: a prior
revision routed vendored grammars through vendored_drift_summary, which fetches
upstream parser.c + commit sha, silently breaking the 'hermetic and offline'
contract — #858 review.)"""
def _run_assert_current(self):
import urllib.request
def explode(*a, **k):
raise AssertionError("--assert-current attempted a network call")
buf = io.StringIO()
with mock.patch.object(urllib.request, "urlopen", side_effect=explode), \
contextlib.redirect_stdout(buf):
code = readiness.assert_current()
return buf.getvalue(), code
def test_assert_current_is_network_free_and_passes(self):
report, code = self._run_assert_current() # raises if any urlopen fires
self.assertEqual(code, 0)
# All 5 vendored grammars are introspected from the repo (ABI 14), not skipped.
for name in readiness.VENDORED_NAMES:
self.assertIn(f"{name}: vendored ABI", report)
def test_assert_current_fails_an_out_of_range_vendored_abi(self):
# vendored_abi_from_repo is the local-read injection point: force one
# grammar out of the current runtime's ABI window and assert the gate trips.
real = readiness.vendored_abi_from_repo
def fake(name, parser_path):
return 99 if name == "tree-sitter-dart" else real(name, parser_path)
import urllib.request
buf = io.StringIO()
with mock.patch.object(readiness, "vendored_abi_from_repo", side_effect=fake), \
mock.patch.object(urllib.request, "urlopen", side_effect=AssertionError("network")), \
contextlib.redirect_stdout(buf):
code = readiness.assert_current()
self.assertEqual(code, 1)
self.assertIn("tree-sitter-dart", buf.getvalue())
self.assertIn("outside current runtime range", buf.getvalue())
class ReportRendering(TestCase):
@classmethod
def setUpClass(cls):
cls.report, cls.code = _render_report()
cls.rows = dict(_ROW_DIFF_RE.findall(cls.report))
def test_no_bare_question_mark_anywhere(self):
# The only legitimate '?' is the "Satisfies 0.25?" column header.
sanitized = self.report.replace("Satisfies 0.25?", "Satisfies 0.25")
self.assertNotIn("?", sanitized, "report still contains a bare '?' placeholder")
def test_malformed_npm_version_renders_unknown_in_prose_not_bare_question(self):
# A successful (200) npm /latest response that omits `version` leaves
# npm_version == "?"; the grammar is still bucketed (fetch did not fail), so
# its disposition PROSE line must show the labeled sentinel, never a bare '?'.
def fake_npm(pkg: str):
if pkg == "tree-sitter-go":
return {"peerDependencies": {"tree-sitter": "^0.25.0"}} # no 'version'
return {"version": "9.9.9", "peerDependencies": {"tree-sitter": "^0.25.0"}}
def fake_fetch(url: str, timeout: int = 8):
if "parser.c" in url and "alex-pinkus" not in url:
return "#define LANGUAGE_VERSION 14\n"
if "/commits/" in url:
return json.dumps({"sha": "0123456789abcdef"})
return None
buf = io.StringIO()
with mock.patch.object(readiness, "npm_view_json", side_effect=fake_npm), \
mock.patch.object(readiness, "fetch_text", side_effect=fake_fetch), \
contextlib.redirect_stdout(buf):
readiness.main()
report = buf.getvalue()
sanitized = report.replace("Satisfies 0.25?", "Satisfies 0.25")
self.assertNotIn("?", sanitized)
# The Ready bucket prose line for go shows the labeled 'unknown', not '?'.
self.assertRegex(report, r"`tree-sitter-go`.*npm latest `unknown`")
def test_every_vendored_grammar_shows_numeric_abi_not_question_mark(self):
for name in readiness.VENDORED_NAMES:
row = self._matrix_row(name)
cells = [c.strip() for c in row.strip().strip("|").split("|")]
abi_cell = cells[5] # Grammar|Pinned|npm|Peer|Satisfies|ABI|UpstreamABI|Status
self.assertRegex(
abi_cell, r"^\d+$",
f"{name} ABI cell is '{abi_cell}', expected a number (read from vendor/)",
)
def test_proto_is_never_npm_queried(self):
# github-only vendored grammars must skip the npm peer-dep path entirely,
# which is what removes the old "? (fetch failed)" for tree-sitter-proto.
self.assertNotIn("tree-sitter-proto", _render_report.last_npm_calls)
self.assertNotIn("tree-sitter-dart", _render_report.last_npm_calls)
self.assertNotIn("Could not check", self.report)
self.assertNotIn("fetch failed", self.report)
def test_held_c_renders_held_and_keeps_exit_nonzero(self):
# Status is the last matrix cell (the row-diff regex captures the whole
# tail, not just status, so read the cell directly).
cells = [c.strip() for c in self._matrix_row("tree-sitter-c").strip().strip("|").split("|")]
self.assertEqual(cells[-1], "Vendored — held")
self.assertIn("**Held:**", self.report)
# With every npm grammar mocked to "Ready", the ONLY remaining blocker is
# the held c — so a non-zero exit proves the hold is treated as a blocker.
self.assertEqual(self.code, 1)
def test_upstream_abi_miss_uses_labeled_sentinel(self):
# swift's upstream parser.c is unreachable (mocked None), so its
# upstream-ABI cell is the labeled 'n/a' token, never a bare '?'.
cells = [c.strip() for c in self._matrix_row("tree-sitter-swift").strip().strip("|").split("|")]
self.assertEqual(cells[6], "n/a") # Upstream ABI column
def test_row_diff_regex_captures_all_fifteen_grammar_statuses(self):
# The change-detection bot keys on this regex: group 1 = grammar name,
# group 2 = the Status cell ONLY (not the whole tail). It must match every
# row after the format change so status transitions keep being detected.
self.assertEqual(len(self.rows), 15)
for name in readiness.VENDORED_NAMES:
self.assertIn(name, self.rows)
# group 2 is the Status cell — held c renders exactly "Vendored — held",
# and no captured status contains a pipe (proves cell-scoped capture).
self.assertEqual(self.rows["tree-sitter-c"], "Vendored — held")
for status in self.rows.values():
self.assertNotIn("|", status)
def _matrix_row(self, name: str) -> str:
for line in self.report.splitlines():
if line.startswith(f"| `{name}` |"):
return line
# Explicit terminating raise (not self.fail, which CodeQL doesn't model as
# NoReturn) so the function has no implicit fall-through return (CodeQL 754).
raise AssertionError(f"no matrix row for {name}")
class OfflineMode(TestCase):
"""--offline must render the report touching ZERO network — vendored ABIs come
from the repo, npm columns are marked unverified. This is what makes the
network-dependent report deterministically testable in air-gapped CI."""
def _render_offline(self):
import urllib.request
def explode(*a, **k):
raise AssertionError("network call attempted in --offline mode")
buf = io.StringIO()
with mock.patch.object(readiness, "OFFLINE", True), \
mock.patch.object(urllib.request, "urlopen", side_effect=explode), \
contextlib.redirect_stdout(buf):
code = readiness.main()
return buf.getvalue(), code
def test_offline_touches_no_network_and_still_renders(self):
report, code = self._render_offline() # raises if any urlopen fires
self.assertIn("Offline mode", report)
# Vendored grammars are introspected from the repo → real ABI 14, not a miss.
for name in readiness.VENDORED_NAMES:
row = next(l for l in report.splitlines() if l.startswith(f"| `{name}` |"))
cells = [c.strip() for c in row.strip().strip("|").split("|")]
self.assertRegex(cells[5], r"^\d+$", f"{name} vendored ABI missing offline")
def test_offline_marks_npm_grammars_offline_not_fetch_failed(self):
report, _ = self._render_offline()
self.assertIn("(offline)", report)
self.assertNotIn("fetch failed", report) # honest: skipped, not failed
def test_offline_report_has_no_bare_question_mark(self):
report, _ = self._render_offline()
sanitized = report.replace("Satisfies 0.25?", "Satisfies 0.25")
self.assertNotIn("?", sanitized)
class VendoredAbiBranches(TestCase):
"""main()'s vendored-ABI classification reads through vendored_abi_from_repo
(the same local-read seam --assert-current uses), so a single patch drives the
out-of-range and prebuilt-only branches that no real vendor dir can trigger
today (all ship parser.c at ABI 14)."""
def _render_with_vendored_abi(self, override):
"""Render main() with the standard production-faithful network mock plus a
vendored_abi_from_repo override (dict: name -> int|None; others read real)."""
real = readiness.vendored_abi_from_repo
def abi_seam(name, parser_path):
return override[name] if name in override else real(name, parser_path)
def fake_npm(pkg):
return {"version": "9.9.9", "peerDependencies": {"tree-sitter": "^0.25.0"}}
def fake_fetch(url, timeout=8):
if "parser.c" in url and "alex-pinkus" not in url:
return "#define LANGUAGE_VERSION 14\n"
if "/commits/" in url:
return json.dumps({"sha": "0123456789abcdef"})
return None
buf = io.StringIO()
with mock.patch.object(readiness, "vendored_abi_from_repo", side_effect=abi_seam), \
mock.patch.object(readiness, "npm_view_json", side_effect=fake_npm), \
mock.patch.object(readiness, "fetch_text", side_effect=fake_fetch), \
contextlib.redirect_stdout(buf):
code = readiness.main()
return buf.getvalue(), code
def _row(self, report, name):
line = next(l for l in report.splitlines() if l.startswith(f"| `{name}` |"))
return [c.strip() for c in line.strip().strip("|").split("|")]
def test_out_of_range_vendored_abi_is_a_blocker(self):
# Force tree-sitter-dart's vendored ABI outside the target range (13–15).
report, code = self._render_with_vendored_abi({"tree-sitter-dart": 99})
cells = self._row(report, "tree-sitter-dart")
self.assertEqual(cells[-1], "Vendored (ABI out of range)")
self.assertEqual(cells[5], "99")
self.assertEqual(code, 1) # out-of-range vendored grammar is a blocker
def test_prebuilt_only_vendored_abi_renders_prebuilt_not_question(self):
# vendored_abi None (a future binary-only vendor with no parser.c).
report, _ = self._render_with_vendored_abi({"tree-sitter-dart": None})
cells = self._row(report, "tree-sitter-dart")
self.assertEqual(cells[5], "prebuilt") # labeled, never a bare '?'
self.assertEqual(cells[4], "Yes") # prebuilt is assumed target-compatible
if __name__ == "__main__":
main()

View file

@ -42,17 +42,52 @@ const COMPATIBLE_ABI = new Set([13, 14]); // tree-sitter@0.21.1 LANGUAGE_VERSION
// github grammars (no usable npm release) track the default branch HEAD. A `hold`
// reason makes a grammar report-only: updates are detected + surfaced but never
// auto-applied (c is ABI-pinned and must not move without a runtime upgrade).
const GRAMMARS = {
c: {
name: 'tree-sitter-c',
npm: 'tree-sitter-c',
hold: 'ABI-pinned at 0.21.4 (#1242/#858) — needs a tree-sitter runtime upgrade before bumping',
},
swift: { name: 'tree-sitter-swift', npm: 'tree-sitter-swift' },
kotlin: { name: 'tree-sitter-kotlin', npm: 'tree-sitter-kotlin' },
dart: { name: 'tree-sitter-dart', github: 'UserNobody14/tree-sitter-dart' },
proto: { name: 'tree-sitter-proto', github: 'coder3101/tree-sitter-proto' },
};
//
// The vendored set lives in .github/vendored-grammars.json — the SHARED source of
// truth this monitor and .github/scripts/check-tree-sitter-upgrade-readiness.py both
// read, so the two tree-sitter workflows can never disagree about which grammars are
// vendored or where their upstream lives. We reshape the manifest's
// `{ upstream: { npm | github } }` form into the flat `{ npm? , github? }` shape the
// rest of this script consumes. This is a local file read (import-safe, no network).
const MANIFEST = path.join(REPO_ROOT, '.github', 'vendored-grammars.json');
// `raw` is injectable for testing; production reads the manifest file.
function loadManifestGrammars(raw = null) {
if (raw === null) {
// Fail loud with a pointer, not a bare ENOENT/SyntaxError: this runs at import.
try {
raw = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
} catch (e) {
throw new Error(
`Could not load the vendored-grammars manifest at ${MANIFEST} ` +
`(shared source of truth — see CONTRIBUTING.md → CI automation contracts): ${e.message}`,
);
}
}
return Object.fromEntries(
Object.entries(raw.grammars || {}).map(([key, g]) => {
if (!g.name)
throw new Error(`manifest entry '${key}' is missing a 'name' field (${MANIFEST})`);
// Defense-in-depth: `name` is joined into gitnexus/vendor/<name> paths (and
// apply() WRITES there), so reject anything that isn't a plain grammar name
// before it can traverse the filesystem (#2187).
if (!/^tree-sitter-[a-z0-9-]+$/.test(g.name))
throw new Error(
`manifest entry '${key}' has an invalid grammar name '${g.name}' ` +
`(must match tree-sitter-[a-z0-9-]+)`,
);
return [
key,
{
name: g.name,
...(g.upstream?.npm ? { npm: g.upstream.npm } : {}),
...(g.upstream?.github ? { github: g.upstream.github } : {}),
...(g.hold ? { hold: g.hold } : {}),
},
];
}),
);
}
const GRAMMARS = loadManifestGrammars();
const sh = (cmd, args, opts = {}) =>
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
@ -62,6 +97,26 @@ const clean = (v) =>
.replace(/^[v^~]/, '')
.trim();
// Shared "is the candidate newer than what we ship?" check, used by BOTH detect()
// and apply() so they can never disagree. up.version is the comparable identity for
// both kinds: a plain semver for npm, and the `<base>-g<sha7>` provenance string for
// github (which apply() also writes to package.json). detect() previously compared
// the bare sha7 for github, so after the bot re-vendored a github grammar once it
// reported a perpetual false "update available" while apply() saw "already current"
// (#2187 review). Comparing up.version on both sides removes that asymmetry.
const isNewer = (up, have) => !have || up.version !== have;
// apply() throws this (instead of calling process.exit) so its error branches are
// exercisable in-process by tests; the CLI entrypoint maps `.code` back to the
// original exit code, keeping the monitor's subprocess contract identical (#2187).
class ApplyExit extends Error {
constructor(message, code) {
super(message);
this.name = 'ApplyExit';
this.code = code;
}
}
function vendoredVersion(g) {
const p = path.join(VENDOR, g.name, 'package.json');
return clean(JSON.parse(fs.readFileSync(p, 'utf8')).version);
@ -136,22 +191,30 @@ function readAbi(srcRoot) {
return null; // unknown (e.g. parser.c only generated at build time)
}
function detect() {
// `deps` injects the network/filesystem seams (vendoredVersion / resolveUpstream /
// fetchSource / readAbi) so the classification logic — newer-detection, the ABI
// gate, and the policy-hold gate — can be unit-tested offline with fixtures, never
// touching live npm/GitHub. Production passes nothing and gets the real functions.
function detect(deps = {}) {
const getVendored = deps.vendoredVersion || vendoredVersion;
const resolveUp = deps.resolveUpstream || resolveUpstream;
const fetchSrc = deps.fetchSource || fetchSource;
const readAbiFn = deps.readAbi || readAbi;
const report = [];
for (const [key, g] of Object.entries(GRAMMARS)) {
const have = vendoredVersion(g);
const have = getVendored(g);
let up;
try {
up = resolveUpstream(g);
up = resolveUp(g);
} catch (err) {
report.push({ grammar: key, error: String(err.message || err) });
continue;
}
const newer = up.kind === 'npm' ? up.version !== have : !have || up.ref.slice(0, 7) !== have;
const newer = isNewer(up, have);
let abi = null;
if (newer) {
try {
abi = readAbi(fetchSource(g, up.ref));
abi = readAbiFn(fetchSrc(g, up.ref));
} catch {
/* fetch/abi best-effort; null = unknown */
}
@ -190,34 +253,48 @@ const copyFile = (srcRoot, dest, rel) => {
* notice), LICENSE, and prebuilds/ (the build workflow refreshes those). Bumps the
* stripped vendor package.json version + provenance — never re-introduces
* scripts/dependencies (#836/#1728). Returns the new version.
*
* opts.dryRun resolves + ABI-validates the candidate but writes NOTHING — it logs
* what it would re-vendor and returns the version, so the flow can be rehearsed
* (locally or in CI) without mutating gitnexus/vendor/. opts.deps injects the
* network/fs seams for offline testing (same shape as detect()).
*/
function apply(key) {
function apply(key, opts = {}) {
const dryRun = opts.dryRun || false;
const deps = opts.deps || {};
const getVendored = deps.vendoredVersion || vendoredVersion;
const resolveUp = deps.resolveUpstream || resolveUpstream;
const fetchSrc = deps.fetchSource || fetchSource;
const readAbiFn = deps.readAbi || readAbi;
const g = GRAMMARS[key];
if (!g) {
console.error(`unknown grammar '${key}'`);
process.exit(2);
}
if (g.hold) {
console.error(
if (!g) throw new ApplyExit(`unknown grammar '${key}'`, 2);
if (g.hold)
throw new ApplyExit(
`${key}: report-only (${g.hold}); not auto-applied. Re-vendor manually if intended.`,
3,
);
process.exit(3);
}
const have = vendoredVersion(g);
const up = resolveUpstream(g);
const newer = up.kind === 'npm' ? up.version !== have : !have || up.version !== have;
const have = getVendored(g);
const up = resolveUp(g);
const newer = isNewer(up, have);
if (!newer) {
// Already current: nothing to apply. Return (exit 0 via the CLI) — NOT an error.
console.error(`${key}: already current (${have}); nothing to apply.`);
process.exit(0);
return have;
}
const srcRoot = fetchSource(g, up.ref);
const abi = readAbi(srcRoot);
if (abi == null || !COMPATIBLE_ABI.has(abi)) {
console.error(
const srcRoot = fetchSrc(g, up.ref);
const abi = readAbiFn(srcRoot);
if (abi == null || !COMPATIBLE_ABI.has(abi))
throw new ApplyExit(
`${key}: candidate ${up.version} is ABI ${abi ?? 'unknown'} — not tree-sitter@0.21.1 ` +
`compatible (need 13/14); refusing to re-vendor. Handle manually.`,
3,
);
process.exit(3);
if (dryRun) {
console.log(
`${key}: [dry-run] would re-vendor ${g.name} → ${up.version} (ABI ${abi}); no files written.`,
);
return up.version;
}
const dest = path.join(VENDOR, g.name);
@ -256,11 +333,31 @@ function apply(key) {
// makes live network calls, so importing must be side-effect-free.
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
if (process.argv[2] === '--apply') {
apply(process.argv[3]);
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
if (args[0] === '--apply') {
// `--apply <grammar> [--dry-run]` — --dry-run previews without writing.
// Map apply()'s thrown ApplyExit back to the original exit codes (0/2/3) so
// the monitor workflow's subprocess (which only distinguishes zero vs non-zero)
// sees identical behavior.
try {
apply(args[1], { dryRun });
} catch (e) {
console.error(e.message);
process.exit(e instanceof ApplyExit ? e.code : 1);
}
} else {
process.stdout.write(JSON.stringify(detect(), null, 2) + '\n');
}
}
export { detect, apply, resolveUpstream, readAbi, vendoredVersion, GRAMMARS, COMPATIBLE_ABI };
export {
detect,
apply,
resolveUpstream,
readAbi,
vendoredVersion,
loadManifestGrammars,
GRAMMARS,
COMPATIBLE_ABI,
};

26
.github/vendored-grammars.json vendored Normal file
View file

@ -0,0 +1,26 @@
{
"_comment": "Single source of truth for the VENDORED SET + policy holds, read by BOTH .github/scripts/update-vendored-grammars.mjs (weekly auto-PR bot) and .github/scripts/check-tree-sitter-upgrade-readiness.py (daily readiness report -> issue #858). The monitor also resolves each grammar's upstream from the `upstream` field here; the readiness report reads vendored ABIs from gitnexus/vendor/<name>/src/parser.c and keeps its own upstream-drift coords. A consistency-guard test asserts this set equals the gitnexus/vendor/tree-sitter-* directories. See CONTRIBUTING.md.",
"grammars": {
"c": {
"name": "tree-sitter-c",
"upstream": { "npm": "tree-sitter-c" },
"hold": "ABI-pinned at 0.21.4 (#1242/#858) — needs a tree-sitter runtime upgrade before bumping"
},
"swift": {
"name": "tree-sitter-swift",
"upstream": { "npm": "tree-sitter-swift" }
},
"kotlin": {
"name": "tree-sitter-kotlin",
"upstream": { "npm": "tree-sitter-kotlin" }
},
"dart": {
"name": "tree-sitter-dart",
"upstream": { "github": "UserNobody14/tree-sitter-dart" }
},
"proto": {
"name": "tree-sitter-proto",
"upstream": { "github": "coder3101/tree-sitter-proto" }
}
}
}

View file

@ -14,6 +14,13 @@ name: Vendored grammar update monitor
# never auto-bumped — a maintainer re-vendors it deliberately after a runtime
# upgrade.
#
# The vendored set + per-grammar upstream coords + the tree-sitter-c hold live in
# .github/vendored-grammars.json — the SHARED source of truth this monitor and
# tree-sitter-upgrade-readiness.yml both read, so the two workflows can never
# disagree about which grammars are vendored (#858). This monitor additionally
# resolves each grammar's upstream from it; the readiness report reads vendored
# ABIs from gitnexus/vendor/ and keeps its own upstream-drift coords.
#
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
on:

View file

@ -1,12 +1,21 @@
name: Tree-sitter Upgrade Readiness
# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks:
# 1. Peer-dep compatibility — can each grammar install cleanly with
# tree-sitter@0.25.0 without --legacy-peer-deps?
# 2. Vendored proto drift — has coder3101/tree-sitter-proto moved
# ahead of our vendored snapshot?
# 1. Peer-dep compatibility — can each NPM-installed grammar install cleanly
# with tree-sitter@0.25.0 without --legacy-peer-deps?
# 2. Vendored grammars — each grammar in .github/vendored-grammars.json
# (c/swift/kotlin/dart/proto) is classified by its vendored ABI, read
# straight from gitnexus/vendor/<name>/src/parser.c (NOT node_modules,
# which is never populated for vendored grammars — that mismatch is why
# the report used to render bare "?" placeholders, #858).
# See .github/scripts/check-tree-sitter-upgrade-readiness.py for the logic.
#
# .github/vendored-grammars.json is the SHARED source of truth for the vendored
# SET + policy holds: this readiness report and grammar-update-monitor.yml both
# read it, so the two workflows can never disagree about which grammars are
# vendored. (The monitor also resolves upstreams from it; this report keeps its
# own upstream-drift coords and reads vendored ABIs from gitnexus/vendor/.)
#
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
on:
@ -18,6 +27,8 @@ on:
pull_request:
paths:
- '.github/scripts/check-tree-sitter-upgrade-readiness.py'
- '.github/scripts/test_check_tree_sitter_upgrade_readiness.py'
- '.github/vendored-grammars.json'
- '.github/workflows/tree-sitter-upgrade-readiness.yml'
concurrency:
@ -28,14 +39,18 @@ permissions:
contents: read
jobs:
readiness:
report:
name: Check upgrade readiness
runs-on: ubuntu-latest
timeout-minutes: 10
# Least privilege: rendering the report needs no write. The issue mutation
# lives in the schedule-only `upsert-issue` job below, so PR runs (incl. forks)
# never receive `issues: write` (#2187 review).
permissions:
contents: read
# Needed to open/update the tracking issue on scheduled runs.
issues: write
outputs:
report: ${{ steps.readiness.outputs.report }}
exit_code: ${{ steps.readiness.outputs.exit_code }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@ -43,6 +58,17 @@ jobs:
with:
build: 'false'
# Guard the readiness script's logic (vendored classification, no bare "?",
# the manifest⇄vendor-dir consistency guard). Stdlib-only, so no extra deps;
# node_modules is populated by setup-gitnexus above, which the npm-path ABI
# reads need. Runs only on validation events (PR / manual), not the daily
# scheduled report.
- name: Run readiness script unit tests
if: github.event_name != 'schedule'
shell: bash
working-directory: .github/scripts
run: python3 -m unittest test_check_tree_sitter_upgrade_readiness -v
- name: Run upgrade readiness check
id: readiness
shell: bash
@ -54,10 +80,15 @@ jobs:
code=$?
set -e
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
# Unguessable per-run heredoc delimiter: the report includes the manifest's
# `hold` field, which a fork PR can edit — a fixed delimiter (e.g. DRIFT_EOF)
# in a hold value could close the heredoc early and inject $GITHUB_OUTPUT keys.
# A random hex delimiter the report cannot contain neutralizes that.
DELIM="DRIFT_EOF_$(openssl rand -hex 16)"
{
echo 'report<<DRIFT_EOF'
echo "report<<${DELIM}"
cat drift-report.md
echo 'DRIFT_EOF'
echo "${DELIM}"
} >> "$GITHUB_OUTPUT"
echo "=== Report ==="
cat drift-report.md
@ -69,13 +100,22 @@ jobs:
run: |
echo "::warning::Tree-sitter 0.25 upgrade has blockers. See job output for the full readiness report."
- name: Upsert tracking issue on scheduled runs
if: >
github.event_name == 'schedule' &&
steps.readiness.outputs.exit_code != '0'
# Issue mutation is isolated here so `issues: write` is only ever granted on the
# scheduled run (never on PRs). Consumes the report + exit_code via job outputs.
upsert-issue:
name: Upsert tracking issue
needs: report
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
steps:
- name: Upsert tracking issue on blockers
if: needs.report.outputs.exit_code != '0'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
REPORT: ${{ steps.readiness.outputs.report }}
REPORT: ${{ needs.report.outputs.report }}
with:
script: |
const title = 'Tree-sitter 0.25 upgrade readiness';
@ -105,7 +145,11 @@ jobs:
// | `tree-sitter-foo` | ... | Blocking |
const parseRows = (md) => {
const map = {};
for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*?\| (\S+(?:\s\S+)*?) \|$/gm)) {
// Group 2 captures ONLY the Status cell ([^|]+? before the final
// `|$`), so change-detection fires on status transitions, not on
// unrelated cell drift (e.g. an upstream-ABI bump). Mirror this in
// _ROW_DIFF_RE in test_check_tree_sitter_upgrade_readiness.py.
for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$/gm)) {
map[m[1]] = m[2].trim();
}
return map;
@ -152,10 +196,8 @@ jobs:
core.info(`Opened issue #${created.number}`);
}
- name: Close tracking issue on clean scheduled runs
if: >
github.event_name == 'schedule' &&
steps.readiness.outputs.exit_code == '0'
- name: Close tracking issue on clean runs
if: needs.report.outputs.exit_code == '0'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |

View file

@ -83,7 +83,7 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
- **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 `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- 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

View file

@ -41,6 +41,8 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
| `route_map` | API route → handler → consumer mappings |
| `tool_map` | MCP/RPC tool definitions and handlers |
| `shape_check` | Response shape vs consumer property access mismatches |
| `explain` | Persisted taint findings (source→sink data flows) — needs `analyze --pdg` |
| `pdg_query` | Control/data dependence — CDG (`mode: controls`) / REACHING_DEF (`mode: flows`) — needs `analyze --pdg` |
| `group_list` | List repo groups or details for one group |
| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph |
@ -204,9 +206,17 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` 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)
### Optional CFG/PDG emission (`--pdg`, #2081–#2086)
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/`.
On a `--pdg` run the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript today) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits the program-dependence layers 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 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 each layer is bounded by a per-function edge cap that logs any dropped edges. All layers are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table, keyed by `type`; there is **no** `Function → BasicBlock` edge — the symbol↔block join is reconstructed from the BasicBlock id prefix + line span. The layers build on each other:
- **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind).
- **M2 — REACHING_DEF** (#2082): GEN/KILL def→use data dependence from a pure fixpoint solver; the variable name rides `reason`.
- **M3/M4 — TAINTED / SANITIZES / TAINT_PATH** (#2083–#2084): intra- and inter-procedural taint (source→sink) — the `explain` tool's data.
- **M5 — CDG** (#2085): Ferrante control dependence over a Cooper–Harvey–Kennedy post-dominator tree (the EXIT-rooted reverse CFG); branch sense (`'T'`/`'F'`) rides `reason`. A CFG whose EXIT is unreachable from some block is skipped for CDG (post-dominance would be unsound) while its CFG/REACHING_DEF layers are kept.
- **M6 — read surface** (#2086): the `pdg_query` MCP tool answers "what gates X?" (CDG, `mode: controls`) and "where does Y flow?" (REACHING_DEF, `mode: flows`); `explain` is the taint consumer. Both are always anchored + `LIMIT`-bounded (LadybugDB has no rel-property index) and share one `resolveBlockAnchor` helper. These PDG edge types are deliberately kept out of the default `VALID_RELATION_TYPES` / web schema.
See `core/ingestion/cfg/` (emit + the pure CFG / post-dominator / control-dependence / reaching-defs / taint passes) and `mcp/local/local-backend.ts` (`_pdgQueryImpl`, `_explainImpl`, the shared `resolveBlockAnchor`).
### `ScopeResolver` contract
@ -383,6 +393,8 @@ Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation
**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF.
**Optional `--pdg` additions** (off by default, opt-in via `gitnexus analyze --pdg`; see _Optional CFG/PDG emission_ above): a `BasicBlock` node table, plus the PDG relation types `CFG`, `REACHING_DEF`, `CDG`, `TAINTED`, `SANITIZES`, and `TAINT_PATH` on the same `CodeRelation` table. These are deliberately kept out of the default `VALID_RELATION_TYPES` / web graph schema — query them via `cypher`, `explain`, or `pdg_query`.
## Embeddings and search
**Embeddings** (`src/core/embeddings/`): Snowflake arctic-embed-xs (384D). Embeddable: File, Function, Class, Method, Interface. Incremental via SHA1 content hash. Separate `Embedding` table.

View file

@ -4,16 +4,6 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
### Fixed
- **Hook db-lock probe no longer strands unkillable `lsof`/`ps` orphans** — the probe's `lsof`/`ps` subprocesses are now wrapped in a self-tested coreutils `timeout`/`gtimeout` (`timeout -k 1 …`), so a hook SIGKILLed by the runner's 10s timeout can no longer leave `lsof` running forever (orphan lifetime bounded at ~3s); `acquireHookSlot` now also gates the probe itself, capping concurrent probes at 3 per repo. Opt out with `GITNEXUS_HOOK_TIMEOUT_PATH=disabled`. (#2163)
### Changed
- Migrated from KuzuDB to LadybugDB v0.15 (`@ladybugdb/core`, `@ladybugdb/wasm-core`)
- Renamed all internal paths from `kuzu` to `lbug` (storage: `.gitnexus/kuzu` → `.gitnexus/lbug`)
- Added automatic cleanup of stale KuzuDB index files
- LadybugDB v0.15 requires explicit VECTOR extension loading for semantic search
## [1.5.3] - 2026-04-01
### Added

View file

@ -65,7 +65,7 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
- **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 `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- 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

View file

@ -144,6 +144,15 @@ Re-invoking `/autofix` after a successful apply is a safe no-op — the workflow
**Sensitive paths.** The apply workflow refuses any patch that touches `.github/` (workflow files, CODEOWNERS, dependabot config). A malicious PR could ship a custom prettier or ESLint config that reformats workflow YAML; if accepted, those edits would be pushed under `contents: write` without human review. Apply formatter changes to files under `.github/` manually in a normal commit so they get the same review every other workflow change gets.
### Vendored tree-sitter grammars
`.github/vendored-grammars.json` is the **single source of truth** for the vendored tree-sitter grammar **set** and each grammar's policy `hold` (the ones shipped from `gitnexus/vendor/<name>` rather than installed from npm). It lists each grammar's name, upstream coords (`npm` or `github`), and any `hold`. The monitor resolves upstreams from it; the readiness report keeps its own upstream-drift coords and reads vendored ABIs from `gitnexus/vendor/`. Two workflows read it:
- `grammar-update-monitor.yml` (`.github/scripts/update-vendored-grammars.mjs`) — weekly; opens auto-PRs re-vendoring ABI-compatible upstream updates.
- `tree-sitter-upgrade-readiness.yml` (`.github/scripts/check-tree-sitter-upgrade-readiness.py`) — daily; renders the tree-sitter-0.25 readiness report (issue #858), reading each vendored grammar's ABI from `gitnexus/vendor/<name>/src/parser.c`.
Sharing the manifest keeps the two aligned: a consistency-guard test asserts the manifest set equals the `gitnexus/vendor/tree-sitter-*` directories. **When you vendor a new grammar (or remove one), update `.github/vendored-grammars.json` in the same change** — otherwise that guard fails CI and the readiness report regresses to `?` placeholders.
## AI-assisted contributions
If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes.

View file

@ -123,7 +123,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
### MCP Setup
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`.
### Editor Support
@ -224,7 +224,7 @@ args = ["-y", "gitnexus@latest", "mcp"]
### CLI Commands
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus setup # Configure MCP for detected editors (one-time; use -c to select)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
@ -360,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:
@ -738,7 +738,7 @@ gitnexus impact get_embeddings --uid "Function:src/embed.py:get_embeddings" # e
### Process-Grouped Search
```
query({query: "authentication middleware"})
query({search_query: "authentication middleware"})
processes:
- summary: "LoginFlow"

View file

@ -15,7 +15,10 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.js');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
const {
hasGitNexusDbLockedByGitNexusServer,
resolveUnixGuardTimeout,
} = require('./hook-db-lock-probe.cjs');
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
/**
@ -196,18 +199,90 @@ function extractPattern(toolName, toolInput) {
return null;
}
// Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
// at most one line per (short-lived) hook process, even if a future change
// runs the CLI more than once.
let unguardedCliWarned = false;
/**
* Spawn a gitnexus CLI command synchronously.
* Detects binary on PATH once, then runs exactly once.
*
* SECURITY: Never use shell: true with user-controlled arguments.
* On Windows, invoke gitnexus.cmd directly (no shell needed).
*
* Unix orphan containment (#2163 follow-up): the augment CLI is the
* longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
* npx), so on Unix every CLI-running branch gets the same SIGKILL-surviving
* coreutils `timeout` wrapper as the probe's lsof/ps (the cheap which/where
* PATH check stays unwrapped). The wrapper budget is ceil(inner/1000)+1
* seconds — STRICTLY greater than the inner spawnSync timeout, so on the
* supervised path Node's SIGTERM always fires first and the existing
* error/status contract is untouched. Once the hook itself has been
* SIGKILLed (exactly the orphan case the wrapper exists for), the guard
* semantics differ per branch:
* - direct exec (GITNEXUS_HOOK_CLI_PATH / PATH-installed `gitnexus`; the
* CLI is the guard's CHILD): `-k 1` TERM-first — a SIGTERM-immune CLI
* can hold the guard ~1s past the inner timeout before the `-k` SIGKILL
* escalation reaps it.
* - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
* budget expiry SIGKILLs the whole process group outright. TERM-first
* would kill only the obedient npx parent, making `timeout` reap it and
* return before the `-k` escalation ever fires, stranding a
* SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
* 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
* `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
* on this branch, both bounded by "no worse than pre-fix" (where the
* grandchild received no signal at all): the group-wide SIGKILL is
* coreutils semantics — a busybox `timeout` passes the self-test (it
* has `-k` and propagates exit status) but signals only its direct
* child, so a busybox guard cannot reach the grandchild; and on the
* SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
* guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
* and the guard exits before any KILL fires — so a SIGTERM-immune CLI
* grandchild still escapes in those two cases.
* If the sibling probe predates the resolveUnixGuardTimeout export (version
* skew), the adapter degrades to the unwrapped invocation instead of
* throwing. Windows is deliberately NOT wrapped — there is no coreutils
* timeout to resolve there and the resolver's self-test spawns /bin/sh — so
* on win32 (the gitnexus.cmd / npx.cmd paths) and whenever the guard
* resolves to null (e.g. macOS without Homebrew coreutils — reported once
* under GITNEXUS_DEBUG) the argv stays byte-identical to the pre-wrap
* invocation.
*/
function runGitNexusCli(args, cwd, timeout) {
const isWin = process.platform === 'win32';
// Version-skew guard (#2163 follow-up review): an older sibling probe
// without the resolveUnixGuardTimeout export must degrade to the unwrapped
// invocation — a TypeError here would be swallowed by the caller's catch
// and silently kill the augment.
const guard =
isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
// Diagnose the "stays unwrapped" Unix paths once per hook process: no
// usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
// coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
unguardedCliWarned = true;
process.stderr.write(
'[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
);
}
const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH;
if (hookCli !== undefined && String(hookCli).trim() && fs.existsSync(String(hookCli))) {
return spawnSync(process.execPath, [String(hookCli), ...args], {
const [cmd, cmdArgs] = guard
? [
guard,
[
'-k',
'1',
String(Math.ceil(timeout / 1000) + 1),
process.execPath,
String(hookCli),
...args,
],
]
: [process.execPath, [String(hookCli), ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout,
cwd,
@ -231,7 +306,12 @@ function runGitNexusCli(args, cwd, timeout) {
}
if (useDirectBinary) {
return spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, {
// A non-null guard implies non-Windows, so the wrapped arm can hardcode
// plain `gitnexus` (the guard resolves it via PATH, like spawnSync does).
const [cmd, cmdArgs] = guard
? [guard, ['-k', '1', String(Math.ceil(timeout / 1000) + 1), 'gitnexus', ...args]]
: [isWin ? 'gitnexus.cmd' : 'gitnexus', args];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout,
cwd,
@ -239,8 +319,27 @@ function runGitNexusCli(args, cwd, timeout) {
windowsHide: true,
});
}
// npx fallback needs shell on Windows since npx is a .cmd script
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
// npx fallback needs shell on Windows since npx is a .cmd script. The
// wrapped arm leads with `-s KILL` (NOT TERM-first like the direct
// branches above): the CLI here is a grandchild behind npx — see the
// docblock.
const [cmd, cmdArgs] = guard
? [
guard,
[
'-s',
'KILL',
'-k',
'1',
String(Math.ceil((timeout + 5000) / 1000) + 1),
'npx',
'-y',
'gitnexus',
...args,
],
]
: [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,

View file

@ -3,14 +3,36 @@
* with a command line that looks like a GitNexus MCP/serve server?
*
* Backends (no user-installed Sysinternals):
* - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof;
* optional lsof fallback when proc scan finds nothing.
* - Linux: cmdline-first procfs scan under /proc, no lsof at all (#2180). Three
* phases, cheapest first: (0) read /proc/<pid>/comm — a tiny task->comm read
* that never touches the target's mm — and keep only PIDs whose comm is a
* plausible node/gitnexus server; (1) read up to GITNEXUS_HOOK_PROC_CMDLINE_MAX
* bytes of /proc/<pid>/cmdline via openSync+readSync (bounded, so a D-state
* holder stuck on mmap_lock or a giant argv can't wedge the hook) and prefilter
* with isGitNexusServerCommand; (2) only for the 0..N survivors, stat their
* /proc/<pid>/fd/* and compare dev+inode against the target lbug. The lbug
* handle is fd-visible (a @ladybugdb/core property), so this finds every real
* owner without scanning every fd of every process.
* - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first).
* - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script +
* Win32_Process for command lines; trusted powershell.exe under %SystemRoot%.
*
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
* Fail matrix:
* - Linux proc scan: owner found -> fail-closed (skip augment); budget exhausted
* (GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS) -> fail-CLOSED (#2180). This is a
* deliberate change from the old "timeout -> fail-open then try lsof" path.
* End-to-end the busy-host outcome is unchanged: the old code's lsof fallback
* ETIMEDOUT'd on the very hosts where the scan ran out of budget and ALSO
* failed closed there — the lsof leg only ever added 1-2s of dead work plus
* the orphan-storm risk it caused (#2163). What changes is that an overloaded
* host now self-throttles immediately (the throttle the incident needed)
* instead of paying for a doomed lsof. Mid-load hosts that used to fall
* through to a successful lsof now answer from the scan directly (faster) or,
* if even the scan can't finish in budget, fail closed (self-throttle) — a
* bounded, documented tradeoff, never an orphan.
* - macOS / other Unix: fail-open on most errors; fail-closed only on lsof
* ETIMEDOUT, matching the hook contract.
* - Windows: fail-closed only on PowerShell ETIMEDOUT.
*
* Unix subprocess containment contract (#2163):
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
@ -20,13 +42,18 @@
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
* wrapper off deterministically; any other value is adopted only when it
* exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
* THROUGH to the built-in candidate list (first self-test pass wins), so
* no malformed value of any shape can silently disable orphan containment.
* exists AND passes a one-shot `-k` exit-propagation self-test — otherwise
* resolution FALLS THROUGH to the built-in candidate list (first self-test
* pass wins), so no malformed value of any shape can silently disable
* orphan containment.
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
* A probe before that first query is therefore always false — a known,
* pre-existing race, not a bug in this probe.
* - resolveUnixGuardTimeout is exported so the hook adapters can wrap the
* `gitnexus augment` CLI child — the longest-lived hook subprocess (7s
* local / 12s npx inner budgets) — in the same guard; see runGitNexusCli
* in the adapters (#2163 follow-up).
*/
const fs = require('fs');
@ -41,6 +68,16 @@ function isGitNexusServerCommand(command) {
return hasServerMode && hasGitNexus;
}
// GITNEXUS_DEBUG-gated stderr diagnostics. Reuses the exact gating predicate the
// Windows ps1-load warning already uses (===' 1' / ==='true') so there is one
// debug convention in this file, and writes via process.stderr.write (NOT a
// spawn) so it never perturbs the windowsHide spawn-count invariant.
function debugLog(msg) {
if (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') {
process.stderr.write(`[GitNexus hook] ${msg}\n`);
}
}
function resolveHookBinary(tool) {
const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
const fromEnv = process.env[envKey];
@ -70,38 +107,53 @@ let unixGuardTimeoutCache;
/**
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
* (#2163). Dead code on Windows (the win32 dispatch returns earlier).
* (#2163). Unix-only by contract: the probe's win32 dispatch returns before
* reaching it, and the exported callers (the adapters' runGitNexusCli,
* #2163 follow-up) must check the platform first — the self-test below
* spawns /bin/sh. The memoized result is module-wide, so probe and adapter
* share one lazy self-test per hook process.
*
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
* wrapper off; any other value is only a CANDIDATE — an existing file path
* is tried first, but it must pass the `-k` self-test to be adopted. On any
* failure (non-existent path, directory, non-executable file, wrapper
* without `-k` support, …) resolution falls through to the built-in
* candidates below, tried in order, first self-test pass wins. This is
* strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
* GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
* value of ANY shape can silently disable orphan containment.
* is tried first, but it must pass the `-k` exit-propagation self-test to
* be adopted. On any failure (non-existent path, directory, non-executable
* file, wrapper without `-k` support, always-exit-0 stub, …) resolution
* falls through to the built-in candidates below, tried in order, first
* self-test pass wins. This is strictly stronger than the sibling
* GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only
* check existence): no bad env value of ANY shape can silently disable
* orphan containment.
*
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
* first reached, and the result is memoized. A candidate is adopted only
* when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
* not support the coreutils `-k` flag — busybox <1.34, toybox, broken
* symlinks — which would otherwise exit with a usage error without ever
* running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
* into fail-open (#1492 regression). Only when EVERY candidate fails does
* the probe fall back to the unwrapped status quo (memoized null).
* busybox ≥1.34 passes the test and is fully usable (capability, not
* identity, decides).
* when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 — i.e. it must RUN
* the wrapped command AND PROPAGATE its exit status. This rejects two
* failure shapes: wrappers without the coreutils `-k` flag — busybox <1.34,
* toybox, broken symlinks — which would exit with a usage error without
* ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed
* contract into fail-open (#1492 regression); and always-exit-0 stubs
* (/bin/true shapes), which would otherwise be adopted and "succeed" every
* wrapped spawn instantly without running it — a constant no-owner probe
* answer and, worse, a silently dead augment (status 0, empty stderr passes
* the adapters' success check with no context; #2163 follow-up review).
* Only when EVERY candidate fails does the probe fall back to the unwrapped
* status quo (memoized null). busybox ≥1.34 passes the test and is fully
* usable for everything THIS file spawns (lsof/ps are the guard's direct
* children) and for the adapters' direct-exec arm. The adapters' npx arm
* additionally relies on coreutils' process-GROUP signalling for its
* `-s KILL` grandchild reaping; busybox signals only its direct child, and
* this self-test deliberately does not probe that capability — see the
* adapter docblocks for the residual-gap statement.
*/
function passesGuardSelfTest(guard) {
try {
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], {
encoding: 'utf-8',
timeout: 3000,
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
});
return !selfTest.error && selfTest.status === 0;
return !selfTest.error && selfTest.status === 42;
} catch {
return false;
}
@ -222,59 +274,325 @@ function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
return false;
}
function readLinuxCmdline(pidStr) {
// The procfs root every Linux scan path reads from. Production is always /proc;
// GITNEXUS_HOOK_PROC_ROOT only exists so unit tests can inject a fixture tree
// (comm + cmdline + fd symlinks) and assert the three-phase logic without
// scanning the real, ~hundreds-of-process /proc of the test host.
//
// Test-only gate (F4): the override is honored ONLY under a test runner —
// vitest injects VITEST="true" and NODE_ENV="test" into every worker (verified;
// a production hook is `node <file>.cjs` with neither set). Without the gate, a
// production env that accidentally leaked GITNEXUS_HOOK_PROC_ROOT (pointing at an
// empty/bad tree) would make readdirSync find no pids -> 'not-owned' -> Linux
// owner detection silently OFF (fail-OPEN: augment races the real server for the
// lbug, the #1492 class). Gating to the test signal makes that leak inert in
// production (always /proc) while the fake-procfs unit tests, which run under
// vitest, still inject freely. Unset env (or non-test context) => /proc, so the
// production path is byte-for-byte the historical behavior.
function isTestContext() {
return (
process.env.VITEST === 'true' || process.env.VITEST === '1' || process.env.NODE_ENV === 'test'
);
}
function getProcRoot() {
if (!isTestContext()) return '/proc';
const raw = process.env.GITNEXUS_HOOK_PROC_ROOT;
return raw && String(raw).trim() ? String(raw) : '/proc';
}
// Max bytes read from /proc/<pid>/cmdline in Phase 1. Bounded by default so a
// D-state holder wedged on mmap_lock, or a process with a pathological multi-MB
// argv, can't stall the hook. 16 KiB comfortably clears a realistic
// `node <abs path to .../node_modules/gitnexus/dist/cli/index.js> mcp` line
// (the `mcp`/`serve` mode token lives at the very tail, so the cap must be large
// enough to reach it — see PROC_CMDLINE_FLOOR escalation below). Overridable for
// tests; never goes below PROC_CMDLINE_FLOOR.
const PROC_CMDLINE_FLOOR = 4096;
function getCmdlineMaxBytes() {
const raw = process.env.GITNEXUS_HOOK_PROC_CMDLINE_MAX;
// Number() (not parseInt) so "8e3" reads as 8000, not 8 (parseInt stops at
// 'e'). The `raw && String(raw).trim()` guard keeps empty/whitespace on the
// default; trailing garbage ("8abc") now -> NaN -> default (stricter).
const n = raw && String(raw).trim() ? Number(String(raw).trim()) : NaN;
if (Number.isFinite(n) && n >= PROC_CMDLINE_FLOOR) return n;
return 16384;
}
// Phase 0 comm prefilter. /proc/<pid>/comm is the kernel task->comm string,
// capped at 16 bytes INCLUDING the trailing NUL — i.e. at most 15 visible
// chars, truncated by the kernel with no marker. So a process whose real name
// is longer than 15 chars shows a 15-char prefix here. The match below is
// therefore truncation-safe in BOTH directions (a whitelist name that is a
// prefix of comm, or comm that is a prefix of a whitelist name, both count) to
// guarantee we never drop a real owner at this cheap stage — Phase 2's dev+ino
// fd check is the real authority; Phase 0/1 only exist to skip the overwhelming
// majority (kernel threads, shells, editors) cheaply.
//
// The whitelist is calibrated against what a real `gitnexus mcp`/`serve` server
// actually reports for comm. Observed on production hosts: the server renames
// its main thread, so comm reads `MainThread` (via @ladybugdb/core's
// worker_threads setup), NOT `node` — omitting it would blind the probe to
// every real server (#1492-class owner miss). We also keep the plausible
// launcher/runtime basenames in case a future build does not rename the thread.
// Conservative by design: over-collecting a few extra candidates only costs a
// bounded number of Phase 1 cmdline reads.
const COMM_CANDIDATES = ['node', 'gitnexus', 'bun', 'deno', 'npm', 'npx', 'MainThread'];
function commLooksLikeServer(comm) {
const c = comm.trim();
if (!c) return false;
for (const name of COMM_CANDIDATES) {
if (name === c || name.startsWith(c) || c.startsWith(name)) return true;
}
return false;
}
function readProcComm(procRoot, pidStr) {
try {
return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
return fs
.readFileSync(path.join(procRoot, pidStr, 'comm'), 'utf8')
.replace(/\0+/g, '')
.trim();
} catch {
return '';
}
}
function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
// Timeout sentinel for readLinuxCmdline (F3). MUST be distinct from the
// "unreadable/empty" return value (''): '' flows through isGitNexusServerCommand
// as a NON-candidate (both regexes are false on ''), so the Phase 1 caller
// `continue`s past it — correct for a raced/openSync-failed pid, but a FAIL-OPEN
// bug if it ever meant "I ran out of budget mid-read" (a real owner whose
// escalation timed out would be silently dropped, racing the lbug -> #1492). A
// unique Symbol can never collide with any cmdline string, so the caller can
// branch on it explicitly and map a mid-read timeout to the tri-state 'timeout'
// (fail-CLOSED) instead of swallowing it as a non-candidate.
const CMDLINE_TIMEOUT = Symbol('gitnexus.cmdline.timeout');
// Bounded /proc/<pid>/cmdline read for Phase 1. openSync+readSync (not
// readFileSync) so a D-state holder cannot stall the hook on a huge or
// never-EOF argv: we read at most `cap` bytes and stop. cmdline separates argv
// with NULs; convert to spaces for isGitNexusServerCommand.
//
// Owner-miss guard for the 4 KB cap: the `gitnexus` token usually sits in the
// first path component while the `mcp`/`serve` mode token is the LAST argv, so
// a naive 4 KB read could clip the mode token off a server launched with a very
// long interpreter path and silently miss a real owner. We mitigate two ways:
// (a) the default cap (16 KiB) already clears realistic lines; (b) if the first
// read fills the cap AND already contains the `gitnexus` token but no mode
// token yet, we keep reading in bounded chunks (up to a hard ceiling) until the
// mode token appears or the file ends — so a genuine server is never missed for
// want of a few more bytes, while non-candidates still pay only the initial
// bounded read.
//
// Budget (F3): the escalation loop above is the one place a SINGLE pathological
// candidate could read up to HARD_CEIL (256 KiB) before the next scan-level
// budget check, weakening the timeout contract. `outOfBudget` (the scan's shared
// deadline callback) is checked once per escalation iteration; on expiry we
// return CMDLINE_TIMEOUT (NOT '') so the caller can fail-closed honestly rather
// than mistake the partial read for a non-candidate. Reads that simply can't
// open / error out still return '' (genuinely "not a readable candidate").
function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) {
const file = path.join(procRoot, pidStr, 'cmdline');
let fd;
try {
fd = fs.openSync(file, 'r');
} catch {
return '';
}
try {
const HARD_CEIL = 262144; // 256 KiB absolute ceiling for the escalation path
let collected = Buffer.alloc(0);
let offset = 0;
let chunkCap = cap;
for (;;) {
// allocUnsafe is safe here: readSync fills exactly [0, bytes), only
// buf.subarray(0, bytes) is consumed, and Buffer.concat deep-copies that
// slice into `collected`, so the uninitialized tail never reaches decode.
const buf = Buffer.allocUnsafe(chunkCap);
const bytes = fs.readSync(fd, buf, 0, chunkCap, offset);
if (bytes <= 0) break;
collected = Buffer.concat([collected, buf.subarray(0, bytes)]);
offset += bytes;
const text = collected.toString('utf8').replace(/\0+/g, ' ');
// Stop early when we can already decide "owner": has both the gitnexus
// token and a mode token. Keep going only when gitnexus is present but
// the mode token might be just past the boundary.
const hasGitNexus =
/(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(text) ||
/node_modules[/\\]gitnexus[/\\]/.test(text);
const hasMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(text);
if (hasMode) break; // decided (positive); isGitNexusServerCommand re-checks below
if (bytes < chunkCap) break; // EOF: full cmdline read, definitive
if (!hasGitNexus) break; // not a candidate; do not escalate the read
if (offset >= HARD_CEIL) break; // bounded escalation only
// Budget gate the escalation: a single huge-argv candidate must not burn
// the whole scan deadline before we re-check. Return the timeout sentinel
// (never '') so the caller fails closed instead of treating us as a
// non-candidate. The sole caller (linuxProcScanFindGitNexusServer) always
// passes outOfBudget, so no presence guard is needed.
if (outOfBudget()) return CMDLINE_TIMEOUT;
chunkCap = cap; // keep reading more in cap-sized chunks
}
return collected.toString('utf8').replace(/\0+/g, ' ').trim();
} catch {
return '';
} finally {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
function resolveLinuxProcBudgetMs() {
const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
// Gate on the STRING's emptiness, NOT the parsed number's truthiness — the
// old `Number(raw && trim()) ? ... : 1200` form treated "0" as falsy and
// silently fell back to 1200 (#2180). Use Number() (not parseInt) so "16e3"
// reads as 16000, not 16 (parseInt stops at 'e'). The `&& String(raw).trim()`
// guard is load-bearing: without it a set-but-empty/whitespace value would be
// `Number("")===0` => budget 0 => immediate fail-CLOSED timeout (augment
// permanently skipped). With it, ''/whitespace => NaN => 1200 default, while a
// finite "0" still parses to an explicit, deterministic "no budget" =>
// immediate timeout. Non-numeric / unset => default 1200.
const n = raw != null && String(raw).trim() ? Number(String(raw).trim()) : NaN;
if (!Number.isFinite(n)) return 1200;
return n; // may be <= 0, meaning "out of budget on the first check"
}
// Returns one of: 'owned' (a non-self process with a GitNexus-server cmdline
// holds the target lbug fd), 'not-owned' (scan completed, no such owner), or
// 'timeout' (the per-scan budget was exhausted before a verdict). The name is
// pinned by a source-contract test; only the return TYPE changed (#2180:
// boolean -> tri-state, so the dispatcher can fail-closed on 'timeout').
function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
const budget = resolveLinuxProcBudgetMs();
// A non-positive budget is an explicit, deterministic "no time to scan" =>
// immediate timeout (the #2180 test vector, and the only correct reading of
// the fixed parse: "0" must NOT mean 1200). Returning before any procfs read
// keeps it instantaneous regardless of host load.
if (budget <= 0) return 'timeout';
const procRoot = getProcRoot();
const cmdlineCap = getCmdlineMaxBytes();
const start = Date.now();
const outOfBudget = () => Date.now() - start > budget;
let targetStat;
try {
targetStat = fs.statSync(dbPathAbs);
} catch {
return false;
// Caller already existsSync'd the path; a stat failure here is a transient
// race, treat as no owner (historical semantics).
return 'not-owned';
}
let procEntries;
try {
procEntries = fs.readdirSync('/proc', { withFileTypes: true });
procEntries = fs.readdirSync(procRoot, { withFileTypes: true });
} catch {
return false;
return 'not-owned';
}
// Phase 0 + Phase 1: collect the few PIDs whose comm AND cmdline look like a
// GitNexus server, without touching any fd yet.
const candidates = [];
for (const ent of procEntries) {
if (Date.now() - start > budget) return false;
if (outOfBudget()) return 'timeout';
if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
const pid = Number.parseInt(ent.name, 10);
if (!Number.isFinite(pid) || pid === myPid) continue;
const fdDir = path.join('/proc', ent.name, 'fd');
// Phase 0: cheap comm prefilter.
const comm = readProcComm(procRoot, ent.name);
if (!comm) continue; // unreadable comm (kernel thread, raced exit) -> skip
if (!commLooksLikeServer(comm)) continue;
// Phase 1: bounded cmdline read + isGitNexusServerCommand prefilter.
if (outOfBudget()) return 'timeout';
const cmdline = readLinuxCmdline(procRoot, ent.name, cmdlineCap, outOfBudget);
// F3: a mid-read budget timeout returns the CMDLINE_TIMEOUT sentinel (a
// Symbol, never a string). Fail CLOSED on it rather than letting it fall
// through isGitNexusServerCommand as a non-candidate — a real owner whose
// escalation timed out must not be silently dropped (would fail-OPEN).
if (cmdline === CMDLINE_TIMEOUT) return 'timeout';
if (!isGitNexusServerCommand(cmdline)) continue;
candidates.push(ent.name);
}
// Phase 2: only now stat the fds of the (typically 0-2) survivors.
for (const pidStr of candidates) {
if (outOfBudget()) return 'timeout';
const fdDir = path.join(procRoot, pidStr, 'fd');
let fds;
try {
fds = fs.readdirSync(fdDir);
} catch {
} catch (err) {
// F1: the old code returned 'owned' for EVERY non-ENOENT error. That was
// a correctness bug: /proc/<pid>/fd is owner-only (mode 0500), so a
// cross-user/root `gitnexus mcp` serving a DIFFERENT repo passes Phase 0+1
// (its cmdline matches) and then EACCES'es here — yet its dev+ino was
// NEVER compared against THIS lbug. Claiming 'owned' lets it permanently,
// silently suppress augment for a repo it does not actually lock. We now
// distinguish the failure shapes (all still fail-closed where we can't
// prove non-ownership, but 'timeout' is the HONEST verdict for
// "inconclusive", not the false-positive 'owned'):
const code = err && err.code;
if (code === 'ENOENT') {
// Process raced away between the candidate scan and now -> genuinely no
// longer an owner. Move on.
continue;
}
if (code === 'EACCES' || code === 'EPERM') {
// Permission-denied fd dir: cannot read fds, so ownership is
// UNVERIFIABLE. Fail closed honestly via 'timeout' (the dispatcher maps
// timeout -> true, same protective skip as before) WITHOUT lying that we
// confirmed ownership. Do NOT degrade to not-owned/fail-open: if this
// really is the owner, fail-open re-opens the #1492 lbug race; augment
// is optional context, so a conservative skip costs little.
debugLog(
`fd dir unreadable for candidate pid ${pidStr} (${code}); ownership ` +
`unverifiable, probe inconclusive -> fail-closed (timeout)`,
);
return 'timeout';
}
if (code === 'EIO' || code === 'ESTALE') {
// Genuine transient I/O against this candidate's fd dir — not evidence
// it does NOT hold the lbug. Treat as inconclusive and fail closed
// (timeout) rather than continue, so a real owner mid-I/O-blip is not
// dropped (would fail-open).
debugLog(
`fd dir transient I/O error for candidate pid ${pidStr} (${code}); ` +
`probe inconclusive -> fail-closed (timeout)`,
);
return 'timeout';
}
// Any other shape (ENOTDIR — fd path is not a directory at all, so this
// is not a plausible live-procfs owner — and the long tail) is treated as
// "this candidate is not an owner": move to the next candidate instead of
// the old blanket 'owned'. If no other candidate owns the lbug the scan
// ends not-owned (dispatcher fail-open) — acceptable because ENOTDIR means
// the fd entry is structurally not a real /proc/<pid>/fd.
debugLog(
`fd dir not a readable directory for candidate pid ${pidStr} ` +
`(${code || 'unknown'}); treating candidate as non-owner -> continue`,
);
continue;
}
let holds = false;
for (const fd of fds) {
if (Date.now() - start > budget) return false;
if (outOfBudget()) return 'timeout';
try {
const st = fs.statSync(path.join(fdDir, fd));
if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
holds = true;
break;
return 'owned';
}
} catch {
/* ignore */
/* fd raced closed; ignore */
}
}
if (!holds) continue;
if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
}
return false;
return 'not-owned';
}
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
@ -350,8 +668,13 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
}
if (process.platform === 'linux') {
if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
// #2180: cmdline-first procfs scan, no lsof. 'timeout' fails CLOSED
// (overloaded host self-throttles — the throttle the orphan-storm incident
// needed; the old lsof fallback ETIMEDOUT'd and failed closed on these same
// hosts anyway, only slower and with the orphan risk). 'not-owned' is the
// only false. See the fail matrix in the file header.
const verdict = linuxProcScanFindGitNexusServer(dbPathAbs, myPid);
return verdict !== 'not-owned';
}
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
@ -359,4 +682,30 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
module.exports = {
hasGitNexusDbLockedByGitNexusServer,
// Exported for white-box unit tests that must assert the tri-state verdict
// ('owned' | 'not-owned' | 'timeout') directly — the dispatcher collapses
// timeout and owned to the same boolean true, so the boolean API alone cannot
// distinguish the F1 EACCES->timeout fix from the old EACCES->owned bug. The
// Probe interface already declares this optional. Linux-only by contract; the
// name is pinned by a source-contract test.
linuxProcScanFindGitNexusServer,
// #2163 follow-up: the hook adapters wrap the augment CLI in the same
// guard. Returns a self-tested wrapper path — the built-in candidates are
// always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
// exact string that passed the self-test. Same string is also the same
// RESOLUTION for absolute paths and for slashless names (PATH lookup is
// cwd-independent); a slash-containing RELATIVE override, however, is
// existsSync-checked and self-tested against this process's cwd while the
// adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
// a value can pass here yet ENOENT at the augment call site — set the
// override to an absolute path. Returns null when the wrapper is
// disabled/unavailable. Never call on win32 (see its JSDoc).
resolveUnixGuardTimeout,
// Exported for white-box unit tests of the numeric-env parsing (#2183 review):
// Number()-not-parseInt so "16e3" reads as 16000, plus the empty/whitespace
// guard that keeps a set-but-empty budget on the 1200 default instead of an
// immediate fail-closed timeout. Tested directly because the values are
// otherwise only observable indirectly through scan timing/escalation.
getCmdlineMaxBytes,
resolveLinuxProcBudgetMs,
};

View file

@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Workflow
```
1. query({query: "<error or symptom>"}) → Find related execution flows
1. query({search_query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@ -51,7 +51,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
**query** — find code related to error:
```
query({query: "payment validation error"})
query({search_query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
@ -75,7 +75,7 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. query({query: "payment error handling"})
1. query({search_query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError

View file

@ -18,7 +18,7 @@ description: "Use when the user asks how code works, wants to understand archite
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. query({query: "<what you want to understand>"}) → Find related execution flows
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@ -50,7 +50,7 @@ description: "Use when the user asks how code works, wants to understand archite
**query** — find execution flows related to a concept:
```
query({query: "payment processing"})
query({search_query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
@ -68,7 +68,7 @@ context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. query({query: "payment processing"})
2. query({search_query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. context({name: "processPayment"})

View file

@ -38,7 +38,10 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` |
| `check` | Check graph invariants such as circular imports |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
@ -71,6 +74,25 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
### 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`.
### Control & data dependence (`pdg_query`)
`pdg_query` reads the control/data-dependence layers `gitnexus analyze --pdg` records (CDG + REACHING_DEF, basic-block granular) — the control/data analog of `explain`. It is **always anchored** (a `target` file path or symbol, resolved like `context`) and has two modes:
- `pdg_query { mode: "controls", target: "..." }` — CDG: "under what condition does X run?". Each edge is a controlling predicate block → dependent block with the branch sense (`'T'`/`'F'`) in `reason`; an edge into an early `return`/`throw` is flagged `guard: true` (guard-clause discovery — the sense depends on the predicate, so don't filter guards by a fixed label).
- `pdg_query { mode: "flows", target: "...", variable?: "..." }` — REACHING_DEF def→use edges within the function; pass `variable` to trace one binding.
A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown" when the layer can't be confirmed). Intra-procedural only — cross-function flow is taint's domain (`explain`). The raw CDG/REACHING_DEF edges are also queryable via `cypher`. See the `gitnexus-pdg-query` skill for the full query surface.
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:

View file

@ -0,0 +1,89 @@
---
name: gitnexus-pdg-query
description: "Use when querying or extending GitNexus's PDG control/data-dependence surface (the `pdg_query` MCP tool, CDG/REACHING_DEF edges), or reasoning about \"what controls X\" / \"where does Y flow\" / guard clauses. Examples: \"what guards this statement?\", \"trace this variable within the function\", \"why is the pdg_query result empty?\", \"add a CDG query\"."
---
# PDG query surface with GitNexus
Expert knowledge for the `pdg_query` MCP tool and the control/data-dependence
edges it reads — the opt-in `--pdg` program-dependence layers. Read this before
touching `gitnexus/src/mcp/local/local-backend.ts` (`_pdgQueryImpl`) or the
`pdg_query` tool def, or when explaining a `pdg_query` result.
## When to Use
- "Under what condition does this statement run?" (guarding predicates).
- "Where does this variable flow inside the function?" (def→use).
- Guard-clause discovery (early-return guards — subsumes the #559 heuristic).
- Extending or reviewing `pdg_query` / the CDG / REACHING_DEF read path.
- Debugging an empty or surprising `pdg_query` result.
## The layered substrate (build order)
`pdg_query` runs **on** the same graph taint runs on. Each layer is opt-in
behind `--pdg`; a default `analyze` run records none of them (byte-identical).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L5 CDG Ferrante control dependence (post-dominators) (M5 #2085)
```
All three are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table
(keyed by the `type` property). There is **no** `Function → BasicBlock` edge.
## The two modes
- `pdg_query({ mode: 'controls', target })` — CDG. For the anchored function,
each edge: controlling predicate block → dependent block + branch sense in
`label` (`'T'` = predicate's true/taken arm, `'F'` = false/fall-through). An
edge into an early-return/throw block is flagged `guard: true`.
- `pdg_query({ mode: 'flows', target, variable? })` — REACHING_DEF def→use
edges; `variable` filters to one binding.
`target` is **required** — a file path or a symbol/function name (resolved like
`context()`). There is no anchorless mode (see below).
## The corrected guard-clause Cypher
The RFC #567 §2 form (`[:CDG {label:'F'}]`) does **not** run as written. Edges
are values of the single `CodeRelation` table's `type` property, and the branch
sense is in `reason`, NOT a `label` column:
```cypher
MATCH (pred:BasicBlock)-[r:CodeRelation {type: 'CDG'}]->(dep:BasicBlock)
WHERE dep.text STARTS WITH 'return' OR dep.text STARTS WITH 'throw'
RETURN pred.startLine, r.reason AS branch, dep.startLine, dep.text
```
`r.reason` is the sense the predicate took to reach the early exit. For
`if (!ok) return;` the return rides the predicate's **true** arm (`'T'`) and the
protected body rides the **false** arm (`'F'`) — polarity depends on the guard,
so don't hard-code one sense.
## Gotchas (the load-bearing ones)
- **Always anchored + LIMIT-bounded.** LadybugDB has no rel-property index, so
an unanchored `[:CDG*]`/`[:REACHING_DEF*]` path scan is unbounded. `pdg_query`
requires `target` and bounds the page; raw `cypher` callers must anchor on a
file id-prefix or symbol span themselves.
- **BasicBlock↔symbol join is reconstructed.** No `Function→BasicBlock` edge:
the block is matched by its id-prefix (`BasicBlock:<file>:<fnStartLine>:…`)
plus `startLine` within the symbol's span. BasicBlock `startLine` is **1-based**
while the symbol node's `startLine`/`endLine` are **0-based**, so **both** bounds
are shifted `+1` (`[symStart+1, symEnd+1]`): the upper `+1` keeps a guard/def/use
on the function's **final line**, the lower `+1` excludes an adjacent function's
block on the line directly **above**. Same-line / nested functions anchor coarsely.
- **No PDG layer ⇒ a note, not an error.** If the repo wasn't indexed with
`--pdg` the tool returns `{ results: [], note: "no PDG layer …" }` (cheap meta
probe on `RepoMeta.pdg.maxCdgEdgesPerFunction` / `maxReachingDefEdgesPerFunction`).
- **CDG labels are binary in M5/M6.** Every `switch`-case arm is `'T'`; per-case
conditions are not yet distinguished.
- **Intra-procedural only.** Cross-function flow is taint's domain (`explain`).
## Mirror, don't fork
`_pdgQueryImpl` is the front half of `_explainImpl` (WAL wrapper, meta no-layer
probe, limit validation, `resolveSymbolCandidates` anchoring) with CDG/
REACHING_DEF instead of TAINTED — and none of taint's path-codec / interproc
`TAINT_PATH` machinery. Reuse those shared helpers; do not re-implement them.

View file

@ -17,7 +17,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
2. query({search_query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```

View file

@ -0,0 +1,178 @@
---
name: gitnexus-taint-analysis
description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
---
# CFG & Taint Analysis with GitNexus
Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
graphs, reaching definitions, and intra- + inter-procedural taint. Read this
before touching `gitnexus/src/core/ingestion/cfg/**` or
`gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
## When to Use
- "How does the taint engine work / why is this flow (not) reported?"
- Adding a source, sink, or sanitizer to the model.
- Extending or reviewing the CFG / reaching-defs / taint / summary code.
- Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
- Debugging a false positive or false negative in `--pdg` output.
## The layered substrate (build order)
Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
and a default `analyze` run is **byte-identical** (the golden parity gate is the
hard floor for every change here).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
```
- **Worker-built, main-thread-solved.** The parse worker builds each function's
CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
(plain, structured-clone-safe data — never AST nodes). The main thread runs
the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
OOM).
- **In-phase emit (KTD1).** L1–L4-harvest all run INSIDE the scope-resolution
pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
because the disk-backed ParsedFile store is cleared when that phase ends — a
standalone post-`mro` phase would read empty data. The cross-function fixpoint
(L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
small plain summary data threaded out via `ScopeResolutionOutput`.
- **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
`harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
(no graph, no I/O, no logger; sorted outputs). Snapshot tests and
content-derived edge ids depend on it.
## Intra-procedural taint (L3)
Forward reachability over RD facts from matched **sources** to matched **sinks**,
killed by **sanitizers**. Key design points worth internalizing:
- **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
`exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
precise.
- **Kind-set sanitizer model.** A taint carries a set of *neutralized*
`SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
kill would be a suppressed live injection (the forbidden FN direction).
`path.basename(t)` neutralizes path-traversal only, not command-injection.
- **Statement-level finding identity.** NOT block-pair (block conflation drops
distinct findings; `exec(req.body, req.query)` is two findings).
- Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
`reason` column via the shared versioned codec (`taint/path-codec.ts`).
## Interprocedural taint (L4) — the functional/summary method
The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
reduced to a compact **summary**, and summaries are composed over the already-
resolved `CALLS` graph.
**Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
| Edge | Meaning | Analogue |
|------|---------|----------|
| `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
| `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
| `param→sink` | a param reaches a modelled sink | partial/triggered sink |
| `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
| `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
| `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
**The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
parameter, source)`. Seed from `source→callee-arg`, propagate via
`param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
- **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
lattice (`fn × param × source`), so the worklist converges — a recursive call
just re-proposes an already-visited entry. SCC condensation would only refine
processing order; correctness/termination don't require it.
- **Source-discriminated state (load-bearing).** Key the state by the SOURCE
too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
tainted by source A is marked visited and a later flow from source B is dropped
before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
- **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
site) is fragile; the callee identity is exact and context-insensitivity
taints the callee's param identically at every call site.
- Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
in `reason` via the same codec; confidence < the intra-procedural 1.0.
**Context-insensitivity** is the accepted trade-off at this tier: one summary
per function, return/call-site merging accepted (security-conservative). Expect
some FP from merging; the bigger FN sources are unmodeled features (below).
## Known false-negative classes (documented, deferred)
The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
into a callback is dropped without per-library models (true of CodeQL's JS libs
too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
flows, promise/async-await threading, and **destructured/rest params before a
tainted simple param** (the summary port index is the binding ordinal, not the
formal arg position — needs a formal-param index threaded from the worker
`BindingEntry`). The interprocedural join is also context-insensitive: when one
caller invokes two distinct **same-named callees**, a flow into one
over-attributes to both (sound — over-report, never a missed flow). Absence of a
finding is NOT proof of safety.
## GitNexus-specific gotchas
- **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
`Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
ambiguous → drop (the summary driver counts `unresolved`) rather than
cross-wire.
- **No rel-property index (S1).** Kuzu has no secondary index on relationship
properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
traversed live; `explain` reads it source-anchored + LIMIT-guarded.
- **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
schema (pinned in `security.test.ts`). `explain` enumerates both layers
(cross-function findings carry `interprocedural: true`).
- **One shared codec.** Both the emit path and `explain` import
`taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
fork it. New metadata extends the format WITHIN the version when writer +
reader ship together.
- **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
full writeback. Model content rides `taintModelVersion`.
## Adding a source / sink / sanitizer
Edit the language model in `taint/typescript-model.ts` (registered via the
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
finding (or its absence) in `test/unit/taint/` (real-source harness:
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
`test/integration/cfg/`.
## Validation checklist for any `--pdg` change
```
1. tsc clean (schema additions are exhaustiveness-checked; watch the
api.ts getNodeQuery runtime read-path if a node label is added).
2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
test/integration/cfg) — verify by ISOLATION, not full-suite exit
(known load-flakes). `node scripts/build.js` before worker/integration runs.
3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
5. detect_changes() before commit; impact({direction:'upstream'}) before
editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
```
## Prior art (for deeper design questions)
Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
notation, incl. callback ports); Infer (content-keyed incremental summaries).

View file

@ -241,7 +241,18 @@ function main() {
if (!pattern || pattern.length < 3) return;
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
if (!release) {
// Normal skip path: all per-repo hook slots are held by concurrent
// sessions. Stays silent by default; surfaced only under the cursor
// hook's own GITNEXUS_DEBUG (truthy) convention. NOTE: unlike the
// claude/plugin/antigravity adapters this integration does not install
// hook-db-lock-probe.cjs, so its augment child is not guard-wrapped
// yet — tracked on the #2163 follow-up list ("cursor probe").
if (process.env.GITNEXUS_DEBUG) {
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
}
return;
}
const cliPath = resolveCliPath();
let result = '';

View file

@ -15,10 +15,10 @@ description: Trace bugs through call chains using knowledge graph
## Workflow
```
1. query({query: "<error or symptom>"}) → Find related execution flows
1. query({search_query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@ -49,7 +49,7 @@ description: Trace bugs through call chains using knowledge graph
**query** — find code related to error:
```
query({query: "payment validation error"})
query({search_query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
@ -71,7 +71,7 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. query({query: "payment error handling"})
1. query({search_query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError

View file

@ -17,7 +17,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. query({query: "<what you want to understand>"}) → Find related execution flows
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@ -48,7 +48,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
**query** — find execution flows related to a concept:
```
query({query: "payment processing"})
query({search_query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
@ -65,7 +65,7 @@ context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. query({query: "payment processing"})
2. query({search_query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. context({name: "processPayment"})

View file

@ -16,7 +16,7 @@ description: Plan safe refactors using blast radius and dependency mapping
```
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
2. query({search_query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```

View file

@ -157,7 +157,21 @@ export type RelationshipType =
| 'SANITIZES'
/** Materialized source→sink taint path. Working name — final name/representation
* is confirmed when M3/M4 emits it; no persisted edge exists before then. */
| 'TAINT_PATH';
| 'TAINT_PATH'
/** Control-dependence edge (PDG, issue #2085 M5): block `dependent` (target)
* executes only because the branch at block `controller` (source) took a
* given side. The branch sense (`'T'` | `'F'`) rides the relation's existing
* `reason` column — mirroring how `CFG` stores its edge kind there — since
* the single `CodeRelation` table has no dedicated label column. */
| 'CDG'
/** Debug-only post-dominator-tree edge (#2085 M5): a block → its immediate
* post-dominator, emitted behind the `GITNEXUS_PDG_EMIT_POST_DOMINATE` env
* flag for inspection. Never emitted in a normal `--pdg` run. Note: as a
* member of this exported union it is a forward-compatibility commitment —
* removing it later is a breaking schema change — and it is deliberately
* excluded from `VALID_RELATION_TYPES` so it never enters impact-style
* symbol-space traversal (same posture as the taint substrate edges). */
| 'POST_DOMINATE';
export interface GraphNode {
id: string;

View file

@ -77,6 +77,12 @@ export const REL_TYPES = [
'TAINTED',
'SANITIZES',
'TAINT_PATH',
// Control dependence (PDG, issue #2085 M5) — CDG carries its 'T'|'F' branch
// label in the relation's `reason` column; POST_DOMINATE is debug-only
// (behind GITNEXUS_PDG_EMIT_POST_DOMINATE). Both are BasicBlock→BasicBlock,
// reusing the existing FROM BasicBlock TO BasicBlock pair in RELATION_SCHEMA.
'CDG',
'POST_DOMINATE',
] as const;
export type RelType = (typeof REL_TYPES)[number];

View file

@ -10,7 +10,7 @@ import { StatusBar } from './components/StatusBar';
import { FileTreePanel } from './components/FileTreePanel';
import { CodeReferencesPanel } from './components/CodeReferencesPanel';
import { getActiveProviderConfig } from './core/llm/settings-service';
import { createKnowledgeGraph } from './core/graph/graph';
import { buildGraphFromConnectResult } from './lib/apply-connect-result';
import {
connectToServer,
fetchRepos,
@ -21,6 +21,7 @@ import {
type BackendRepo,
} from './services/backend-client';
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
import { parseSkipGraphParam } from './lib/graph-load-decision';
import { formatBackendError } from './i18n/error-messages';
import { useTranslation } from 'react-i18next';
@ -30,6 +31,8 @@ const AppContent = () => {
viewMode,
setViewMode,
setGraph,
setGraphMode,
setChatOnlyNodeCount,
setProgress,
setProjectName,
progress,
@ -66,15 +69,14 @@ const AppContent = () => {
setProjectName(projectName);
setCurrentRepo(projectName);
// Build KnowledgeGraph from server data for visualization
const graph = createKnowledgeGraph();
for (const node of result.nodes) {
graph.addNode(node);
}
for (const rel of result.relationships) {
graph.addRelationship(rel);
}
setGraph(graph);
// Build KnowledgeGraph from server data for visualization. In chat-only
// mode the graph download was skipped, so the shared builder keeps an
// empty (but non-null) graph and flags the mode so the UI shows the
// chat-only empty state, with the node count captured for its notice.
const built = buildGraphFromConnectResult(result);
setGraph(built.graph);
setGraphMode(built.graphMode);
setChatOnlyNodeCount(built.graphMode === 'chatOnly' ? built.nodeCount : null);
// Persist the active project in the URL for bookmarkability and F5 refresh resilience
const urlObj = new URL(window.location.href);
@ -84,10 +86,11 @@ const AppContent = () => {
// Transition directly to exploring view
setViewMode('exploring');
// Initialize agent with backend queries, then start embeddings
// Initialize agent with backend queries, then start embeddings. Pass the
// chat-only flag so the agent's prompt matches the loaded/skipped graph (#2178).
try {
if (getActiveProviderConfig()) {
await initializeAgent(projectName);
await initializeAgent(projectName, { chatOnly: result.graphSkipped });
}
startEmbeddingsWithFallback();
} catch (err) {
@ -97,6 +100,8 @@ const AppContent = () => {
[
setViewMode,
setGraph,
setGraphMode,
setChatOnlyNodeCount,
setProjectName,
setCurrentRepo,
initializeAgent,
@ -116,6 +121,9 @@ const AppContent = () => {
const params = new URLSearchParams(window.location.search);
const serverUrlParam = params.get('server');
const projectParam = params.get('project');
// `?skipGraph=1` forces chat-only, `?skipGraph=0` forces a full graph;
// absent → auto-detect by node count. Bookmarkable / survives F5 (#2178).
const skipGraphParam = parseSkipGraphParam(params.get('skipGraph'));
if (!serverUrlParam && !projectParam) return;
autoConnectRan.current = true;
@ -162,15 +170,19 @@ const AppContent = () => {
},
undefined,
projectParam || undefined,
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
{ awaitAnalysis: true, skipGraph: skipGraphParam }, // hold-queue + chat-only control (#2178)
);
};
tryConnect()
.then(async (result) => {
// Set serverBaseUrl BEFORE handleServerConnect: the latter transitions
// to 'exploring' (rendering the chat-only overlay + its "Load graph
// anyway" button) and then awaits agent init, leaving a window where
// loadGraphAnyway would silently no-op on a still-null serverBaseUrl.
setServerBaseUrl(baseUrl);
await handleServerConnect(result);
setProgress(null);
setServerBaseUrl(baseUrl);
fetchRepos()
.then((repos) => setAvailableRepos(repos))
.catch((e) => console.warn('Failed to fetch repo list:', e));
@ -261,6 +273,9 @@ const AppContent = () => {
try {
const repos = await fetchRepos();
setAvailableRepos(repos);
// Auto-detect by size for a freshly-analyzed repo (#2178). A stale
// ?skipGraph from a previously-viewed repo must NOT leak in here —
// that would bypass the size guard and could re-trigger the hang.
const result = await connectToServer(url, undefined, undefined, repoName);
await handleServerConnect(result);
setServerBaseUrl(normalizeServerUrl(url));

View file

@ -206,6 +206,10 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
// Landing-screen repo selection auto-detects by size (#2178). The
// ?skipGraph URL param is a bookmark hint for the initial auto-connect
// only; honoring a stale value for a different repo here would risk the
// hang it is meant to prevent.
const result = await connectToServer(
detectedBackendUrl,
(p, downloaded, total) => {

View file

@ -27,6 +27,8 @@ import type { GraphNode } from 'gitnexus-shared';
import { QueryFAB } from './QueryFAB';
import Graph from 'graphology';
import { useTranslation } from 'react-i18next';
import { LARGE_GRAPH_NODE_THRESHOLD } from '../config/ui-constants';
import { shouldConfirmGraphLoad } from '../lib/graph-load-decision';
export interface GraphCanvasHandle {
focusNode: (nodeId: string) => void;
@ -55,6 +57,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
animatedNodes,
graphViewMode,
setGraphViewMode,
graphMode,
chatOnlyNodeCount,
loadGraphAnyway,
} = useAppState();
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
@ -193,7 +198,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
// Update Sigma graph when KnowledgeGraph changes
useEffect(() => {
if (!graph) return;
// Skip layout work in chat-only mode: `graph` is non-null but empty, the
// overlay covers the canvas, and this guard also future-proofs against a
// transient where a populated graph is set while mode is still chat-only.
if (!graph || graphMode === 'chatOnly') return;
let sigmaGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>;
@ -218,7 +226,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
}
setSigmaGraph(sigmaGraph);
}, [graph, nodeById, setSigmaGraph, graphViewMode]);
}, [graph, graphMode, nodeById, setSigmaGraph, graphViewMode]);
// Update node visibility when filters change
useEffect(() => {
@ -256,6 +264,37 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
resetZoom();
}, [setSelectedNode, setSigmaSelectedNode, resetZoom]);
// Chat-only mode (#2178): the graph download was skipped. `chatOnlyNodeCount`
// comes from app state (captured at connect time), so it is authoritative and
// available immediately — not derived from the async `availableRepos` list.
const handleLoadGraphAnyway = useCallback(() => {
// Warn before re-triggering a potentially browser-hanging download. Confirm
// whenever the count is large OR unknown — never silently re-load a graph we
// can't size, which would risk re-introducing the original #2178 hang. Skip
// the prompt only when the count is known to be below the threshold (a small
// repo force-skipped via ?skipGraph=1).
const needsConfirm = shouldConfirmGraphLoad(chatOnlyNodeCount, LARGE_GRAPH_NODE_THRESHOLD);
if (needsConfirm) {
// Fail SAFE, not open: if there's no usable confirm dialog (some embedded
// webviews) or it throws, treat it as declined rather than loading a
// graph we couldn't warn about (#2178).
const canPrompt = typeof window !== 'undefined' && typeof window.confirm === 'function';
if (!canPrompt) return;
let confirmed = false;
try {
confirmed = window.confirm(
chatOnlyNodeCount != null
? t('canvas.chatOnly.loadAnywayWarning', { count: chatOnlyNodeCount.toLocaleString() })
: t('canvas.chatOnly.loadAnywayWarningUnknown'),
);
} catch {
return;
}
if (!confirmed) return;
}
void loadGraphAnyway();
}, [chatOnlyNodeCount, loadGraphAnyway, t]);
return (
<div className="relative h-full w-full bg-void">
{/* Background gradient */}
@ -324,6 +363,32 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
className="sigma-container h-full w-full cursor-grab active:cursor-grabbing"
/>
{/* Chat-only empty state (#2178): graph download was skipped for a large
project. Chat works normally; offer an explicit "load anyway" escape. */}
{graphMode === 'chatOnly' && (
<div className="absolute inset-0 z-20 flex items-center justify-center p-6">
<div className="max-w-md rounded-xl border border-border-subtle bg-elevated/95 p-6 text-center shadow-lg backdrop-blur-sm">
<h3 className="text-lg font-semibold text-text-primary">
{t('canvas.chatOnly.title')}
</h3>
<p className="mt-2 text-sm text-text-secondary">
{chatOnlyNodeCount != null
? t('canvas.chatOnly.descriptionWithCount', {
count: chatOnlyNodeCount.toLocaleString(),
})
: t('canvas.chatOnly.description')}
</p>
<p className="mt-2 text-xs text-text-muted">{t('canvas.chatOnly.citationNote')}</p>
<button
onClick={handleLoadGraphAnyway}
className="mt-4 rounded-md border border-accent/30 bg-accent/20 px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent/30"
>
{t('canvas.chatOnly.loadAnyway')}
</button>
</div>
</div>
)}
{/* Hovered node tooltip - only show when NOT selected */}
{hoveredNodeName && !sigmaSelectedNode && (
<div className="pointer-events-none absolute top-4 left-1/2 z-20 -translate-x-1/2 animate-fade-in rounded-lg border border-border-subtle bg-elevated/95 px-3 py-1.5 backdrop-blur-sm">

View file

@ -27,6 +27,7 @@ import { EmbeddingStatus } from './EmbeddingStatus';
import { RepoAnalyzer } from './RepoAnalyzer';
import { LanguageSwitcher } from './LanguageSwitcher';
import { translateProgressMessage } from '../i18n/progress';
import { formatBackendError } from '../i18n/error-messages';
// Color mapping for node types in search results
const NODE_TYPE_COLORS: Record<string, string> = {
@ -58,10 +59,11 @@ export const Header = ({
onAnalyzeComplete,
onReposChanged,
}: HeaderProps) => {
const { t } = useTranslation(['common', 'header']);
const { t } = useTranslation(['common', 'header', 'errors']);
const {
projectName,
graph,
graphMode,
openChatPanel,
isRightPanelOpen,
rightPanelTab,
@ -72,6 +74,7 @@ export const Header = ({
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
const [showAnalyzer, setShowAnalyzer] = useState(false);
const [reanalyzing, setReanalyzing] = useState<string | null>(null); // repo name being re-analyzed
const [deleteError, setDeleteError] = useState<string | null>(null); // surfaced when a delete is rejected (e.g. origin-blocked 403)
const [reanalyzeProgress, setReanalyzeProgress] = useState<JobProgress | null>(null);
const reanalyzeSseRef = useRef<AbortController | null>(null);
const repoDropdownRef = useRef<HTMLDivElement>(null);
@ -305,6 +308,7 @@ export const Header = ({
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
}
setDeleteError(null);
try {
await deleteRepo(repo.name);
const updated = await fetchRepos();
@ -317,7 +321,11 @@ export const Header = ({
window.location.reload();
}
} catch (err) {
// Surface the failure instead of silently no-opping —
// e.g. an origin-blocked 403 when driving a local
// backend from the hosted UI.
console.error('Failed to delete repo:', err);
setDeleteError(formatBackendError(err, t));
}
}}
className="cursor-pointer rounded p-1 text-text-muted/0 transition-all group-hover:text-text-muted hover:!text-red-400"
@ -330,6 +338,13 @@ export const Header = ({
</div>
)}
{/* Surfaced delete failure (e.g. origin-blocked 403) */}
{deleteError && (
<div className="px-3 py-2 text-xs text-red-400" role="alert">
{deleteError}
</div>
)}
{/* Re-analyze progress bar */}
{reanalyzing && reanalyzeProgress && (
<div className="border-t border-border-subtle bg-accent/5 px-4 py-2.5">
@ -453,8 +468,9 @@ export const Header = ({
<span className="hidden sm:inline">✨</span>
</a>
{/* Stats */}
{graph && (
{/* Stats — hidden in chat-only mode, where the empty-but-non-null graph
would otherwise show a misleading "0 nodes / 0 edges" (#2178). */}
{graph && graphMode !== 'chatOnly' && (
<div className="mr-2 flex items-center gap-4 text-xs text-text-muted">
<span>{t('common:counts.nodes', { count: nodeCount })}</span>
<span>{t('common:counts.edges', { count: edgeCount })}</span>

View file

@ -23,6 +23,7 @@ export const RightPanel = () => {
isRightPanelOpen,
setRightPanelOpen,
graph,
graphMode,
addCodeReference,
// LLM / chat state
chatMessages,
@ -283,6 +284,14 @@ export const RightPanel = () => {
</div>
</div>
{/* Chat-only notice: the graph wasn't loaded for this large project, so
inline node citations won't pin in the (absent) graph view (#2178). */}
{graphMode === 'chatOnly' && (
<div className="border-b border-amber-500/20 bg-amber-500/10 px-4 py-2 text-[11px] text-amber-200/90">
{t('chat:chatOnly.banner')}
</div>
)}
{/* Status / errors */}
{agentError && (
<div className="flex items-center gap-2 border-b border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
@ -292,7 +301,7 @@ export const RightPanel = () => {
)}
{/* Messages */}
<div ref={scrollContainerRef} className="scrollbar-thin flex-1 overflow-y-auto p-4">
<div ref={scrollContainerRef} className="flex-1 scrollbar-thin overflow-y-auto p-4">
{chatMessages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-node-interface text-2xl shadow-glow">
@ -417,7 +426,7 @@ export const RightPanel = () => {
onKeyDown={handleKeyDown}
placeholder={t('chat:input.placeholder')}
rows={1}
className="scrollbar-thin min-h-[36px] flex-1 resize-none border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
className="min-h-[36px] flex-1 resize-none scrollbar-thin border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
style={{ height: '36px', overflowY: 'hidden' }}
/>
<button

View file

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { translateProgressMessage } from '../i18n/progress';
export const StatusBar = () => {
const { graph, progress } = useAppState();
const { graph, graphMode, progress } = useAppState();
const { t } = useTranslation(['common', 'graph']);
const nodeCount = graph?.nodes.length ?? 0;
@ -68,7 +68,9 @@ export const StatusBar = () => {
{/* Right - Stats */}
<div className="flex items-center gap-3" data-testid="graph-stats">
{graph && (
{/* Suppress counts in chat-only mode: the empty-but-non-null graph would
otherwise show a misleading "0 nodes / 0 edges" for a large repo (#2178). */}
{graph && graphMode !== 'chatOnly' && (
<>
<span>{t('common:counts.nodes', { count: nodeCount })}</span>
<span className="text-border-default">•</span>

View file

@ -8,6 +8,40 @@ export const DEFAULT_BACKEND_URL =
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
/**
* Default node-count above which the WebUI connects in chat-only mode (skips
* the full graph download). Grounded in sigma.js/graphology prior art: ~10K
* nodes render smoothly, complex-styled rendering struggles past ~5K, and the
* force-layout degrades beyond ~50K edges. GitNexus renders labeled nodes with
* force layout and has ~1.7x more edges than nodes, so the edge cliff is crossed
* around ~25-30K nodes. Override at deploy time via
* window.__GITNEXUS_CONFIG__.largeGraphNodeThreshold. See issue #2178.
*/
const DEFAULT_LARGE_GRAPH_NODE_THRESHOLD = 25_000;
/**
* Default edge-count above which the WebUI connects in chat-only mode. The
* browser force-layout cliff is edge-driven (degrades beyond ~50K edges), and
* GitNexus graphs carry more edges than nodes, so an edge-heavy but node-light
* repo can still hang even when under the node threshold. Override via
* window.__GITNEXUS_CONFIG__.largeGraphEdgeThreshold. See issue #2178.
*/
const DEFAULT_LARGE_GRAPH_EDGE_THRESHOLD = 50_000;
const resolveThreshold = (override: number | undefined, fallback: number): number =>
// Ignore non-finite, NaN, or non-positive overrides — fall back to the default.
typeof override === 'number' && Number.isFinite(override) && override > 0 ? override : fallback;
export const LARGE_GRAPH_NODE_THRESHOLD = resolveThreshold(
typeof window !== 'undefined' ? window.__GITNEXUS_CONFIG__?.largeGraphNodeThreshold : undefined,
DEFAULT_LARGE_GRAPH_NODE_THRESHOLD,
);
export const LARGE_GRAPH_EDGE_THRESHOLD = resolveThreshold(
typeof window !== 'undefined' ? window.__GITNEXUS_CONFIG__?.largeGraphEdgeThreshold : undefined,
DEFAULT_LARGE_GRAPH_EDGE_THRESHOLD,
);
/** Minimum Node.js version required by the gitnexus CLI (injected by Vite from package.json engines). */
declare const __REQUIRED_NODE_VERSION__: string;
export const REQUIRED_NODE_VERSION = __REQUIRED_NODE_VERSION__;

View file

@ -33,7 +33,11 @@ import type {
AgentStreamChunk,
AgentHistoryMessage,
} from './types';
import { type CodebaseContext, buildDynamicSystemPrompt } from './context-builder';
import {
type CodebaseContext,
buildDynamicSystemPrompt,
CHAT_ONLY_PROMPT_NOTE,
} from './context-builder';
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
import {
DeepSeekChatOpenAI,
@ -357,14 +361,20 @@ export const createGraphRAGAgent = (
config: ProviderConfig,
backend: GraphRAGBackend,
codebaseContext?: CodebaseContext,
chatOnly = false,
) => {
const model = createChatModel(config);
const tools = createGraphRAGTools(backend);
// Use dynamic prompt if context is provided, otherwise use base prompt
// Use dynamic prompt if context is provided, otherwise use base prompt. The
// chat-only note (graph not loaded, #2178) must apply in BOTH branches — when
// codebaseContext is absent, buildDynamicSystemPrompt is never called, so
// append the note here too.
const systemPrompt = codebaseContext
? buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, codebaseContext)
: BASE_SYSTEM_PROMPT;
? buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, codebaseContext, chatOnly)
: chatOnly
? `${BASE_SYSTEM_PROMPT}${CHAT_ONLY_PROMPT_NOTE}`
: BASE_SYSTEM_PROMPT;
// Log the full prompt for debugging
if (import.meta.env.DEV) {

View file

@ -413,7 +413,27 @@ export function formatContextForPrompt(context: CodebaseContext): string {
* Build the complete dynamic system prompt
* Context is appended at the END so core instructions remain at the top
*/
export function buildDynamicSystemPrompt(basePrompt: string, context: CodebaseContext): string {
/**
* Note appended in chat-only mode (graph download skipped for a large project,
* #2178). It supersedes the static VISUAL GROUNDING section in BASE_SYSTEM_PROMPT
* so the agent stops claiming the user sees a graph or that node citations
* highlight — neither is true when the in-memory graph is empty.
*/
export const CHAT_ONLY_PROMPT_NOTE = `
---
## ⚠️ CHAT-ONLY MODE (graph not loaded)
The knowledge graph is NOT loaded in the UI for this project (it was too large to render). This OVERRIDES the VISUAL GROUNDING section above:
- \`[[Type:Name]]\` node citations will NOT highlight anything — avoid relying on them.
- Prefer \`[[path:START-END]]\` file citations, which still resolve and open the file.
- All your tools (search, cypher, grep, read) work normally against the backend; only the visual graph is absent.`;
export function buildDynamicSystemPrompt(
basePrompt: string,
context: CodebaseContext,
chatOnly = false,
): string {
const contextSection = formatContextForPrompt(context);
// Append context at the END - keeps core instructions at top for better adherence
@ -422,5 +442,5 @@ export function buildDynamicSystemPrompt(basePrompt: string, context: CodebaseCo
---
## 📦 CURRENT CODEBASE
${contextSection}`;
${contextSection}${chatOnly ? CHAT_ONLY_PROMPT_NOTE : ''}`;
}

View file

@ -19,4 +19,21 @@ describe('GraphState', () => {
});
expect(result.current.graphViewMode).toBe('tree');
});
it('should default graphMode to "full"', () => {
const { result } = renderHook(() => useGraphState(), { wrapper });
expect(result.current.graphMode).toBe('full');
});
it('should switch graphMode to "chatOnly" and back', () => {
const { result } = renderHook(() => useGraphState(), { wrapper });
act(() => {
result.current.setGraphMode('chatOnly');
});
expect(result.current.graphMode).toBe('chatOnly');
act(() => {
result.current.setGraphMode('full');
});
expect(result.current.graphMode).toBe('full');
});
});

View file

@ -2,6 +2,9 @@ import { createContext, useContext, useCallback, useMemo, useState, ReactNode }
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../core/graph/types';
import { DEFAULT_VISIBLE_LABELS, DEFAULT_VISIBLE_EDGES, type EdgeType } from '../../lib/constants';
import type { GraphMode } from '../../lib/apply-connect-result';
export type { GraphMode };
interface GraphStateContextValue {
graph: KnowledgeGraph | null;
@ -18,6 +21,22 @@ interface GraphStateContextValue {
setHighlightedNodeIds: (ids: Set<string>) => void;
graphViewMode: 'force' | 'tree' | 'circles';
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
/**
* Whether the in-memory graph was downloaded ('full') or skipped for a large
* project ('chatOnly'). In chat-only mode `graph` is an empty-but-non-null
* KnowledgeGraph so existing `graph?.` consumers keep working; this flag is
* the explicit signal that drives the chat-only empty-state UI. See #2178.
*/
graphMode: GraphMode;
setGraphMode: (mode: GraphMode) => void;
/**
* Node count of the connected repo when in chat-only mode (from the connect
* result's repo stats), or null when unknown. Used to size and gate the
* chat-only empty-state notice and its "load anyway" warning without waiting
* on the async `availableRepos` list. See #2178.
*/
chatOnlyNodeCount: number | null;
setChatOnlyNodeCount: (count: number | null) => void;
}
const GraphStateContext = createContext<GraphStateContextValue | null>(null);
@ -30,6 +49,8 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
const [depthFilter, setDepthFilter] = useState<number | null>(null);
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
const [graphViewMode, setGraphViewMode] = useState<'force' | 'tree' | 'circles'>('force');
const [graphMode, setGraphMode] = useState<GraphMode>('full');
const [chatOnlyNodeCount, setChatOnlyNodeCount] = useState<number | null>(null);
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
setVisibleLabels((prev) =>
@ -59,6 +80,10 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
setHighlightedNodeIds,
graphViewMode,
setGraphViewMode,
graphMode,
setGraphMode,
chatOnlyNodeCount,
setChatOnlyNodeCount,
}),
[
graph,
@ -68,6 +93,8 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
depthFilter,
highlightedNodeIds,
graphViewMode,
graphMode,
chatOnlyNodeCount,
],
);

View file

@ -10,7 +10,7 @@ import {
} from 'react';
import type { GraphNode, NodeLabel, PipelineProgress } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../core/graph/types';
import { createKnowledgeGraph } from '../core/graph/graph';
import { buildGraphFromConnectResult } from '../lib/apply-connect-result';
import type {
LLMSettings,
AgentStreamChunk,
@ -43,7 +43,7 @@ import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
import i18n from '../i18n';
import { normalizePath } from '../lib/path-resolution';
import { FILE_REF_REGEX, NODE_REF_REGEX } from '../lib/grounding-patterns';
import { GraphStateProvider, useGraphState } from './app-state/graph';
import { GraphStateProvider, useGraphState, type GraphMode } from './app-state/graph';
export const AUTO_START_EMBEDDINGS_STORAGE_KEY = 'gitnexus.autoStartEmbeddings';
@ -127,6 +127,13 @@ interface AppState {
graphViewMode: 'force' | 'tree' | 'circles';
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
// Graph load mode (full download vs chat-only / skipped graph)
graphMode: GraphMode;
setGraphMode: (mode: GraphMode) => void;
// Connected repo's node count while in chat-only mode (null when unknown)
chatOnlyNodeCount: number | null;
setChatOnlyNodeCount: (count: number | null) => void;
// Query state
highlightedNodeIds: Set<string>;
setHighlightedNodeIds: (ids: Set<string>) => void;
@ -163,6 +170,8 @@ interface AppState {
setAvailableRepos: (repos: BackendRepo[]) => void;
switchRepo: (repoName: string) => Promise<void>;
setCurrentRepo: (repoName: string) => void;
/** Download the full graph for the current repo after a chat-only connect (#2178). */
loadGraphAnyway: () => Promise<void>;
// Worker API (shared across app)
runQuery: (cypher: string) => Promise<any[]>;
@ -195,7 +204,7 @@ interface AppState {
// LLM methods
refreshLLMSettings: () => void;
initializeAgent: (overrideProjectName?: string) => Promise<void>;
initializeAgent: (overrideProjectName?: string, opts?: { chatOnly?: boolean }) => Promise<void>;
sendChatMessage: (message: string) => Promise<void>;
stopChatResponse: () => void;
clearChat: () => void;
@ -238,6 +247,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setHighlightedNodeIds,
graphViewMode,
setGraphViewMode,
graphMode,
setGraphMode,
chatOnlyNodeCount,
setChatOnlyNodeCount,
} = useGraphState();
// Right Panel
@ -591,13 +604,24 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const chatAbortRef = useRef<AbortController | null>(null);
const chatStateRef = useRef<'idle' | 'streaming' | 'aborting'>('idle');
// Mirror graphMode into a ref so initializeAgent's deferred callers (lazy chat
// init, settings-driven re-init) can read the current mode without re-creating
// the callback; connect-flow callers pass an explicit chatOnly flag. (#2178)
const graphModeRef = useRef(graphMode);
useEffect(() => {
graphModeRef.current = graphMode;
}, [graphMode]);
const initializeAgent = useCallback(
async (overrideProjectName?: string): Promise<void> => {
async (overrideProjectName?: string, opts?: { chatOnly?: boolean }): Promise<void> => {
const config = getActiveProviderConfig();
if (!config) {
setAgentError('Please configure an LLM provider in settings');
return;
}
// Explicit flag from connect-flow callers (race-safe); otherwise fall back
// to live mode via the ref so deferred callers stay correct too. (#2178)
const chatOnly = opts?.chatOnly ?? graphModeRef.current === 'chatOnly';
setIsAgentInitializing(true);
setAgentError(null);
@ -628,7 +652,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
backendReadFile(filePath, { repo }).then((r) => r.content),
};
agentRef.current = createGraphRAGAgent(config, backend, codebaseContext);
agentRef.current = createGraphRAGAgent(config, backend, codebaseContext, chatOnly);
setIsAgentReady(true);
setAgentError(null);
if (import.meta.env.DEV) {
@ -1153,9 +1177,15 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setCodeReferences([]);
setCodePanelOpen(false);
setCodeReferenceFocus(null);
// Reset graph-load mode up front so a FAILED switch can't leave the
// previous repo's stale chat-only overlay showing (#2178). The success
// path re-derives the mode from the connect result below.
setGraphMode('full');
setChatOnlyNodeCount(null);
let connectedRepo: BackendRepo | undefined;
let pNameStr = repoName || 'server-project';
let connectedChatOnly = false;
try {
const result: ConnectResult = await connectToServer(
@ -1205,10 +1235,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
connectedRepo = result.repoInfo;
pNameStr = pName;
const newGraph = createKnowledgeGraph();
for (const node of result.nodes) newGraph.addNode(node);
for (const rel of result.relationships) newGraph.addRelationship(rel);
setGraph(newGraph);
// In chat-only mode the graph download was skipped; the shared builder
// keeps an empty (but non-null) graph so existing `graph?.` consumers
// stay happy, and reports the mode + node count in lockstep.
const built = buildGraphFromConnectResult(result);
setGraph(built.graph);
setGraphMode(built.graphMode);
setChatOnlyNodeCount(built.graphMode === 'chatOnly' ? built.nodeCount : null);
connectedChatOnly = built.graphMode === 'chatOnly';
} catch (err: unknown) {
console.error('Repo switch failed:', err);
setProgress({
@ -1227,9 +1261,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
}
if (pNameStr) {
// Persist the selected project in the URL so a refresh re-opens it
// Persist the selected project in the URL so a refresh re-opens it.
// Drop any `?skipGraph` override: a deliberate repo switch should make a
// fresh per-repo decision (auto-detect) on the next refresh rather than
// carry the previous repo's forced mode (#2178).
const urlObj = new URL(window.location.href);
urlObj.searchParams.set('project', pNameStr);
urlObj.searchParams.delete('skipGraph');
window.history.replaceState(null, '', urlObj.toString());
}
@ -1241,7 +1279,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Re-initialize agent with the new repo's graph context
try {
if (getActiveProviderConfig()) {
await initializeAgent(pNameStr);
await initializeAgent(pNameStr, { chatOnly: connectedChatOnly });
}
setViewMode('exploring');
startEmbeddingsWithFallback();
@ -1261,6 +1299,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setViewMode,
setProjectName,
setGraph,
setGraphMode,
setChatOnlyNodeCount,
initializeAgent,
startEmbeddingsWithFallback,
setHighlightedNodeIds,
@ -1276,6 +1316,102 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
],
);
// Load the full graph for the current repo after a chat-only connection.
// This is the escape hatch behind the chat-only empty state (#2178). It
// forces `skipGraph: false` so the size-based auto-detect cannot re-skip it.
// The override is session-scoped (deliberately NOT persisted to the URL): a
// persisted `?skipGraph=0` would leak onto a different repo via the other
// connect entry points and could silently re-trigger the hang on refresh.
const loadGraphInFlightRef = useRef(false);
// Cancels the in-flight load-anyway download; mountedRef gates post-await
// state writes so an unmount mid-download can't setState on a dead instance.
const loadGraphAbortRef = useRef<AbortController | null>(null);
const loadGraphMountedRef = useRef(true);
useEffect(() => {
return () => {
loadGraphMountedRef.current = false;
loadGraphAbortRef.current?.abort();
};
}, []);
const loadGraphAnyway = useCallback(async (): Promise<void> => {
if (!serverBaseUrl) return;
// Guard against a double-trigger (rapid double-click or a racing
// programmatic call) starting two concurrent full-graph downloads.
if (loadGraphInFlightRef.current) return;
loadGraphInFlightRef.current = true;
const repo = repoRef.current;
const controller = new AbortController();
loadGraphAbortRef.current = controller;
setProgress({
phase: 'extracting',
percent: 0,
message: i18n.t('common:progress.downloadingGraph'),
detail: i18n.t('common:progress.validating'),
});
setViewMode('loading');
try {
const result = await connectToServer(
serverBaseUrl,
(phase, downloaded, total) => {
if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({
phase: 'extracting',
percent: pct,
message: i18n.t('common:progress.downloadingGraph'),
detail: i18n.t('common:progress.downloadedMb', { mb }),
});
}
},
controller.signal,
repo,
{ awaitAnalysis: true, skipGraph: false },
);
// Bail if we unmounted, or if a concurrent switchRepo changed the active
// repo while this load was in flight (the late result must not clobber the
// new repo's state). Guard keyed on the ref — an abort surfaces as a
// BackendError, not a DOMException AbortError.
if (!loadGraphMountedRef.current || repoRef.current !== repo) return;
const built = buildGraphFromConnectResult(result);
setGraph(built.graph);
setGraphMode(built.graphMode);
// Full download succeeded → leave chat-only mode; clear the cached count.
setChatOnlyNodeCount(built.graphMode === 'chatOnly' ? built.nodeCount : null);
setProgress(null);
setViewMode('exploring');
// The graph is now loaded — re-init the agent so its system prompt drops
// the chat-only note (#2178, KTD2). Guarded on a configured provider, like
// switchRepo; runs inside the mounted/stale guard above.
if (getActiveProviderConfig()) {
await initializeAgent(repo, { chatOnly: false });
}
} catch (err) {
if (!loadGraphMountedRef.current || repoRef.current !== repo) return;
console.error('Load graph anyway failed:', err);
// Stay in chat-only mode (the overlay reappears) and return to the view.
setProgress(null);
setViewMode('exploring');
} finally {
if (loadGraphAbortRef.current === controller) loadGraphAbortRef.current = null;
loadGraphInFlightRef.current = false;
}
}, [
serverBaseUrl,
setProgress,
setViewMode,
setGraph,
setGraphMode,
setChatOnlyNodeCount,
initializeAgent,
]);
const removeCodeReference = useCallback(
(id: string) => {
setCodeReferences((prev) => {
@ -1334,6 +1470,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setDepthFilter,
graphViewMode,
setGraphViewMode,
graphMode,
setGraphMode,
chatOnlyNodeCount,
setChatOnlyNodeCount,
highlightedNodeIds,
setHighlightedNodeIds,
aiCitationHighlightedNodeIds,
@ -1362,6 +1502,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setAvailableRepos,
switchRepo,
setCurrentRepo,
loadGraphAnyway,
runQuery,
isDatabaseReady,
// Embedding state and methods

View file

@ -14,6 +14,8 @@ export function formatBackendError(error: unknown, t: TFunction): string {
return t('errors:backend.rateLimited', { seconds, defaultValue: fallback });
case 'not_found':
return t('errors:backend.notFound', { defaultValue: fallback });
case 'origin_blocked':
return t('errors:backend.originBlocked', { defaultValue: fallback });
case 'client':
return t('errors:backend.client', { message: error.message, defaultValue: fallback });
case 'server':

View file

@ -0,0 +1,46 @@
import type { ConnectResult } from '../services/backend-client';
import type { KnowledgeGraph } from '../core/graph/types';
import { createKnowledgeGraph } from '../core/graph/graph';
/**
* Whether the in-memory graph was downloaded ('full') or skipped for a large
* project ('chatOnly'). Defined here (not in the graph state slice) so the
* shared connect-result builder and the state slice agree on one source. In
* chat-only mode `graph` is an empty-but-non-null KnowledgeGraph so existing
* `graph?.` consumers keep working; the flag drives the chat-only UI. See #2178.
*/
export type GraphMode = 'full' | 'chatOnly';
export interface BuiltGraph {
graph: KnowledgeGraph;
graphMode: GraphMode;
/**
* Node count for the connected repo (from `repoInfo.stats.nodes`), or null
* when the backend did not report it. Captured here at connect time so the
* chat-only notice and its size warning have an authoritative value that does
* not depend on the async `availableRepos` list having loaded yet.
*/
nodeCount: number | null;
}
/**
* Build the in-memory KnowledgeGraph from a connect result and derive the
* graph mode + node count. In chat-only mode (`graphSkipped`) the node/relation
* loops are skipped, leaving an empty-but-non-null graph.
*
* Shared by every connect entry point — App.handleServerConnect, switchRepo,
* and loadGraphAnyway — so the build, the mode flag, and the node count stay in
* lockstep instead of drifting across three near-identical copies. See #2178.
*/
export function buildGraphFromConnectResult(result: ConnectResult): BuiltGraph {
const graph = createKnowledgeGraph();
if (!result.graphSkipped) {
for (const node of result.nodes) graph.addNode(node);
for (const rel of result.relationships) graph.addRelationship(rel);
}
return {
graph,
graphMode: result.graphSkipped ? 'chatOnly' : 'full',
nodeCount: result.repoInfo.stats?.nodes ?? null,
};
}

View file

@ -0,0 +1,84 @@
/**
* Pure decision logic for the WebUI's chat-only / skip-graph connection mode
* (issue #2178). Kept free of React and network concerns so it can be unit
* tested directly and reused at every connect entry point.
*
* The WebUI hangs on very large projects because the connect flow downloads the
* entire knowledge graph into memory. The AI chat does not need that graph (it
* calls the backend HTTP API directly), so we skip the download when the user
* asked for chat-only mode or when the project is large enough to auto-detect.
*/
export interface SkipGraphDecisionInput {
/**
* Explicit user/URL choice, if any. `true` forces chat-only, `false` forces a
* full graph download, `undefined` defers to auto-detection by size.
*/
explicit: boolean | undefined;
/** Node count reported by the backend (`repoInfo.stats.nodes`), if known. */
nodeCount: number | null | undefined;
/** Node auto-detect threshold (LARGE_GRAPH_NODE_THRESHOLD). */
threshold: number;
/** Edge count reported by the backend (`repoInfo.stats.edges`), if known. */
edgeCount?: number | null | undefined;
/** Edge auto-detect threshold (LARGE_GRAPH_EDGE_THRESHOLD). */
edgeThreshold?: number;
}
const isOver = (count: number | null | undefined, threshold: number | undefined): boolean =>
typeof threshold === 'number' &&
typeof count === 'number' &&
Number.isFinite(count) &&
count > threshold;
/**
* Decide whether to skip the graph download.
*
* - An explicit boolean choice always wins (override in both directions).
* - Otherwise auto-detect: skip when EITHER the node count OR the edge count is
* known and strictly greater than its threshold. Edges matter because the
* browser force-layout cliff is edge-driven and GitNexus graphs carry more
* edges than nodes — an edge-heavy but node-light repo can still hang.
* - Missing/unknown counts fail open to a full download (we never skip purely
* because we couldn't read the size).
*/
export function decideSkipGraph({
explicit,
nodeCount,
threshold,
edgeCount,
edgeThreshold,
}: SkipGraphDecisionInput): boolean {
if (typeof explicit === 'boolean') return explicit;
return isOver(nodeCount, threshold) || isOver(edgeCount, edgeThreshold);
}
/**
* Whether to prompt for confirmation before loading the full graph from the
* chat-only escape hatch ("Load graph anyway"). Confirm whenever the node count
* is large OR unknown — never silently re-load a graph we cannot size, which
* would risk re-introducing the original browser hang (#2178). Skip the prompt
* only when the count is known to be at or below the threshold (a small repo
* that was force-skipped via `?skipGraph=1`).
*/
export function shouldConfirmGraphLoad(
nodeCount: number | null | undefined,
threshold: number,
): boolean {
if (typeof nodeCount !== 'number' || !Number.isFinite(nodeCount)) return true;
return nodeCount > threshold;
}
/**
* Parse the `?skipGraph` URL parameter into the tri-state used by
* {@link decideSkipGraph}. Accepts `1`/`true` (chat-only) and `0`/`false`
* (full graph), case-insensitively. Anything else — including a missing
* parameter — yields `undefined` (auto-detect).
*/
export function parseSkipGraphParam(value: string | null | undefined): boolean | undefined {
if (value == null) return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === '1' || normalized === 'true') return true;
if (normalized === '0' || normalized === 'false') return false;
return undefined;
}

View file

@ -29,6 +29,9 @@
"configureAI": "Configure AI",
"connecting": "Connecting"
},
"chatOnly": {
"banner": "Graph not loaded (large project). Chat works normally; inline node citations won't highlight in the graph view."
},
"roles": {
"you": "You",
"assistant": "Nexus AI"

View file

@ -13,6 +13,7 @@
"timeout": "The server took too long to respond. Try again in a moment.",
"rateLimited": "Too many requests. Try again in {{seconds}}s.",
"notFound": "The requested repository or resource was not found.",
"originBlocked": "This action isn't available from the hosted UI. Open GitNexus from the server's own address (e.g. http://localhost:4747) to continue.",
"client": "Request failed: {{message}}",
"server": "Server error: {{message}}"
}

View file

@ -129,7 +129,16 @@
"runLayout": "Run Layout Again",
"layoutOptimizing": "Layout optimizing...",
"turnOffHighlights": "Turn off all highlights",
"turnOnHighlights": "Turn on AI highlights"
"turnOnHighlights": "Turn on AI highlights",
"chatOnly": {
"title": "Graph not loaded",
"description": "This is a large project, so the graph was skipped to keep the browser responsive. AI chat works normally.",
"descriptionWithCount": "This project has {{count}} nodes, so the graph was skipped to keep the browser responsive. AI chat works normally.",
"citationNote": "While the graph is unloaded, inline file citations from chat won't auto-open in the Code panel.",
"loadAnyway": "Load graph anyway",
"loadAnywayWarning": "This project has {{count}} nodes. Loading the full graph may make the browser slow or unresponsive. Continue?",
"loadAnywayWarningUnknown": "This may be a large project. Loading the full graph may make the browser slow or unresponsive. Continue?"
}
},
"processes": {
"unknownStep": "Unknown",

View file

@ -29,6 +29,9 @@
"configureAI": "配置 AI",
"connecting": "连接中"
},
"chatOnly": {
"banner": "图谱未加载(大型项目)。对话功能正常;内联节点引用不会在图谱视图中高亮。"
},
"roles": {
"you": "你",
"assistant": "Nexus AI"

View file

@ -13,6 +13,7 @@
"timeout": "服务器响应超时,请稍后重试。",
"rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。",
"notFound": "未找到请求的仓库或资源。",
"originBlocked": "此操作无法从托管界面执行。请通过服务器自身地址(例如 http://localhost:4747)打开 GitNexus 后再继续。",
"client": "请求失败:{{message}}",
"server": "服务器错误:{{message}}"
}

View file

@ -129,7 +129,16 @@
"runLayout": "重新运行布局",
"layoutOptimizing": "正在优化布局...",
"turnOffHighlights": "关闭全部高亮",
"turnOnHighlights": "开启 AI 高亮"
"turnOnHighlights": "开启 AI 高亮",
"chatOnly": {
"title": "图谱未加载",
"description": "这是一个大型项目,已跳过图谱加载以保持浏览器响应。AI 对话功能正常可用。",
"descriptionWithCount": "该项目包含 {{count}} 个节点,已跳过图谱加载以保持浏览器响应。AI 对话功能正常可用。",
"citationNote": "图谱未加载时,对话中的内联文件引用不会自动在代码面板中打开。",
"loadAnyway": "仍然加载图谱",
"loadAnywayWarning": "该项目包含 {{count}} 个节点。加载完整图谱可能导致浏览器变慢或无响应。是否继续?",
"loadAnywayWarningUnknown": "这可能是一个大型项目。加载完整图谱可能导致浏览器变慢或无响应。是否继续?"
}
},
"processes": {
"unknownStep": "未知",

View file

@ -8,6 +8,8 @@
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
import { LARGE_GRAPH_NODE_THRESHOLD, LARGE_GRAPH_EDGE_THRESHOLD } from '../config/ui-constants';
import { decideSkipGraph } from '../lib/graph-load-decision';
// ── Types ──────────────────────────────────────────────────────────────────
@ -79,7 +81,11 @@ export class BackendError extends Error {
| 'client'
| 'not_found'
| 'timeout'
| 'rate_limited',
| 'rate_limited'
// The write-route same-host Origin guard rejected this request (HTTP 403
// with `{ code: 'origin_not_allowed' }`). Distinct from a generic `client`
// 403 so the UI can show actionable "open the local UI" guidance.
| 'origin_blocked',
/**
* Milliseconds until the caller should retry. Populated for rate-limited
* responses (HTTP 429) from the server's `Retry-After` header. `undefined`
@ -92,6 +98,24 @@ export class BackendError extends Error {
}
}
/**
* Thrown by the graph stream parser when the streamed node/relationship count
* crosses the size limit mid-download (#2178). It is the backstop for the case
* pre-fetch stats can't cover (absent/stale `stats.nodes`/`stats.edges` on a
* genuinely large repo). `connectToServer` catches it and falls into chat-only
* mode instead of letting the full graph hang the browser.
*/
export class GraphTooLargeError extends Error {
constructor(
message: string,
public readonly nodeCount: number,
public readonly relationshipCount: number,
) {
super(message);
this.name = 'GraphTooLargeError';
}
}
// ── SSE Utility ────────────────────────────────────────────────────────────
export interface SSEHandlers<T = unknown> {
@ -361,6 +385,7 @@ const assertOk = async (response: Response): Promise<void> => {
if (response.ok) return;
let message = response.statusText;
let bodyCode: string | undefined;
try {
const body = await response.json();
if (body && typeof body.error === 'string') {
@ -368,6 +393,9 @@ const assertOk = async (response: Response): Promise<void> => {
} else if (body && typeof body.message === 'string') {
message = body.message;
}
if (body && typeof body.code === 'string') {
bodyCode = body.code;
}
} catch {
// Response body was not JSON
}
@ -377,9 +405,13 @@ const assertOk = async (response: Response): Promise<void> => {
? 'not_found'
: response.status === 429
? 'rate_limited'
: response.status >= 400 && response.status < 500
? 'client'
: 'server';
: // The write-route Origin guard returns 403 with this discriminator;
// surface it as a distinct code so the UI can give actionable guidance.
bodyCode === 'origin_not_allowed'
? 'origin_blocked'
: response.status >= 400 && response.status < 500
? 'client'
: 'server';
// Retry-After is the standard HTTP signal for when the client may try again.
// express-rate-limit emits it on 429 with seconds (integer) or HTTP-date.
@ -527,13 +559,18 @@ export const fetchRepoInfo = async (
return { ...data, repoPath: data.repoPath ?? data.path };
};
/** Fetch the graph (nodes + relationships). Content stripped by default. */
/** Fetch the graph (nodes + relationships). Content stripped by default.
* `maxNodes`/`maxEdges` arm a streaming circuit breaker (#2178): if the streamed
* count crosses either limit, the download aborts with a GraphTooLargeError
* instead of materializing a graph that would hang the browser. Off by default. */
export const fetchGraph = async (
repo?: string,
opts?: {
includeContent?: boolean;
signal?: AbortSignal;
onProgress?: (downloaded: number, total: number | null) => void;
maxNodes?: number;
maxEdges?: number;
},
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : '', 'stream=true']
@ -546,7 +583,7 @@ export const fetchGraph = async (
const contentType = response.headers.get('Content-Type') || '';
if (contentType.includes('application/x-ndjson')) {
return parseNdjsonGraphResponse(response, opts?.onProgress);
return parseNdjsonGraphResponse(response, opts?.onProgress, opts?.maxNodes, opts?.maxEdges);
}
if (!opts?.onProgress || !response.body) {
@ -580,6 +617,8 @@ export const fetchGraph = async (
const parseNdjsonGraphResponse = async (
response: Response,
onProgress?: (downloaded: number, total: number | null) => void,
maxNodes?: number,
maxEdges?: number,
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
if (!response.body) {
throw new BackendError('No response body', response.status, 'server');
@ -594,6 +633,14 @@ const parseNdjsonGraphResponse = async (
let buffer = '';
let downloaded = 0;
// Streaming circuit breaker (#2178): enforce the size limits mid-download as a
// backstop when pre-fetch stats were missing. Same `> threshold` comparison as
// decideSkipGraph. Throwing immediately after the offending push means a later
// error record in the same chunk is never reached — the breaker wins.
const overLimit = (): boolean =>
(typeof maxNodes === 'number' && nodes.length > maxNodes) ||
(typeof maxEdges === 'number' && relationships.length > maxEdges);
const parseLine = (line: string) => {
const trimmed = line.trim();
if (!trimmed) return;
@ -616,6 +663,20 @@ const parseNdjsonGraphResponse = async (
}
};
const tripBreaker = async () => {
// Free the socket promptly; never let a cancel rejection mask the breaker.
try {
await reader.cancel();
} catch {
// ignore — we're aborting anyway
}
throw new GraphTooLargeError(
`Graph exceeds the size limit (nodes=${nodes.length}, relationships=${relationships.length})`,
nodes.length,
relationships.length,
);
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
@ -628,11 +689,13 @@ const parseNdjsonGraphResponse = async (
buffer = lines.pop() || '';
for (const line of lines) {
parseLine(line);
if (overLimit()) await tripBreaker();
}
}
buffer += decoder.decode();
parseLine(buffer);
if (overLimit()) await tripBreaker();
return { nodes, relationships };
};
@ -892,6 +955,14 @@ export interface ConnectResult {
nodes: GraphNode[];
relationships: GraphRelationship[];
repoInfo: BackendRepo;
/**
* True when the graph download was skipped (chat-only mode) — either because
* the caller asked for it or because the project exceeded the auto-detect
* node threshold. When true, `nodes`/`relationships` are empty and graph
* visualization is unavailable, but AI chat and all backend-API features
* work normally. See issue #2178.
*/
graphSkipped: boolean;
}
/**
@ -899,13 +970,15 @@ export interface ConnectResult {
* Content is NOT included (use readFile/grep for file access).
* Pass `awaitAnalysis: true` when the repo may still be cloning/analyzing —
* this enables the backend hold-queue and a 5-minute fetch timeout.
* Pass `skipGraph: true`/`false` to force chat-only / full-graph mode; omit it
* to auto-detect from the project's node count (LARGE_GRAPH_NODE_THRESHOLD).
*/
export async function connectToServer(
url: string,
onProgress?: (phase: string, downloaded: number, total: number | null) => void,
signal?: AbortSignal,
repoName?: string,
opts?: { awaitAnalysis?: boolean },
opts?: { awaitAnalysis?: boolean; skipGraph?: boolean },
): Promise<ConnectResult> {
const baseUrl = normalizeServerUrl(url);
setBackendUrl(baseUrl);
@ -913,11 +986,45 @@ export async function connectToServer(
onProgress?.('validating', 0, null);
const repoInfo = await fetchRepoInfo(repoName, { awaitAnalysis: opts?.awaitAnalysis });
onProgress?.('downloading', 0, null);
const { nodes, relationships } = await fetchGraph(repoName, {
signal,
onProgress: (downloaded, total) => onProgress?.('downloading', downloaded, total),
// Decide whether to skip the (potentially huge) graph download. The AI chat
// talks to the backend HTTP API directly and does not need the in-memory
// graph, so for large projects — or when the caller explicitly asked for
// chat-only mode — we connect instantly without materializing the graph.
// repoInfo is already fetched above, so the node-count check costs no extra
// round-trip. See issue #2178.
const skipGraph = decideSkipGraph({
explicit: opts?.skipGraph,
nodeCount: repoInfo.stats?.nodes,
threshold: LARGE_GRAPH_NODE_THRESHOLD,
edgeCount: repoInfo.stats?.edges,
edgeThreshold: LARGE_GRAPH_EDGE_THRESHOLD,
});
return { nodes, relationships, repoInfo };
if (skipGraph) {
return { nodes: [], relationships: [], repoInfo, graphSkipped: true };
}
// Arm the streaming circuit breaker for auto-detect downloads as a backstop
// for the no-stats fail-open case (#2178). An explicit "load anyway"
// (skipGraph === false) opts out — the user has accepted the cost.
const enforceLimits = opts?.skipGraph !== false;
onProgress?.('downloading', 0, null);
try {
const { nodes, relationships } = await fetchGraph(repoName, {
signal,
onProgress: (downloaded, total) => onProgress?.('downloading', downloaded, total),
maxNodes: enforceLimits ? LARGE_GRAPH_NODE_THRESHOLD : undefined,
maxEdges: enforceLimits ? LARGE_GRAPH_EDGE_THRESHOLD : undefined,
});
return { nodes, relationships, repoInfo, graphSkipped: false };
} catch (err) {
// The breaker tripped mid-stream → fall into chat-only, the same result the
// pre-fetch skip path produces. Re-throw every other error (genuine
// BackendErrors must still surface to the caller's catch).
if (err instanceof GraphTooLargeError) {
return { nodes: [], relationships: [], repoInfo, graphSkipped: true };
}
throw err;
}
}

View file

@ -3,5 +3,19 @@
interface Window {
__GITNEXUS_CONFIG__?: {
backendUrl?: string;
/**
* Node-count above which the WebUI connects in chat-only mode by default
* (skips the full graph download to avoid hanging the browser on very
* large projects). Override at deploy time; falls back to
* LARGE_GRAPH_NODE_THRESHOLD in config/ui-constants.ts. See issue #2178.
*/
largeGraphNodeThreshold?: number;
/**
* Edge-count above which the WebUI connects in chat-only mode by default.
* The browser force-layout cliff is edge-driven, so this guards edge-heavy
* repos that fall under the node threshold. Falls back to
* LARGE_GRAPH_EDGE_THRESHOLD in config/ui-constants.ts. See issue #2178.
*/
largeGraphEdgeThreshold?: number;
};
}

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { BASE_SYSTEM_PROMPT } from '../../src/core/llm/agent';
import { buildDynamicSystemPrompt, type CodebaseContext } from '../../src/core/llm/context-builder';
import {
createGraphRAGTools,
GRAPH_RAG_TOOL_NAMES,
@ -7,6 +8,19 @@ import {
} from '../../src/core/llm/tools';
import { NODE_REF_REGEX } from '../../src/lib/grounding-patterns';
const MINIMAL_CONTEXT: CodebaseContext = {
stats: {
projectName: 'proj',
fileCount: 0,
functionCount: 0,
classCount: 0,
interfaceCount: 0,
methodCount: 0,
},
hotspots: [],
folderTree: '',
};
/** Legacy or phantom tool names that must not appear in the system prompt. */
const FORBIDDEN_TOOL_NAMES = [
'hybrid_search',
@ -80,3 +94,19 @@ describe('BASE_SYSTEM_PROMPT tool parity', () => {
expect(BASE_SYSTEM_PROMPT).not.toMatch(/\b(?:use|call|invoke)\s+`?highlight_in_graph/i);
});
});
describe('buildDynamicSystemPrompt chat-only mode (#2178)', () => {
it('appends a chat-only note that overrides VISUAL GROUNDING when chatOnly', () => {
const prompt = buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, MINIMAL_CONTEXT, true);
expect(prompt).toContain('CHAT-ONLY MODE');
expect(prompt).toMatch(/node citations will NOT highlight/i);
expect(prompt).toContain('[[path:START-END]]');
});
it('leaves the prompt unchanged when chatOnly is false/omitted', () => {
const full = buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, MINIMAL_CONTEXT);
const explicitFalse = buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, MINIMAL_CONTEXT, false);
expect(full).toBe(explicitFalse);
expect(full).not.toContain('CHAT-ONLY MODE');
});
});

View file

@ -13,7 +13,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getBreaker } from 'gitnexus-shared';
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
import { fetchRepos, setBackendUrl, startAnalyze } from '../../src/services/backend-client';
import {
deleteRepo,
fetchRepos,
setBackendUrl,
startAnalyze,
} from '../../src/services/backend-client';
const BASE = 'http://localhost:4747';
@ -89,6 +94,40 @@ describe('backend-client retry budget (method-aware)', () => {
expect(getBreaker(bKey).getConsecutiveFailures()).toBe(0);
});
it('maps an origin-blocked 403 to BackendError code "origin_blocked"', async () => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
error: 'This endpoint is restricted to same-host origins',
code: 'origin_not_allowed',
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
await expect(deleteRepo('my-repo')).rejects.toMatchObject({
status: 403,
code: 'origin_blocked',
});
// 403 is a terminal client error — never retried.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('maps a generic 403 (no recognized code) to BackendError code "client" (back-compat)', async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ error: 'forbidden' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await expect(deleteRepo('my-repo')).rejects.toMatchObject({ status: 403, code: 'client' });
});
it('breaker not incremented when timeout fires (TimeoutError, not AbortError)', async () => {
// Reject directly with a TimeoutError DOMException, mimicking what
// `fetch` produces when its `AbortSignal.timeout()`-wired signal

View file

@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import {
decideSkipGraph,
parseSkipGraphParam,
shouldConfirmGraphLoad,
} from '../../src/lib/graph-load-decision';
const THRESHOLD = 25_000;
const EDGE_THRESHOLD = 50_000;
describe('decideSkipGraph', () => {
it('auto-detects: skips when node count exceeds the threshold', () => {
expect(decideSkipGraph({ explicit: undefined, nodeCount: 300_000, threshold: THRESHOLD })).toBe(
true,
);
});
it('auto-detects: keeps the full graph for small projects', () => {
expect(decideSkipGraph({ explicit: undefined, nodeCount: 500, threshold: THRESHOLD })).toBe(
false,
);
});
it('explicit choice overrides auto-detection in both directions', () => {
// Force chat-only even for a tiny repo.
expect(decideSkipGraph({ explicit: true, nodeCount: 10, threshold: THRESHOLD })).toBe(true);
// Force a full graph even for a huge repo.
expect(decideSkipGraph({ explicit: false, nodeCount: 999_999, threshold: THRESHOLD })).toBe(
false,
);
});
it('uses strictly-greater comparison at the threshold boundary', () => {
expect(
decideSkipGraph({ explicit: undefined, nodeCount: THRESHOLD, threshold: THRESHOLD }),
).toBe(false);
expect(
decideSkipGraph({ explicit: undefined, nodeCount: THRESHOLD + 1, threshold: THRESHOLD }),
).toBe(true);
});
it('fails open to a full download when the node count is unknown', () => {
expect(
decideSkipGraph({ explicit: undefined, nodeCount: undefined, threshold: THRESHOLD }),
).toBe(false);
expect(decideSkipGraph({ explicit: undefined, nodeCount: null, threshold: THRESHOLD })).toBe(
false,
);
expect(decideSkipGraph({ explicit: undefined, nodeCount: NaN, threshold: THRESHOLD })).toBe(
false,
);
});
it('skips on the edge count even when nodes are under the node threshold', () => {
// Edge-heavy, node-light repo: 20K nodes (< 25K) but 80K edges (> 50K).
expect(
decideSkipGraph({
explicit: undefined,
nodeCount: 20_000,
threshold: THRESHOLD,
edgeCount: 80_000,
edgeThreshold: EDGE_THRESHOLD,
}),
).toBe(true);
});
it('does not skip when both node and edge counts are under their thresholds', () => {
expect(
decideSkipGraph({
explicit: undefined,
nodeCount: 5_000,
threshold: THRESHOLD,
edgeCount: 10_000,
edgeThreshold: EDGE_THRESHOLD,
}),
).toBe(false);
});
it('explicit choice overrides the edge auto-detect too', () => {
expect(
decideSkipGraph({
explicit: false,
nodeCount: 1,
threshold: THRESHOLD,
edgeCount: 999_999,
edgeThreshold: EDGE_THRESHOLD,
}),
).toBe(false);
});
it('fails open when edge count is unknown and nodes are under threshold', () => {
expect(
decideSkipGraph({
explicit: undefined,
nodeCount: 5_000,
threshold: THRESHOLD,
edgeCount: undefined,
edgeThreshold: EDGE_THRESHOLD,
}),
).toBe(false);
});
});
describe('parseSkipGraphParam', () => {
it('parses affirmative values to true', () => {
expect(parseSkipGraphParam('1')).toBe(true);
expect(parseSkipGraphParam('true')).toBe(true);
expect(parseSkipGraphParam('TRUE')).toBe(true);
expect(parseSkipGraphParam(' true ')).toBe(true);
});
it('parses negative values to false', () => {
expect(parseSkipGraphParam('0')).toBe(false);
expect(parseSkipGraphParam('false')).toBe(false);
expect(parseSkipGraphParam('False')).toBe(false);
});
it('returns undefined for missing or unrecognized values', () => {
expect(parseSkipGraphParam(null)).toBeUndefined();
expect(parseSkipGraphParam(undefined)).toBeUndefined();
expect(parseSkipGraphParam('')).toBeUndefined();
expect(parseSkipGraphParam('yes')).toBeUndefined();
expect(parseSkipGraphParam('2')).toBeUndefined();
});
});
describe('shouldConfirmGraphLoad', () => {
it('confirms for a large repo', () => {
expect(shouldConfirmGraphLoad(300_000, THRESHOLD)).toBe(true);
expect(shouldConfirmGraphLoad(THRESHOLD + 1, THRESHOLD)).toBe(true);
});
it('does NOT confirm for a small repo at or below the threshold', () => {
expect(shouldConfirmGraphLoad(500, THRESHOLD)).toBe(false);
expect(shouldConfirmGraphLoad(THRESHOLD, THRESHOLD)).toBe(false);
});
it('confirms (fail-safe) when the node count is unknown', () => {
// The key regression guard: an unknown count must NOT silently re-load,
// which would risk re-introducing the #2178 hang.
expect(shouldConfirmGraphLoad(null, THRESHOLD)).toBe(true);
expect(shouldConfirmGraphLoad(undefined, THRESHOLD)).toBe(true);
expect(shouldConfirmGraphLoad(NaN, THRESHOLD)).toBe(true);
});
});

View file

@ -0,0 +1,242 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { AppStateProvider, useAppState } from '../../src/hooks/useAppState';
afterEach(() => {
vi.restoreAllMocks();
// Reset the URL mutated by loadGraphAnyway's persistence.
window.history.replaceState(null, '', '/');
});
const repoInfoResponse = () =>
new Response(
JSON.stringify({
name: 'big-repo',
path: '/r/big-repo',
repoPath: '/r/big-repo',
indexedAt: '2026-06-13T00:00:00Z',
stats: { nodes: 300_000, edges: 600_000 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
const graphNdjsonResponse = () => {
const body =
'{"type":"node","data":{"id":"File:a.ts","label":"File","properties":{"name":"a.ts","filePath":"a.ts"}}}\n' +
'{"type":"relationship","data":{"id":"r1","type":"CONTAINS","sourceId":"File:a.ts","targetId":"File:a.ts"}}\n';
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'application/x-ndjson' },
});
};
describe('loadGraphAnyway (chat-only escape hatch, #2178)', () => {
it('forces a full graph download and flips graphMode back to full', async () => {
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
if (url.includes('/api/graph')) return Promise.resolve(graphNdjsonResponse());
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setServerBaseUrl('http://localhost:4747');
result.current.setCurrentRepo('big-repo');
result.current.setGraphMode('chatOnly');
});
await act(async () => {
await result.current.loadGraphAnyway();
});
// Despite the 300K node count, skipGraph:false forces the download.
expect(result.current.graphMode).toBe('full');
expect(result.current.graph?.nodeCount).toBe(1);
const graphCalls = fetchMock.mock.calls.filter(([u]) => String(u).includes('/api/graph'));
expect(graphCalls.length).toBeGreaterThan(0);
// The override is session-scoped — deliberately NOT persisted to the URL, so
// it cannot leak onto a different repo or re-trigger the hang on F5 (#2178).
expect(window.location.search).not.toContain('skipGraph');
});
it('no-ops when there is no server connection', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
await act(async () => {
await result.current.loadGraphAnyway();
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('guards against a concurrent double-invocation (only one download)', async () => {
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
if (url.includes('/api/graph')) return Promise.resolve(graphNdjsonResponse());
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setServerBaseUrl('http://localhost:4747');
result.current.setCurrentRepo('big-repo');
result.current.setGraphMode('chatOnly');
});
await act(async () => {
// Fire twice synchronously — the second call must be dropped by the guard.
const a = result.current.loadGraphAnyway();
const b = result.current.loadGraphAnyway();
await Promise.all([a, b]);
});
const graphCalls = fetchMock.mock.calls.filter(([u]) => String(u).includes('/api/graph'));
expect(graphCalls).toHaveLength(1);
});
it('stays in chat-only mode when the full-graph download fails', async () => {
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
if (url.includes('/api/graph'))
return Promise.resolve(new Response('{"error":"boom"}', { status: 500 }));
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setServerBaseUrl('http://localhost:4747');
result.current.setCurrentRepo('big-repo');
result.current.setGraphMode('chatOnly');
});
await act(async () => {
await result.current.loadGraphAnyway();
});
// Failure leaves the user in chat-only mode (overlay reappears), view restored.
expect(result.current.graphMode).toBe('chatOnly');
expect(result.current.viewMode).toBe('exploring');
expect(window.location.search).not.toContain('skipGraph=0');
});
it('discards a stale result when the active repo changed mid-load', async () => {
let resolveGraph: (r: Response) => void = () => {};
const graphPromise = new Promise<Response>((res) => {
resolveGraph = res;
});
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
if (url.includes('/api/graph')) return graphPromise;
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setServerBaseUrl('http://localhost:4747');
result.current.setCurrentRepo('repo-A');
result.current.setGraphMode('chatOnly');
});
let loadPromise: Promise<void> = Promise.resolve();
act(() => {
loadPromise = result.current.loadGraphAnyway(); // captures repo-A
});
// A concurrent switch changes the active repo while the load is in flight.
act(() => {
result.current.setCurrentRepo('repo-B');
});
await act(async () => {
resolveGraph(graphNdjsonResponse());
await loadPromise;
});
// The stale repo-A result must NOT flip the (now repo-B) view to full.
expect(result.current.graphMode).toBe('chatOnly');
});
it('does not throw or apply state when unmounted mid-load', async () => {
let resolveGraph: (r: Response) => void = () => {};
const graphPromise = new Promise<Response>((res) => {
resolveGraph = res;
});
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
if (url.includes('/api/graph')) return graphPromise;
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const { result, unmount } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setServerBaseUrl('http://localhost:4747');
result.current.setCurrentRepo('big-repo');
result.current.setGraphMode('chatOnly');
});
let loadPromise: Promise<void> = Promise.resolve();
act(() => {
loadPromise = result.current.loadGraphAnyway();
});
unmount(); // fires cleanup: mountedRef=false + abort
await act(async () => {
resolveGraph(graphNdjsonResponse());
await loadPromise; // resolves without setState-after-unmount throwing
});
});
});
describe('switchRepo auto-detect (chat-only, #2178)', () => {
afterEach(() => {
vi.restoreAllMocks();
window.history.replaceState(null, '', '/');
});
it('enters chat-only mode and captures the node count for a large repo', async () => {
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoInfoResponse());
if (url.includes('/api/repos'))
return Promise.resolve(
new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
if (url.includes('/api/graph')) return Promise.resolve(graphNdjsonResponse());
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useAppState(), { wrapper: AppStateProvider });
act(() => {
result.current.setServerBaseUrl('http://localhost:4747');
});
await act(async () => {
await result.current.switchRepo('big-repo');
});
// 300K nodes > threshold → auto-skip, empty graph, count captured, no graph download.
expect(result.current.graphMode).toBe('chatOnly');
expect(result.current.graph?.nodeCount).toBe(0);
expect(result.current.chatOnlyNodeCount).toBe(300_000);
const graphCalls = fetchMock.mock.calls.filter(([u]) => String(u).includes('/api/graph'));
expect(graphCalls).toHaveLength(0);
});
});

View file

@ -1,12 +1,34 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
connectToServer,
fetchGraph,
getBackendUrl,
GraphTooLargeError,
normalizeServerUrl,
setBackendUrl,
validateBackendUrl,
} from '../../src/services/backend-client';
// ── NDJSON stream helpers for the U3 circuit-breaker tests ──
const ndjsonStream = (lines: string[]): ReadableStream<Uint8Array> => {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
start(controller) {
for (const l of lines) controller.enqueue(encoder.encode(l));
controller.close();
},
});
};
const ndjsonResponse = (lines: string[]): Response =>
new Response(ndjsonStream(lines), {
status: 200,
headers: { 'Content-Type': 'application/x-ndjson' },
});
const nodeLine = (i: number): string =>
`{"type":"node","data":{"id":"n${i}","label":"Function","properties":{"name":"f${i}"}}}\n`;
const relLine = (i: number): string =>
`{"type":"relationship","data":{"id":"r${i}","type":"CALLS","sourceId":"n0","targetId":"n${i}"}}\n`;
describe('normalizeServerUrl', () => {
it('adds http:// to localhost', () => {
expect(normalizeServerUrl('localhost:4747')).toBe('http://localhost:4747');
@ -172,6 +194,151 @@ describe('fetchGraph', () => {
});
});
describe('connectToServer skipGraph (chat-only mode)', () => {
const repoInfo = (nodes: number | undefined) => ({
name: 'big-repo',
path: '/repos/big-repo',
repoPath: '/repos/big-repo',
indexedAt: '2026-06-13T00:00:00Z',
...(nodes !== undefined ? { stats: { nodes, edges: nodes * 2 } } : {}),
});
// Routes /api/repo to the repo info and /api/graph to the supplied handler;
// any other path returns an empty 200 so the breaker stays closed.
const makeFetchMock = (nodes: number | undefined) => {
const graphHandler = vi.fn(
() =>
new Response('{"nodes":[],"relationships":[]}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) {
return Promise.resolve(
new Response(JSON.stringify(repoInfo(nodes)), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
}
if (url.includes('/api/graph')) {
return Promise.resolve(graphHandler());
}
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
return { fetchMock, graphHandler };
};
const graphRequests = (fetchMock: ReturnType<typeof vi.fn>) =>
fetchMock.mock.calls.filter(([u]: unknown[]) => String(u).includes('/api/graph'));
it('skips the graph download when skipGraph is true (even for a tiny repo)', async () => {
const { fetchMock } = makeFetchMock(5);
vi.stubGlobal('fetch', fetchMock);
const result = await connectToServer(
'http://localhost:4747',
undefined,
undefined,
'big-repo',
{
skipGraph: true,
},
);
expect(result.graphSkipped).toBe(true);
expect(result.nodes).toEqual([]);
expect(result.relationships).toEqual([]);
expect(result.repoInfo.name).toBe('big-repo');
expect(graphRequests(fetchMock)).toHaveLength(0);
});
it('downloads the graph when skipGraph is false (even for a huge repo)', async () => {
const { fetchMock } = makeFetchMock(300_000);
vi.stubGlobal('fetch', fetchMock);
const result = await connectToServer(
'http://localhost:4747',
undefined,
undefined,
'big-repo',
{
skipGraph: false,
},
);
expect(result.graphSkipped).toBe(false);
expect(graphRequests(fetchMock).length).toBeGreaterThan(0);
});
it('auto-detects a large project and skips the graph (no explicit flag)', async () => {
const { fetchMock } = makeFetchMock(300_000);
vi.stubGlobal('fetch', fetchMock);
const result = await connectToServer('http://localhost:4747', undefined, undefined, 'big-repo');
expect(result.graphSkipped).toBe(true);
expect(graphRequests(fetchMock)).toHaveLength(0);
});
it('downloads the graph for a small project (no explicit flag)', async () => {
const { fetchMock } = makeFetchMock(500);
vi.stubGlobal('fetch', fetchMock);
const result = await connectToServer('http://localhost:4747', undefined, undefined, 'big-repo');
expect(result.graphSkipped).toBe(false);
expect(graphRequests(fetchMock).length).toBeGreaterThan(0);
});
it('fails open to a full download when node stats are missing', async () => {
const { fetchMock } = makeFetchMock(undefined);
vi.stubGlobal('fetch', fetchMock);
const result = await connectToServer('http://localhost:4747', undefined, undefined, 'big-repo');
expect(result.graphSkipped).toBe(false);
expect(graphRequests(fetchMock).length).toBeGreaterThan(0);
});
it('auto-detects an edge-heavy repo (nodes under, edges over the threshold)', async () => {
// 10K nodes (< 25K node threshold) but 80K edges (> 50K edge threshold).
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) {
return Promise.resolve(
new Response(
JSON.stringify({
name: 'edgy-repo',
path: '/repos/edgy-repo',
repoPath: '/repos/edgy-repo',
indexedAt: '2026-06-13T00:00:00Z',
stats: { nodes: 10_000, edges: 80_000 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
}
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const result = await connectToServer(
'http://localhost:4747',
undefined,
undefined,
'edgy-repo',
);
expect(result.graphSkipped).toBe(true);
expect(fetchMock.mock.calls.filter(([u]) => String(u).includes('/api/graph'))).toHaveLength(0);
});
});
describe('DEFAULT_BACKEND_URL resolution', () => {
afterEach(() => {
delete window.__GITNEXUS_CONFIG__;
@ -203,6 +370,34 @@ describe('DEFAULT_BACKEND_URL resolution', () => {
});
});
describe('LARGE_GRAPH_NODE_THRESHOLD resolution', () => {
afterEach(() => {
delete window.__GITNEXUS_CONFIG__;
vi.resetModules();
});
it('defaults to 25000 when no config is injected', async () => {
delete window.__GITNEXUS_CONFIG__;
const { LARGE_GRAPH_NODE_THRESHOLD } = await import('../../src/config/ui-constants');
expect(LARGE_GRAPH_NODE_THRESHOLD).toBe(25_000);
});
it('uses a valid positive override', async () => {
window.__GITNEXUS_CONFIG__ = { largeGraphNodeThreshold: 100_000 };
const { LARGE_GRAPH_NODE_THRESHOLD } = await import('../../src/config/ui-constants');
expect(LARGE_GRAPH_NODE_THRESHOLD).toBe(100_000);
});
it('ignores NaN, zero, and negative overrides (falls back to default)', async () => {
for (const bad of [NaN, 0, -10]) {
window.__GITNEXUS_CONFIG__ = { largeGraphNodeThreshold: bad };
vi.resetModules();
const { LARGE_GRAPH_NODE_THRESHOLD } = await import('../../src/config/ui-constants');
expect(LARGE_GRAPH_NODE_THRESHOLD, `override=${bad}`).toBe(25_000);
}
});
});
describe('validateBackendUrl', () => {
it('allows http:// URLs', () => {
expect(() => validateBackendUrl('http://localhost:4747')).not.toThrow();
@ -259,3 +454,122 @@ describe('setBackendUrl', () => {
expect(getBackendUrl()).toBe('http://localhost:4747');
});
});
describe('fetchGraph streaming size breaker (#2178)', () => {
it('throws GraphTooLargeError when node count exceeds maxNodes', async () => {
setBackendUrl('http://localhost:4747');
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(ndjsonResponse([nodeLine(0), nodeLine(1), nodeLine(2)])),
);
await expect(fetchGraph('repo', { maxNodes: 2 })).rejects.toBeInstanceOf(GraphTooLargeError);
});
it('completes when node count is at or below maxNodes (== not >)', async () => {
setBackendUrl('http://localhost:4747');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ndjsonResponse([nodeLine(0), nodeLine(1)])));
const result = await fetchGraph('repo', { maxNodes: 2 });
expect(result.nodes).toHaveLength(2);
});
it('trips on the edge counter for a node-light stream', async () => {
setBackendUrl('http://localhost:4747');
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(ndjsonResponse([nodeLine(0), relLine(1), relLine(2), relLine(3)])),
);
await expect(fetchGraph('repo', { maxNodes: 1000, maxEdges: 2 })).rejects.toBeInstanceOf(
GraphTooLargeError,
);
});
it('never trips when no limits are passed (default behavior unchanged)', async () => {
setBackendUrl('http://localhost:4747');
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(ndjsonResponse([nodeLine(0), nodeLine(1), nodeLine(2), relLine(3)])),
);
const result = await fetchGraph('repo');
expect(result.nodes).toHaveLength(3);
expect(result.relationships).toHaveLength(1);
});
it('breaker wins over a later error record in the same stream', async () => {
setBackendUrl('http://localhost:4747');
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
ndjsonResponse([
nodeLine(0),
nodeLine(1),
nodeLine(2),
'{"type":"error","error":"late boom"}\n',
]),
),
);
await expect(fetchGraph('repo', { maxNodes: 2 })).rejects.toBeInstanceOf(GraphTooLargeError);
});
});
describe('connectToServer streaming breaker (no-stats fail-open backstop, #2178)', () => {
afterEach(() => {
delete window.__GITNEXUS_CONFIG__;
vi.resetModules();
});
// Re-import with a tiny threshold so a 3-record stream exercises the breaker.
const setupTinyThreshold = async () => {
window.__GITNEXUS_CONFIG__ = { largeGraphNodeThreshold: 2, largeGraphEdgeThreshold: 2 };
vi.resetModules();
const mod = await import('../../src/services/backend-client');
mod.setBackendUrl('http://localhost:4747');
return mod;
};
const repoNoStats = () =>
new Response(
JSON.stringify({ name: 'r', path: '/r', repoPath: '/r', indexedAt: '2026-06-13T00:00:00Z' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
it('falls into chat-only when an auto-detect stream exceeds the threshold (absent stats)', async () => {
const { connectToServer: connect } = await setupTinyThreshold();
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoNoStats());
if (url.includes('/api/graph'))
return Promise.resolve(ndjsonResponse([nodeLine(0), nodeLine(1), nodeLine(2)]));
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const result = await connect('http://localhost:4747', undefined, undefined, 'r');
expect(result.graphSkipped).toBe(true);
expect(result.nodes).toEqual([]);
expect(result.relationships).toEqual([]);
});
it('does NOT enforce the breaker for an explicit load-anyway (skipGraph:false)', async () => {
const { connectToServer: connect } = await setupTinyThreshold();
const fetchMock = vi.fn((url: string) => {
if (url.includes('/api/repo')) return Promise.resolve(repoNoStats());
if (url.includes('/api/graph'))
return Promise.resolve(ndjsonResponse([nodeLine(0), nodeLine(1), nodeLine(2)]));
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
});
vi.stubGlobal('fetch', fetchMock);
const result = await connect('http://localhost:4747', undefined, undefined, 'r', {
skipGraph: false,
});
expect(result.graphSkipped).toBe(false);
expect(result.nodes).toHaveLength(3);
});
});

View file

@ -34,7 +34,7 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`.
### Editor Support
@ -134,7 +134,7 @@ Your AI agent gets these tools automatically:
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
| `cypher` | Raw Cypher graph queries | Optional |
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({query: "auth", repo: "my-app"})`.
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`.
## MCP Resources
@ -158,7 +158,7 @@ Your AI agent gets these tools automatically:
## CLI Commands
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus setup # Configure MCP for detected editors (one-time; use -c to select)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data

View file

@ -9,20 +9,20 @@
"_note": "#2081 M1 / #2082 M2: ONE function, N coalescing statements (extendBlock text accumulation + per-statement fact harvest). Runs at 2000->8000. M2 REWROTE the old 'output is constant 4 blocks' note: statement facts make disk/heap LINEAR in N (a free gate on the harvest payload); TIME still guards the concat path (array-join ~1.0; a genuine O(n^2) re-join accumulation is ~3.8). M2 adds rd_scaling_budget (measured ~0.74) and disk_bytes_large_max -- an ABSOLUTE ceiling ~1.35x the measured indexed-encoding bytes (969,986 at N=8000, ~121 B/stmt); a named-record encoding regression (~4x facts bytes) blows it. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change (the canon now includes statements+bindings)."
},
"many-functions": {
"fingerprint": "f3bcc5e6ef4cf58aefe4e7d801a8fea0215494b9688833e501c2afc6df029c1b",
"fingerprint": "d881f60e77f0262bdc1b5c7049aa4acf5071e0eabc536476be293c3a133e626e",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2081 M1 / #2082 M2: N small branchy functions (collect walk + per-function build + per-function solve). Time ~1.0, disk ~1.01, heap ~1.0, rd ~0.86 (solver is per-function; N functions scale linearly)."
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: N small branchy functions (collect walk + per-function build + per-function solve). Time ~1.0, disk ~1.01, heap ~1.0, rd ~0.86 (solver is per-function; N functions scale linearly). M3 U1 re-fingerprinted: taint sites join StatementFacts (a()/b() call sites); disk_large 2565641->2721641 (+6.1% measured site-harvest cost at N=2000)."
},
"branchy": {
"fingerprint": "5b5886521ab21604df8f78af98c8c28a6be8e64c24f3d67b165c2d96ba2a3d52",
"fingerprint": "936765bba5c3f8fc7058737c48351e03e4e1da7fed448467e8fcc8a0fb7786ce",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2081 M1 / #2082 M2: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (noisiest scenario; budget 1.8 absorbs noise, catches ~4.0 quadratic), disk ~1.03, heap ~1.0, rd ~0.7."
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (noisiest scenario; budget 1.8 absorbs noise, catches ~4.0 quadratic), disk ~1.03, heap ~1.0, rd ~0.7. M3 U1 re-fingerprinted (s{i}() call sites); disk_large 908964->993854 (+9.3%)."
},
"dense-bindings": {
"fingerprint": "e4d7eb3c7e8b3772423af25cef391e0e6b68067b554819e81b543439a487403f",
@ -33,12 +33,25 @@
"_note": "#2082 M2: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The overlay design measures rd ~5.2 normalized: the OUT spine copy on genning blocks is O(V) per block, which is quadratic when V scales with B (bounded in prod by maxFunctionLines; real functions have V~10-40). Budget 10 deliberately tolerates that known shape and exists to catch the repo's recurring per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16). If rd drops well below 5, tighten."
},
"fact-fanout": {
"fingerprint": "488e63e072d514a9229e21872615e32c7b099ccbd65ec8c045ba517568fd3e5d",
"fingerprint": "83a8243a8aff117f69aeecb39d02a483e6cca70439d75f63e433f4e4ac85578f",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 3.0,
"facts_large_max": 16000,
"_note": "#2082 M2: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically."
"_note": "#2082 M2 / #2083 M3 U1: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically. M3 U1 re-fingerprinted (u{i}(x) call sites); disk_large 996737->1107627 (+11.1%)."
},
"taint-dense": {
"fingerprint": "218a1a0c7e092550c233607c67daa401543a25bf8d3f122899d30cd9c30c3a89",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"disk_bytes_large_max": 3150000,
"taint_findings_per_fn_pin": 8,
"taint_scaling_budget": 2.0,
"taint_reason_bytes_large_max": 198000,
"taint_zero_match_budget": 0.5,
"_note": "#2083 M3 U7 (R10): N functions, each with 12 req.body sources + a 4-hop chain + 13 eval sinks (13 deduped findings/fn) at 125->500 fns; the zero-match control (inp.payload/evalish) keeps the identical CFG shape with zero model hits. BOUNDEDNESS pin: kept findings/function == 8 (the scenario cap) at BOTH sizes -- above means the cap was lost, below means detection regressed; total findings grow linearly with N by design. disk_bytes_large_max is the LOAD-BEARING site-harvest absolute ceiling (densest sites of the suite; measured 2335772 at N=500, ceiling ~1.35x). taint_reason_bytes_large_max caps the persisted TAINTED reason bytes (measured 146827 = ~37 B/finding, ceiling ~1.35x; blows on hop-encoding bloat or cap loss). taint_zero_match_budget 0.5 vs measured 0.15: the zero-match pass (match gate only, no solver) must stay a small fraction of the match-dense pass. taint scaling measured ~0.93 (per-function work is N-linear); time/disk/heap/rd ratios all ~1.0."
}
}

View file

@ -48,6 +48,13 @@ import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../../src/core/ingestion/cfg/emit.ts';
import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.ts';
import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts';
import { buildTaintImportIndex, matchFunctionSites } from '../../src/core/ingestion/taint/match.ts';
import { TS_JS_TAINT_MODEL } from '../../src/core/ingestion/taint/typescript-model.ts';
import {
computeTaintFlows,
DEFAULT_PDG_MAX_TAINT_HOPS,
} from '../../src/core/ingestion/taint/propagate.ts';
import { encodeTaintPath } from '../../src/core/ingestion/taint/path-codec.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
@ -141,8 +148,51 @@ const SCENARIOS = [
return s + '}\n';
},
},
{
name: 'taint-dense',
// #2083 M3 U7 (R10): N functions, EACH source/sink-dense — 12 matched
// `req.body` source statements + a 4-hop chained reassignment + 13 `eval`
// sinks per function (13 deduped findings/fn, ABOVE the scenario cap of 8
// so the cap binds). Functions scale with N, so total findings grow
// linearly BY DESIGN; the boundedness gate is the per-function pin: kept
// findings/function stays EXACTLY at the cap as N grows (a cap loss shows
// as 13). This scenario's sites are the densest of the suite, so its
// ABSOLUTE disk_bytes_large_max is the load-bearing site-harvest ceiling
// (the M2 straight-line carrier has no call sites), and the summed
// encoded TAINTED reason bytes get their own absolute ceiling
// (taint_reason_bytes_large_max). The zero-match control (genZero) keeps
// the identical statement/CFG shape with names OUTSIDE the model
// (inp.payload / evalish) — the match-gate must make unmatched functions
// cost ~nothing (no solver call), gated as zero-time/dense-time ratio.
small: 125,
large: 500, // 4x, like the global sizes — per-fn bodies are ~30 lines
taint: { cap: 8 },
gen: (n) => genTaintFunctions(n, false),
genZero: (n) => genTaintFunctions(n, true),
},
];
// taint-dense generator: `zero` swaps every model-matched name for an
// unmatched one without changing statement count, def/use shape, or CFG.
const TAINT_SOURCES_PER_FN = 12;
const TAINT_CHAIN_HOPS = 4;
function genTaintFunctions(n, zero) {
const recv = zero ? 'inp' : 'req';
const prop = zero ? 'payload' : 'body';
const sink = zero ? 'evalish' : 'eval';
let s = '';
for (let i = 0; i < n; i++) {
s += `function f${i}(${recv}) {\n`;
for (let j = 0; j < TAINT_SOURCES_PER_FN; j++) s += ` const s${j} = ${recv}.${prop};\n`;
s += ` let c0 = s0 + '!';\n`;
for (let h = 1; h < TAINT_CHAIN_HOPS; h++) s += ` const c${h} = c${h - 1} + '!';\n`;
for (let j = 0; j < TAINT_SOURCES_PER_FN; j++) s += ` ${sink}(s${j});\n`;
s += ` ${sink}(c${TAINT_CHAIN_HOPS - 1});\n`;
s += '}\n';
}
return s;
}
const SMALL = 500;
const LARGE = 2000; // 4× — O(n) ⇒ ratio ~1, O(n²) ⇒ ratio ~4
const REPS = 15; // median over more reps → stabler time signal at small absolute ms
@ -199,6 +249,57 @@ function measureReachingDefs(cfgs, reps, maxFacts) {
return { ms: median(samples), facts };
}
// ---- taint pass cost (#2083 M3 U7) ----
// Times the EXACT per-function sequence the in-phase emit driver runs on a
// --pdg run for a taint-modeled language: match sites → zero-match fast path
// → computeReachingDefs → computeTaintFlows. `cap` is the scenario's
// maxFindingsPerFunction (deliberately small so the cap BINDS on the dense
// generator). Also sums the encoded TAINTED `reason` bytes for the kept
// findings — the persisted-taint disk posture (R10).
function measureTaint(cfgs, reps, cap) {
const importIndex = buildTaintImportIndex([]); // bench callees are globals
const pass = () => {
let analyzed = 0;
let kept = 0;
let dropped = 0;
let reasonBytes = 0;
for (const c of cfgs) {
const matches = matchFunctionSites(c, TS_JS_TAINT_MODEL, importIndex);
if (!matches.hasSource || !matches.hasSink) continue;
const du = computeReachingDefs(c, {
maxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
});
const flows = computeTaintFlows(c, du, matches, {
maxFindingsPerFunction: cap,
maxHops: DEFAULT_PDG_MAX_TAINT_HOPS,
});
if (flows.status !== 'computed') continue;
analyzed++;
kept += flows.findings.length;
dropped += flows.droppedFindings;
for (const f of flows.findings) {
// All structural chars + identifier names are single-byte ASCII, so
// string length IS the byte length (path-codec discipline).
reasonBytes += encodeTaintPath(
f.hops.map((h) => ({ name: h.name, line: h.point.line, viaCall: h.viaCall })),
{ truncated: f.hopsTruncated === true, kind: f.sinkKind },
).reason.length;
}
}
return { analyzed, kept, dropped, reasonBytes };
};
pass(); // warm JIT (uncounted)
const samples = [];
let out;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
out = pass();
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return { ms: median(samples), ...out };
}
// ---- memory growth: retained heap of the cfgSideChannel payload ----
// Needs `node --expose-gc` to force collection for a clean delta; without it the
@ -279,7 +380,40 @@ function measureScenario(scenario) {
// ratio 0 and the gate would self-disable exactly when the solver is fast.
const rdRatio = rdLarge.ms / Math.max(rdSmall.ms, 0.001) / sizeRatio;
// #2083 M3 U7: taint pass cost + boundedness on taint-bearing scenarios.
let taintMetrics = {};
if (scenario.taint !== undefined) {
const cap = scenario.taint.cap;
const tSmall = measureTaint(small.cfgs, REPS, cap);
const tLarge = measureTaint(large.cfgs, REPS, cap);
const tRatio = tLarge.ms / Math.max(tSmall.ms, 0.001) / sizeRatio;
// Zero-match control: identical CFG shape, no model hits — measures the
// match-gate overhead unmatched functions pay on a real --pdg repo.
const zeroCfgs = collectFunctionCfgs(
parse(scenario.genZero(nLarge)).rootNode,
visitor,
`${scenario.name}-zero.ts`,
NO_CAP,
).cfgs;
const tZero = measureTaint(zeroCfgs, REPS, cap);
taintMetrics = {
taint_ms_small: Number(tSmall.ms.toFixed(3)),
taint_ms_large: Number(tLarge.ms.toFixed(3)),
taint_scaling_ratio: Number(tRatio.toFixed(3)),
// Boundedness: kept findings PER ANALYZED FUNCTION (total findings grow
// linearly with N by design — the per-function pin is the cap gate).
taint_findings_per_fn_small: tSmall.analyzed > 0 ? tSmall.kept / tSmall.analyzed : 0,
taint_findings_per_fn_large: tLarge.analyzed > 0 ? tLarge.kept / tLarge.analyzed : 0,
taint_dropped_large: tLarge.dropped,
taint_reason_bytes_large: tLarge.reasonBytes,
taint_zero_ms_large: Number(tZero.ms.toFixed(3)),
taint_zero_findings: tZero.kept + tZero.dropped,
taint_zero_match_ratio: Number((tZero.ms / Math.max(tLarge.ms, 0.001)).toFixed(3)),
};
}
return {
...taintMetrics,
scenario: scenario.name,
elapsed_ms_small: Number(small.ms.toFixed(3)),
elapsed_ms_large: Number(large.ms.toFixed(3)),
@ -367,6 +501,56 @@ if (!CHECK) {
`${base.disk_bytes_large_max} bytes (constant-factor encoding bloat)`,
);
}
// #2083 M3 U7 gates — taint boundedness (per-function findings pinned at
// the cap as N grows), an ABSOLUTE ceiling on persisted TAINTED reason
// bytes, taint solve-time scaling, and the zero-match fast path staying
// ~free relative to the match-dense pass.
if (base.taint_findings_per_fn_pin !== undefined) {
for (const side of ['small', 'large']) {
const perFn = r[`taint_findings_per_fn_${side}`];
if (perFn !== base.taint_findings_per_fn_pin) {
failures.push(
`${r.scenario}: taint findings/function (${side}) ${perFn} != pin ` +
`${base.taint_findings_per_fn_pin} (cap must BIND exactly: above = cap lost, ` +
`below = detection regressed)`,
);
}
}
if (r.taint_zero_findings !== 0) {
failures.push(
`${r.scenario}: zero-match control produced ${r.taint_zero_findings} findings ` +
`(the control must not match the model — generator drift)`,
);
}
}
if (
base.taint_reason_bytes_large_max !== undefined &&
r.taint_reason_bytes_large > base.taint_reason_bytes_large_max
) {
failures.push(
`${r.scenario}: persisted TAINTED reason bytes ${r.taint_reason_bytes_large} > ceiling ` +
`${base.taint_reason_bytes_large_max} (hop-encoding bloat or cap loss)`,
);
}
if (
base.taint_scaling_budget !== undefined &&
r.taint_scaling_ratio >= base.taint_scaling_budget
) {
failures.push(
`${r.scenario}: taint scaling ratio ${r.taint_scaling_ratio} >= budget ` +
`${base.taint_scaling_budget} (ms ${r.taint_ms_small}->${r.taint_ms_large})`,
);
}
if (
base.taint_zero_match_budget !== undefined &&
r.taint_zero_match_ratio >= base.taint_zero_match_budget
) {
failures.push(
`${r.scenario}: zero-match taint time is ${r.taint_zero_match_ratio} of the match-dense ` +
`pass, >= budget ${base.taint_zero_match_budget} (the match gate must keep unmatched ` +
`functions ~free — no solver call)`,
);
}
// Heap gate only when measured (--expose-gc present) AND a budget exists.
if (
base.heap_budget !== undefined &&

View file

@ -24,7 +24,10 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.cjs');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
const {
hasGitNexusDbLockedByGitNexusServer,
resolveUnixGuardTimeout,
} = require('./hook-db-lock-probe.cjs');
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
function readInput() {
@ -198,10 +201,73 @@ function resolveCliPath() {
return cliPath;
}
// Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
// at most one line per (short-lived) hook process, even if a future change
// runs the CLI more than once.
let unguardedCliWarned = false;
/**
* Unix orphan containment (#2163 follow-up): the augment CLI is the
* longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
* npx), so on Unix it gets the same SIGKILL-surviving coreutils `timeout`
* wrapper as the probe's lsof/ps. The wrapper budget is ceil(inner/1000)+1
* seconds — STRICTLY greater than the inner spawnSync timeout, so on the
* supervised path Node's SIGTERM always fires first and the existing
* error/status contract is untouched. Once the hook itself has been
* SIGKILLed (exactly the orphan case the wrapper exists for), the guard
* semantics differ per branch:
* - direct exec (the CLI is the guard's CHILD): `-k 1` TERM-first — a
* SIGTERM-immune CLI can hold the guard ~1s past the inner timeout
* before the `-k` SIGKILL escalation reaps it.
* - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
* budget expiry SIGKILLs the whole process group outright. TERM-first
* would kill only the obedient npx parent, making `timeout` reap it and
* return before the `-k` escalation ever fires, stranding a
* SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
* 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
* `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
* on this branch, both bounded by "no worse than pre-fix" (where the
* grandchild received no signal at all): the group-wide SIGKILL is
* coreutils semantics — a busybox `timeout` passes the self-test (it
* has `-k` and propagates exit status) but signals only its direct
* child, so a busybox guard cannot reach the grandchild; and on the
* SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
* guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
* and the guard exits before any KILL fires — so a SIGTERM-immune CLI
* grandchild still escapes in those two cases.
* If the sibling probe predates the resolveUnixGuardTimeout export (version
* skew), the adapter degrades to the unwrapped invocation instead of
* throwing. Windows is deliberately NOT wrapped — there is no coreutils
* timeout to resolve there and the resolver's self-test spawns /bin/sh — so
* on win32 (the npx.cmd path) and whenever the guard resolves to null (e.g.
* macOS without Homebrew coreutils — reported once under GITNEXUS_DEBUG)
* the argv stays byte-identical to the pre-wrap invocation.
*/
function runGitNexusCli(cliPath, args, cwd, timeout) {
const isWin = process.platform === 'win32';
// Version-skew guard (#2163 follow-up review): an older sibling probe
// without the resolveUnixGuardTimeout export must degrade to the unwrapped
// invocation — a TypeError here would be swallowed by the caller's catch
// and silently kill the augment.
const guard =
isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
// Diagnose the "stays unwrapped" Unix paths once per hook process: no
// usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
// coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
unguardedCliWarned = true;
process.stderr.write(
'[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
);
}
if (cliPath) {
return spawnSync(process.execPath, [cliPath, ...args], {
const [cmd, cmdArgs] = guard
? [
guard,
['-k', '1', String(Math.ceil(timeout / 1000) + 1), process.execPath, cliPath, ...args],
]
: [process.execPath, [cliPath, ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout,
cwd,
@ -209,7 +275,27 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
windowsHide: true,
});
}
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
// A non-null guard implies non-Windows, so the wrapped arm can hardcode
// plain `npx`. The wrapped arm leads with `-s KILL` (NOT TERM-first like
// the direct branch above): the CLI here is a grandchild behind npx — see
// the docblock.
const [cmd, cmdArgs] = guard
? [
guard,
[
'-s',
'KILL',
'-k',
'1',
String(Math.ceil((timeout + 5000) / 1000) + 1),
'npx',
'-y',
'gitnexus',
...args,
],
]
: [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,

View file

@ -15,7 +15,10 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.cjs');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
const {
hasGitNexusDbLockedByGitNexusServer,
resolveUnixGuardTimeout,
} = require('./hook-db-lock-probe.cjs');
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
/**
@ -218,14 +221,76 @@ function resolveCliPath() {
return cliPath;
}
// Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
// at most one line per (short-lived) hook process, even if a future change
// runs the CLI more than once.
let unguardedCliWarned = false;
/**
* Spawn a gitnexus CLI command synchronously.
* Returns the stderr output (KuzuDB captures stdout at OS level).
*
* Unix orphan containment (#2163 follow-up): the augment CLI is the
* longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
* npx), so on Unix it gets the same SIGKILL-surviving coreutils `timeout`
* wrapper as the probe's lsof/ps. The wrapper budget is ceil(inner/1000)+1
* seconds — STRICTLY greater than the inner spawnSync timeout, so on the
* supervised path Node's SIGTERM always fires first and the existing
* error/status contract is untouched. Once the hook itself has been
* SIGKILLed (exactly the orphan case the wrapper exists for), the guard
* semantics differ per branch:
* - direct exec (the CLI is the guard's CHILD): `-k 1` TERM-first — a
* SIGTERM-immune CLI can hold the guard ~1s past the inner timeout
* before the `-k` SIGKILL escalation reaps it.
* - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
* budget expiry SIGKILLs the whole process group outright. TERM-first
* would kill only the obedient npx parent, making `timeout` reap it and
* return before the `-k` escalation ever fires, stranding a
* SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
* 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
* `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
* on this branch, both bounded by "no worse than pre-fix" (where the
* grandchild received no signal at all): the group-wide SIGKILL is
* coreutils semantics — a busybox `timeout` passes the self-test (it
* has `-k` and propagates exit status) but signals only its direct
* child, so a busybox guard cannot reach the grandchild; and on the
* SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
* guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
* and the guard exits before any KILL fires — so a SIGTERM-immune CLI
* grandchild still escapes in those two cases.
* If the sibling probe predates the resolveUnixGuardTimeout export (version
* skew), the adapter degrades to the unwrapped invocation instead of
* throwing. Windows is deliberately NOT wrapped — there is no coreutils
* timeout to resolve there and the resolver's self-test spawns /bin/sh — so
* on win32 (the npx.cmd path) and whenever the guard resolves to null (e.g.
* macOS without Homebrew coreutils — reported once under GITNEXUS_DEBUG)
* the argv stays byte-identical to the pre-wrap invocation.
*/
function runGitNexusCli(cliPath, args, cwd, timeout) {
const isWin = process.platform === 'win32';
// Version-skew guard (#2163 follow-up review): an older sibling probe
// without the resolveUnixGuardTimeout export must degrade to the unwrapped
// invocation — a TypeError here would be swallowed by the caller's catch
// and silently kill the augment.
const guard =
isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
// Diagnose the "stays unwrapped" Unix paths once per hook process: no
// usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
// coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
unguardedCliWarned = true;
process.stderr.write(
'[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
);
}
if (cliPath) {
return spawnSync(process.execPath, [cliPath, ...args], {
const [cmd, cmdArgs] = guard
? [
guard,
['-k', '1', String(Math.ceil(timeout / 1000) + 1), process.execPath, cliPath, ...args],
]
: [process.execPath, [cliPath, ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout,
cwd,
@ -233,8 +298,27 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
windowsHide: true,
});
}
// On Windows, invoke npx.cmd directly (no shell needed)
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
// On Windows, invoke npx.cmd directly (no shell needed). A non-null guard
// implies non-Windows, so the wrapped arm can hardcode plain `npx`. The
// wrapped arm leads with `-s KILL` (NOT TERM-first like the direct branch
// above): the CLI here is a grandchild behind npx — see the docblock.
const [cmd, cmdArgs] = guard
? [
guard,
[
'-s',
'KILL',
'-k',
'1',
String(Math.ceil((timeout + 5000) / 1000) + 1),
'npx',
'-y',
'gitnexus',
...args,
],
]
: [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
return spawnSync(cmd, cmdArgs, {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,

View file

@ -3,14 +3,36 @@
* with a command line that looks like a GitNexus MCP/serve server?
*
* Backends (no user-installed Sysinternals):
* - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof;
* optional lsof fallback when proc scan finds nothing.
* - Linux: cmdline-first procfs scan under /proc, no lsof at all (#2180). Three
* phases, cheapest first: (0) read /proc/<pid>/comm — a tiny task->comm read
* that never touches the target's mm — and keep only PIDs whose comm is a
* plausible node/gitnexus server; (1) read up to GITNEXUS_HOOK_PROC_CMDLINE_MAX
* bytes of /proc/<pid>/cmdline via openSync+readSync (bounded, so a D-state
* holder stuck on mmap_lock or a giant argv can't wedge the hook) and prefilter
* with isGitNexusServerCommand; (2) only for the 0..N survivors, stat their
* /proc/<pid>/fd/* and compare dev+inode against the target lbug. The lbug
* handle is fd-visible (a @ladybugdb/core property), so this finds every real
* owner without scanning every fd of every process.
* - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first).
* - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script +
* Win32_Process for command lines; trusted powershell.exe under %SystemRoot%.
*
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
* Fail matrix:
* - Linux proc scan: owner found -> fail-closed (skip augment); budget exhausted
* (GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS) -> fail-CLOSED (#2180). This is a
* deliberate change from the old "timeout -> fail-open then try lsof" path.
* End-to-end the busy-host outcome is unchanged: the old code's lsof fallback
* ETIMEDOUT'd on the very hosts where the scan ran out of budget and ALSO
* failed closed there — the lsof leg only ever added 1-2s of dead work plus
* the orphan-storm risk it caused (#2163). What changes is that an overloaded
* host now self-throttles immediately (the throttle the incident needed)
* instead of paying for a doomed lsof. Mid-load hosts that used to fall
* through to a successful lsof now answer from the scan directly (faster) or,
* if even the scan can't finish in budget, fail closed (self-throttle) — a
* bounded, documented tradeoff, never an orphan.
* - macOS / other Unix: fail-open on most errors; fail-closed only on lsof
* ETIMEDOUT, matching the hook contract.
* - Windows: fail-closed only on PowerShell ETIMEDOUT.
*
* Unix subprocess containment contract (#2163):
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
@ -20,13 +42,18 @@
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
* wrapper off deterministically; any other value is adopted only when it
* exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
* THROUGH to the built-in candidate list (first self-test pass wins), so
* no malformed value of any shape can silently disable orphan containment.
* exists AND passes a one-shot `-k` exit-propagation self-test — otherwise
* resolution FALLS THROUGH to the built-in candidate list (first self-test
* pass wins), so no malformed value of any shape can silently disable
* orphan containment.
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
* A probe before that first query is therefore always false — a known,
* pre-existing race, not a bug in this probe.
* - resolveUnixGuardTimeout is exported so the hook adapters can wrap the
* `gitnexus augment` CLI child — the longest-lived hook subprocess (7s
* local / 12s npx inner budgets) — in the same guard; see runGitNexusCli
* in the adapters (#2163 follow-up).
*/
const fs = require('fs');
@ -41,6 +68,16 @@ function isGitNexusServerCommand(command) {
return hasServerMode && hasGitNexus;
}
// GITNEXUS_DEBUG-gated stderr diagnostics. Reuses the exact gating predicate the
// Windows ps1-load warning already uses (===' 1' / ==='true') so there is one
// debug convention in this file, and writes via process.stderr.write (NOT a
// spawn) so it never perturbs the windowsHide spawn-count invariant.
function debugLog(msg) {
if (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') {
process.stderr.write(`[GitNexus hook] ${msg}\n`);
}
}
function resolveHookBinary(tool) {
const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
const fromEnv = process.env[envKey];
@ -70,38 +107,53 @@ let unixGuardTimeoutCache;
/**
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
* (#2163). Dead code on Windows (the win32 dispatch returns earlier).
* (#2163). Unix-only by contract: the probe's win32 dispatch returns before
* reaching it, and the exported callers (the adapters' runGitNexusCli,
* #2163 follow-up) must check the platform first — the self-test below
* spawns /bin/sh. The memoized result is module-wide, so probe and adapter
* share one lazy self-test per hook process.
*
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
* wrapper off; any other value is only a CANDIDATE — an existing file path
* is tried first, but it must pass the `-k` self-test to be adopted. On any
* failure (non-existent path, directory, non-executable file, wrapper
* without `-k` support, …) resolution falls through to the built-in
* candidates below, tried in order, first self-test pass wins. This is
* strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
* GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
* value of ANY shape can silently disable orphan containment.
* is tried first, but it must pass the `-k` exit-propagation self-test to
* be adopted. On any failure (non-existent path, directory, non-executable
* file, wrapper without `-k` support, always-exit-0 stub, …) resolution
* falls through to the built-in candidates below, tried in order, first
* self-test pass wins. This is strictly stronger than the sibling
* GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only
* check existence): no bad env value of ANY shape can silently disable
* orphan containment.
*
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
* first reached, and the result is memoized. A candidate is adopted only
* when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
* not support the coreutils `-k` flag — busybox <1.34, toybox, broken
* symlinks — which would otherwise exit with a usage error without ever
* running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
* into fail-open (#1492 regression). Only when EVERY candidate fails does
* the probe fall back to the unwrapped status quo (memoized null).
* busybox ≥1.34 passes the test and is fully usable (capability, not
* identity, decides).
* when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 — i.e. it must RUN
* the wrapped command AND PROPAGATE its exit status. This rejects two
* failure shapes: wrappers without the coreutils `-k` flag — busybox <1.34,
* toybox, broken symlinks — which would exit with a usage error without
* ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed
* contract into fail-open (#1492 regression); and always-exit-0 stubs
* (/bin/true shapes), which would otherwise be adopted and "succeed" every
* wrapped spawn instantly without running it — a constant no-owner probe
* answer and, worse, a silently dead augment (status 0, empty stderr passes
* the adapters' success check with no context; #2163 follow-up review).
* Only when EVERY candidate fails does the probe fall back to the unwrapped
* status quo (memoized null). busybox ≥1.34 passes the test and is fully
* usable for everything THIS file spawns (lsof/ps are the guard's direct
* children) and for the adapters' direct-exec arm. The adapters' npx arm
* additionally relies on coreutils' process-GROUP signalling for its
* `-s KILL` grandchild reaping; busybox signals only its direct child, and
* this self-test deliberately does not probe that capability — see the
* adapter docblocks for the residual-gap statement.
*/
function passesGuardSelfTest(guard) {
try {
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], {
encoding: 'utf-8',
timeout: 3000,
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
});
return !selfTest.error && selfTest.status === 0;
return !selfTest.error && selfTest.status === 42;
} catch {
return false;
}
@ -222,59 +274,325 @@ function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
return false;
}
function readLinuxCmdline(pidStr) {
// The procfs root every Linux scan path reads from. Production is always /proc;
// GITNEXUS_HOOK_PROC_ROOT only exists so unit tests can inject a fixture tree
// (comm + cmdline + fd symlinks) and assert the three-phase logic without
// scanning the real, ~hundreds-of-process /proc of the test host.
//
// Test-only gate (F4): the override is honored ONLY under a test runner —
// vitest injects VITEST="true" and NODE_ENV="test" into every worker (verified;
// a production hook is `node <file>.cjs` with neither set). Without the gate, a
// production env that accidentally leaked GITNEXUS_HOOK_PROC_ROOT (pointing at an
// empty/bad tree) would make readdirSync find no pids -> 'not-owned' -> Linux
// owner detection silently OFF (fail-OPEN: augment races the real server for the
// lbug, the #1492 class). Gating to the test signal makes that leak inert in
// production (always /proc) while the fake-procfs unit tests, which run under
// vitest, still inject freely. Unset env (or non-test context) => /proc, so the
// production path is byte-for-byte the historical behavior.
function isTestContext() {
return (
process.env.VITEST === 'true' || process.env.VITEST === '1' || process.env.NODE_ENV === 'test'
);
}
function getProcRoot() {
if (!isTestContext()) return '/proc';
const raw = process.env.GITNEXUS_HOOK_PROC_ROOT;
return raw && String(raw).trim() ? String(raw) : '/proc';
}
// Max bytes read from /proc/<pid>/cmdline in Phase 1. Bounded by default so a
// D-state holder wedged on mmap_lock, or a process with a pathological multi-MB
// argv, can't stall the hook. 16 KiB comfortably clears a realistic
// `node <abs path to .../node_modules/gitnexus/dist/cli/index.js> mcp` line
// (the `mcp`/`serve` mode token lives at the very tail, so the cap must be large
// enough to reach it — see PROC_CMDLINE_FLOOR escalation below). Overridable for
// tests; never goes below PROC_CMDLINE_FLOOR.
const PROC_CMDLINE_FLOOR = 4096;
function getCmdlineMaxBytes() {
const raw = process.env.GITNEXUS_HOOK_PROC_CMDLINE_MAX;
// Number() (not parseInt) so "8e3" reads as 8000, not 8 (parseInt stops at
// 'e'). The `raw && String(raw).trim()` guard keeps empty/whitespace on the
// default; trailing garbage ("8abc") now -> NaN -> default (stricter).
const n = raw && String(raw).trim() ? Number(String(raw).trim()) : NaN;
if (Number.isFinite(n) && n >= PROC_CMDLINE_FLOOR) return n;
return 16384;
}
// Phase 0 comm prefilter. /proc/<pid>/comm is the kernel task->comm string,
// capped at 16 bytes INCLUDING the trailing NUL — i.e. at most 15 visible
// chars, truncated by the kernel with no marker. So a process whose real name
// is longer than 15 chars shows a 15-char prefix here. The match below is
// therefore truncation-safe in BOTH directions (a whitelist name that is a
// prefix of comm, or comm that is a prefix of a whitelist name, both count) to
// guarantee we never drop a real owner at this cheap stage — Phase 2's dev+ino
// fd check is the real authority; Phase 0/1 only exist to skip the overwhelming
// majority (kernel threads, shells, editors) cheaply.
//
// The whitelist is calibrated against what a real `gitnexus mcp`/`serve` server
// actually reports for comm. Observed on production hosts: the server renames
// its main thread, so comm reads `MainThread` (via @ladybugdb/core's
// worker_threads setup), NOT `node` — omitting it would blind the probe to
// every real server (#1492-class owner miss). We also keep the plausible
// launcher/runtime basenames in case a future build does not rename the thread.
// Conservative by design: over-collecting a few extra candidates only costs a
// bounded number of Phase 1 cmdline reads.
const COMM_CANDIDATES = ['node', 'gitnexus', 'bun', 'deno', 'npm', 'npx', 'MainThread'];
function commLooksLikeServer(comm) {
const c = comm.trim();
if (!c) return false;
for (const name of COMM_CANDIDATES) {
if (name === c || name.startsWith(c) || c.startsWith(name)) return true;
}
return false;
}
function readProcComm(procRoot, pidStr) {
try {
return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
return fs
.readFileSync(path.join(procRoot, pidStr, 'comm'), 'utf8')
.replace(/\0+/g, '')
.trim();
} catch {
return '';
}
}
function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
// Timeout sentinel for readLinuxCmdline (F3). MUST be distinct from the
// "unreadable/empty" return value (''): '' flows through isGitNexusServerCommand
// as a NON-candidate (both regexes are false on ''), so the Phase 1 caller
// `continue`s past it — correct for a raced/openSync-failed pid, but a FAIL-OPEN
// bug if it ever meant "I ran out of budget mid-read" (a real owner whose
// escalation timed out would be silently dropped, racing the lbug -> #1492). A
// unique Symbol can never collide with any cmdline string, so the caller can
// branch on it explicitly and map a mid-read timeout to the tri-state 'timeout'
// (fail-CLOSED) instead of swallowing it as a non-candidate.
const CMDLINE_TIMEOUT = Symbol('gitnexus.cmdline.timeout');
// Bounded /proc/<pid>/cmdline read for Phase 1. openSync+readSync (not
// readFileSync) so a D-state holder cannot stall the hook on a huge or
// never-EOF argv: we read at most `cap` bytes and stop. cmdline separates argv
// with NULs; convert to spaces for isGitNexusServerCommand.
//
// Owner-miss guard for the 4 KB cap: the `gitnexus` token usually sits in the
// first path component while the `mcp`/`serve` mode token is the LAST argv, so
// a naive 4 KB read could clip the mode token off a server launched with a very
// long interpreter path and silently miss a real owner. We mitigate two ways:
// (a) the default cap (16 KiB) already clears realistic lines; (b) if the first
// read fills the cap AND already contains the `gitnexus` token but no mode
// token yet, we keep reading in bounded chunks (up to a hard ceiling) until the
// mode token appears or the file ends — so a genuine server is never missed for
// want of a few more bytes, while non-candidates still pay only the initial
// bounded read.
//
// Budget (F3): the escalation loop above is the one place a SINGLE pathological
// candidate could read up to HARD_CEIL (256 KiB) before the next scan-level
// budget check, weakening the timeout contract. `outOfBudget` (the scan's shared
// deadline callback) is checked once per escalation iteration; on expiry we
// return CMDLINE_TIMEOUT (NOT '') so the caller can fail-closed honestly rather
// than mistake the partial read for a non-candidate. Reads that simply can't
// open / error out still return '' (genuinely "not a readable candidate").
function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) {
const file = path.join(procRoot, pidStr, 'cmdline');
let fd;
try {
fd = fs.openSync(file, 'r');
} catch {
return '';
}
try {
const HARD_CEIL = 262144; // 256 KiB absolute ceiling for the escalation path
let collected = Buffer.alloc(0);
let offset = 0;
let chunkCap = cap;
for (;;) {
// allocUnsafe is safe here: readSync fills exactly [0, bytes), only
// buf.subarray(0, bytes) is consumed, and Buffer.concat deep-copies that
// slice into `collected`, so the uninitialized tail never reaches decode.
const buf = Buffer.allocUnsafe(chunkCap);
const bytes = fs.readSync(fd, buf, 0, chunkCap, offset);
if (bytes <= 0) break;
collected = Buffer.concat([collected, buf.subarray(0, bytes)]);
offset += bytes;
const text = collected.toString('utf8').replace(/\0+/g, ' ');
// Stop early when we can already decide "owner": has both the gitnexus
// token and a mode token. Keep going only when gitnexus is present but
// the mode token might be just past the boundary.
const hasGitNexus =
/(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(text) ||
/node_modules[/\\]gitnexus[/\\]/.test(text);
const hasMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(text);
if (hasMode) break; // decided (positive); isGitNexusServerCommand re-checks below
if (bytes < chunkCap) break; // EOF: full cmdline read, definitive
if (!hasGitNexus) break; // not a candidate; do not escalate the read
if (offset >= HARD_CEIL) break; // bounded escalation only
// Budget gate the escalation: a single huge-argv candidate must not burn
// the whole scan deadline before we re-check. Return the timeout sentinel
// (never '') so the caller fails closed instead of treating us as a
// non-candidate. The sole caller (linuxProcScanFindGitNexusServer) always
// passes outOfBudget, so no presence guard is needed.
if (outOfBudget()) return CMDLINE_TIMEOUT;
chunkCap = cap; // keep reading more in cap-sized chunks
}
return collected.toString('utf8').replace(/\0+/g, ' ').trim();
} catch {
return '';
} finally {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
function resolveLinuxProcBudgetMs() {
const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
// Gate on the STRING's emptiness, NOT the parsed number's truthiness — the
// old `Number(raw && trim()) ? ... : 1200` form treated "0" as falsy and
// silently fell back to 1200 (#2180). Use Number() (not parseInt) so "16e3"
// reads as 16000, not 16 (parseInt stops at 'e'). The `&& String(raw).trim()`
// guard is load-bearing: without it a set-but-empty/whitespace value would be
// `Number("")===0` => budget 0 => immediate fail-CLOSED timeout (augment
// permanently skipped). With it, ''/whitespace => NaN => 1200 default, while a
// finite "0" still parses to an explicit, deterministic "no budget" =>
// immediate timeout. Non-numeric / unset => default 1200.
const n = raw != null && String(raw).trim() ? Number(String(raw).trim()) : NaN;
if (!Number.isFinite(n)) return 1200;
return n; // may be <= 0, meaning "out of budget on the first check"
}
// Returns one of: 'owned' (a non-self process with a GitNexus-server cmdline
// holds the target lbug fd), 'not-owned' (scan completed, no such owner), or
// 'timeout' (the per-scan budget was exhausted before a verdict). The name is
// pinned by a source-contract test; only the return TYPE changed (#2180:
// boolean -> tri-state, so the dispatcher can fail-closed on 'timeout').
function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
const budget = resolveLinuxProcBudgetMs();
// A non-positive budget is an explicit, deterministic "no time to scan" =>
// immediate timeout (the #2180 test vector, and the only correct reading of
// the fixed parse: "0" must NOT mean 1200). Returning before any procfs read
// keeps it instantaneous regardless of host load.
if (budget <= 0) return 'timeout';
const procRoot = getProcRoot();
const cmdlineCap = getCmdlineMaxBytes();
const start = Date.now();
const outOfBudget = () => Date.now() - start > budget;
let targetStat;
try {
targetStat = fs.statSync(dbPathAbs);
} catch {
return false;
// Caller already existsSync'd the path; a stat failure here is a transient
// race, treat as no owner (historical semantics).
return 'not-owned';
}
let procEntries;
try {
procEntries = fs.readdirSync('/proc', { withFileTypes: true });
procEntries = fs.readdirSync(procRoot, { withFileTypes: true });
} catch {
return false;
return 'not-owned';
}
// Phase 0 + Phase 1: collect the few PIDs whose comm AND cmdline look like a
// GitNexus server, without touching any fd yet.
const candidates = [];
for (const ent of procEntries) {
if (Date.now() - start > budget) return false;
if (outOfBudget()) return 'timeout';
if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
const pid = Number.parseInt(ent.name, 10);
if (!Number.isFinite(pid) || pid === myPid) continue;
const fdDir = path.join('/proc', ent.name, 'fd');
// Phase 0: cheap comm prefilter.
const comm = readProcComm(procRoot, ent.name);
if (!comm) continue; // unreadable comm (kernel thread, raced exit) -> skip
if (!commLooksLikeServer(comm)) continue;
// Phase 1: bounded cmdline read + isGitNexusServerCommand prefilter.
if (outOfBudget()) return 'timeout';
const cmdline = readLinuxCmdline(procRoot, ent.name, cmdlineCap, outOfBudget);
// F3: a mid-read budget timeout returns the CMDLINE_TIMEOUT sentinel (a
// Symbol, never a string). Fail CLOSED on it rather than letting it fall
// through isGitNexusServerCommand as a non-candidate — a real owner whose
// escalation timed out must not be silently dropped (would fail-OPEN).
if (cmdline === CMDLINE_TIMEOUT) return 'timeout';
if (!isGitNexusServerCommand(cmdline)) continue;
candidates.push(ent.name);
}
// Phase 2: only now stat the fds of the (typically 0-2) survivors.
for (const pidStr of candidates) {
if (outOfBudget()) return 'timeout';
const fdDir = path.join(procRoot, pidStr, 'fd');
let fds;
try {
fds = fs.readdirSync(fdDir);
} catch {
} catch (err) {
// F1: the old code returned 'owned' for EVERY non-ENOENT error. That was
// a correctness bug: /proc/<pid>/fd is owner-only (mode 0500), so a
// cross-user/root `gitnexus mcp` serving a DIFFERENT repo passes Phase 0+1
// (its cmdline matches) and then EACCES'es here — yet its dev+ino was
// NEVER compared against THIS lbug. Claiming 'owned' lets it permanently,
// silently suppress augment for a repo it does not actually lock. We now
// distinguish the failure shapes (all still fail-closed where we can't
// prove non-ownership, but 'timeout' is the HONEST verdict for
// "inconclusive", not the false-positive 'owned'):
const code = err && err.code;
if (code === 'ENOENT') {
// Process raced away between the candidate scan and now -> genuinely no
// longer an owner. Move on.
continue;
}
if (code === 'EACCES' || code === 'EPERM') {
// Permission-denied fd dir: cannot read fds, so ownership is
// UNVERIFIABLE. Fail closed honestly via 'timeout' (the dispatcher maps
// timeout -> true, same protective skip as before) WITHOUT lying that we
// confirmed ownership. Do NOT degrade to not-owned/fail-open: if this
// really is the owner, fail-open re-opens the #1492 lbug race; augment
// is optional context, so a conservative skip costs little.
debugLog(
`fd dir unreadable for candidate pid ${pidStr} (${code}); ownership ` +
`unverifiable, probe inconclusive -> fail-closed (timeout)`,
);
return 'timeout';
}
if (code === 'EIO' || code === 'ESTALE') {
// Genuine transient I/O against this candidate's fd dir — not evidence
// it does NOT hold the lbug. Treat as inconclusive and fail closed
// (timeout) rather than continue, so a real owner mid-I/O-blip is not
// dropped (would fail-open).
debugLog(
`fd dir transient I/O error for candidate pid ${pidStr} (${code}); ` +
`probe inconclusive -> fail-closed (timeout)`,
);
return 'timeout';
}
// Any other shape (ENOTDIR — fd path is not a directory at all, so this
// is not a plausible live-procfs owner — and the long tail) is treated as
// "this candidate is not an owner": move to the next candidate instead of
// the old blanket 'owned'. If no other candidate owns the lbug the scan
// ends not-owned (dispatcher fail-open) — acceptable because ENOTDIR means
// the fd entry is structurally not a real /proc/<pid>/fd.
debugLog(
`fd dir not a readable directory for candidate pid ${pidStr} ` +
`(${code || 'unknown'}); treating candidate as non-owner -> continue`,
);
continue;
}
let holds = false;
for (const fd of fds) {
if (Date.now() - start > budget) return false;
if (outOfBudget()) return 'timeout';
try {
const st = fs.statSync(path.join(fdDir, fd));
if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
holds = true;
break;
return 'owned';
}
} catch {
/* ignore */
/* fd raced closed; ignore */
}
}
if (!holds) continue;
if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
}
return false;
return 'not-owned';
}
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
@ -350,8 +668,13 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
}
if (process.platform === 'linux') {
if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
// #2180: cmdline-first procfs scan, no lsof. 'timeout' fails CLOSED
// (overloaded host self-throttles — the throttle the orphan-storm incident
// needed; the old lsof fallback ETIMEDOUT'd and failed closed on these same
// hosts anyway, only slower and with the orphan risk). 'not-owned' is the
// only false. See the fail matrix in the file header.
const verdict = linuxProcScanFindGitNexusServer(dbPathAbs, myPid);
return verdict !== 'not-owned';
}
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
@ -359,4 +682,30 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
module.exports = {
hasGitNexusDbLockedByGitNexusServer,
// Exported for white-box unit tests that must assert the tri-state verdict
// ('owned' | 'not-owned' | 'timeout') directly — the dispatcher collapses
// timeout and owned to the same boolean true, so the boolean API alone cannot
// distinguish the F1 EACCES->timeout fix from the old EACCES->owned bug. The
// Probe interface already declares this optional. Linux-only by contract; the
// name is pinned by a source-contract test.
linuxProcScanFindGitNexusServer,
// #2163 follow-up: the hook adapters wrap the augment CLI in the same
// guard. Returns a self-tested wrapper path — the built-in candidates are
// always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
// exact string that passed the self-test. Same string is also the same
// RESOLUTION for absolute paths and for slashless names (PATH lookup is
// cwd-independent); a slash-containing RELATIVE override, however, is
// existsSync-checked and self-tested against this process's cwd while the
// adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
// a value can pass here yet ENOENT at the augment call site — set the
// override to an absolute path. Returns null when the wrapper is
// disabled/unavailable. Never call on win32 (see its JSDoc).
resolveUnixGuardTimeout,
// Exported for white-box unit tests of the numeric-env parsing (#2183 review):
// Number()-not-parseInt so "16e3" reads as 16000, plus the empty/whitespace
// guard that keeps a set-but-empty budget on the 1200 default instead of an
// immediate fail-closed timeout. Tested directly because the values are
// otherwise only observable indirectly through scan timing/escalation.
getCmdlineMaxBytes,
resolveLinuxProcBudgetMs,
};

View file

@ -171,9 +171,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@ -188,9 +188,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@ -205,9 +205,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@ -222,9 +222,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@ -239,9 +239,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@ -256,9 +256,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@ -273,9 +273,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@ -290,9 +290,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@ -307,9 +307,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@ -324,9 +324,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@ -341,9 +341,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@ -358,9 +358,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@ -375,9 +375,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@ -392,9 +392,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@ -409,9 +409,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@ -426,9 +426,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@ -443,9 +443,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@ -460,9 +460,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@ -477,9 +477,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@ -494,9 +494,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@ -511,9 +511,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@ -528,9 +528,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@ -545,9 +545,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@ -562,9 +562,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@ -579,9 +579,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@ -596,9 +596,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@ -3056,9 +3056,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@ -3069,32 +3069,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": {

View file

@ -37,6 +37,7 @@ const PLATFORM_LOGIC = [
'test/unit/repo-manager.test.ts',
'test/unit/repo-manager-finalize-invariant.test.ts',
'test/unit/hooks.test.ts',
'test/unit/hook-db-lock-probe.test.ts',
'test/unit/cursor-hook.test.ts',
'test/unit/sidecar-recovery.test.ts',
'test/unit/pool-wal-recovery.test.ts',

View file

@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Workflow
```
1. query({query: "<error or symptom>"}) → Find related execution flows
1. query({search_query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@ -51,7 +51,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
**query** — find code related to error:
```
query({query: "payment validation error"})
query({search_query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
@ -75,7 +75,7 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. query({query: "payment error handling"})
1. query({search_query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError

View file

@ -18,7 +18,7 @@ description: "Use when the user asks how code works, wants to understand archite
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. query({query: "<what you want to understand>"}) → Find related execution flows
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@ -50,7 +50,7 @@ description: "Use when the user asks how code works, wants to understand archite
**query** — find execution flows related to a concept:
```
query({query: "payment processing"})
query({search_query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
@ -68,7 +68,7 @@ context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. query({query: "payment processing"})
2. query({search_query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. context({name: "processPayment"})

View file

@ -38,6 +38,9 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` |
| `check` | Check graph invariants such as circular imports |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
@ -71,6 +74,25 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
### 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`.
### Control & data dependence (`pdg_query`)
`pdg_query` reads the control/data-dependence layers `gitnexus analyze --pdg` records (CDG + REACHING_DEF, basic-block granular) — the control/data analog of `explain`. It is **always anchored** (a `target` file path or symbol, resolved like `context`) and has two modes:
- `pdg_query { mode: "controls", target: "..." }` — CDG: "under what condition does X run?". Each edge is a controlling predicate block → dependent block with the branch sense (`'T'`/`'F'`) in `reason`; an edge into an early `return`/`throw` is flagged `guard: true` (guard-clause discovery — the sense depends on the predicate, so don't filter guards by a fixed label).
- `pdg_query { mode: "flows", target: "...", variable?: "..." }` — REACHING_DEF def→use edges within the function; pass `variable` to trace one binding.
A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown" when the layer can't be confirmed). Intra-procedural only — cross-function flow is taint's domain (`explain`). The raw CDG/REACHING_DEF edges are also queryable via `cypher`. See the `gitnexus-pdg-query` skill for the full query surface.
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:

View file

@ -0,0 +1,89 @@
---
name: gitnexus-pdg-query
description: "Use when querying or extending GitNexus's PDG control/data-dependence surface (the `pdg_query` MCP tool, CDG/REACHING_DEF edges), or reasoning about \"what controls X\" / \"where does Y flow\" / guard clauses. Examples: \"what guards this statement?\", \"trace this variable within the function\", \"why is the pdg_query result empty?\", \"add a CDG query\"."
---
# PDG query surface with GitNexus
Expert knowledge for the `pdg_query` MCP tool and the control/data-dependence
edges it reads — the opt-in `--pdg` program-dependence layers. Read this before
touching `gitnexus/src/mcp/local/local-backend.ts` (`_pdgQueryImpl`) or the
`pdg_query` tool def, or when explaining a `pdg_query` result.
## When to Use
- "Under what condition does this statement run?" (guarding predicates).
- "Where does this variable flow inside the function?" (def→use).
- Guard-clause discovery (early-return guards — subsumes the #559 heuristic).
- Extending or reviewing `pdg_query` / the CDG / REACHING_DEF read path.
- Debugging an empty or surprising `pdg_query` result.
## The layered substrate (build order)
`pdg_query` runs **on** the same graph taint runs on. Each layer is opt-in
behind `--pdg`; a default `analyze` run records none of them (byte-identical).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L5 CDG Ferrante control dependence (post-dominators) (M5 #2085)
```
All three are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table
(keyed by the `type` property). There is **no** `Function → BasicBlock` edge.
## The two modes
- `pdg_query({ mode: 'controls', target })` — CDG. For the anchored function,
each edge: controlling predicate block → dependent block + branch sense in
`label` (`'T'` = predicate's true/taken arm, `'F'` = false/fall-through). An
edge into an early-return/throw block is flagged `guard: true`.
- `pdg_query({ mode: 'flows', target, variable? })` — REACHING_DEF def→use
edges; `variable` filters to one binding.
`target` is **required** — a file path or a symbol/function name (resolved like
`context()`). There is no anchorless mode (see below).
## The corrected guard-clause Cypher
The RFC #567 §2 form (`[:CDG {label:'F'}]`) does **not** run as written. Edges
are values of the single `CodeRelation` table's `type` property, and the branch
sense is in `reason`, NOT a `label` column:
```cypher
MATCH (pred:BasicBlock)-[r:CodeRelation {type: 'CDG'}]->(dep:BasicBlock)
WHERE dep.text STARTS WITH 'return' OR dep.text STARTS WITH 'throw'
RETURN pred.startLine, r.reason AS branch, dep.startLine, dep.text
```
`r.reason` is the sense the predicate took to reach the early exit. For
`if (!ok) return;` the return rides the predicate's **true** arm (`'T'`) and the
protected body rides the **false** arm (`'F'`) — polarity depends on the guard,
so don't hard-code one sense.
## Gotchas (the load-bearing ones)
- **Always anchored + LIMIT-bounded.** LadybugDB has no rel-property index, so
an unanchored `[:CDG*]`/`[:REACHING_DEF*]` path scan is unbounded. `pdg_query`
requires `target` and bounds the page; raw `cypher` callers must anchor on a
file id-prefix or symbol span themselves.
- **BasicBlock↔symbol join is reconstructed.** No `Function→BasicBlock` edge:
the block is matched by its id-prefix (`BasicBlock:<file>:<fnStartLine>:…`)
plus `startLine` within the symbol's span. BasicBlock `startLine` is **1-based**
while the symbol node's `startLine`/`endLine` are **0-based**, so **both** bounds
are shifted `+1` (`[symStart+1, symEnd+1]`): the upper `+1` keeps a guard/def/use
on the function's **final line**, the lower `+1` excludes an adjacent function's
block on the line directly **above**. Same-line / nested functions anchor coarsely.
- **No PDG layer ⇒ a note, not an error.** If the repo wasn't indexed with
`--pdg` the tool returns `{ results: [], note: "no PDG layer …" }` (cheap meta
probe on `RepoMeta.pdg.maxCdgEdgesPerFunction` / `maxReachingDefEdgesPerFunction`).
- **CDG labels are binary in M5/M6.** Every `switch`-case arm is `'T'`; per-case
conditions are not yet distinguished.
- **Intra-procedural only.** Cross-function flow is taint's domain (`explain`).
## Mirror, don't fork
`_pdgQueryImpl` is the front half of `_explainImpl` (WAL wrapper, meta no-layer
probe, limit validation, `resolveSymbolCandidates` anchoring) with CDG/
REACHING_DEF instead of TAINTED — and none of taint's path-codec / interproc
`TAINT_PATH` machinery. Reuse those shared helpers; do not re-implement them.

View file

@ -17,7 +17,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
2. query({search_query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```

View file

@ -0,0 +1,178 @@
---
name: gitnexus-taint-analysis
description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
---
# CFG & Taint Analysis with GitNexus
Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
graphs, reaching definitions, and intra- + inter-procedural taint. Read this
before touching `gitnexus/src/core/ingestion/cfg/**` or
`gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
## When to Use
- "How does the taint engine work / why is this flow (not) reported?"
- Adding a source, sink, or sanitizer to the model.
- Extending or reviewing the CFG / reaching-defs / taint / summary code.
- Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
- Debugging a false positive or false negative in `--pdg` output.
## The layered substrate (build order)
Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
and a default `analyze` run is **byte-identical** (the golden parity gate is the
hard floor for every change here).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
```
- **Worker-built, main-thread-solved.** The parse worker builds each function's
CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
(plain, structured-clone-safe data — never AST nodes). The main thread runs
the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
OOM).
- **In-phase emit (KTD1).** L1–L4-harvest all run INSIDE the scope-resolution
pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
because the disk-backed ParsedFile store is cleared when that phase ends — a
standalone post-`mro` phase would read empty data. The cross-function fixpoint
(L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
small plain summary data threaded out via `ScopeResolutionOutput`.
- **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
`harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
(no graph, no I/O, no logger; sorted outputs). Snapshot tests and
content-derived edge ids depend on it.
## Intra-procedural taint (L3)
Forward reachability over RD facts from matched **sources** to matched **sinks**,
killed by **sanitizers**. Key design points worth internalizing:
- **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
`exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
precise.
- **Kind-set sanitizer model.** A taint carries a set of *neutralized*
`SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
kill would be a suppressed live injection (the forbidden FN direction).
`path.basename(t)` neutralizes path-traversal only, not command-injection.
- **Statement-level finding identity.** NOT block-pair (block conflation drops
distinct findings; `exec(req.body, req.query)` is two findings).
- Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
`reason` column via the shared versioned codec (`taint/path-codec.ts`).
## Interprocedural taint (L4) — the functional/summary method
The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
reduced to a compact **summary**, and summaries are composed over the already-
resolved `CALLS` graph.
**Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
| Edge | Meaning | Analogue |
|------|---------|----------|
| `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
| `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
| `param→sink` | a param reaches a modelled sink | partial/triggered sink |
| `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
| `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
| `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
**The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
parameter, source)`. Seed from `source→callee-arg`, propagate via
`param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
- **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
lattice (`fn × param × source`), so the worklist converges — a recursive call
just re-proposes an already-visited entry. SCC condensation would only refine
processing order; correctness/termination don't require it.
- **Source-discriminated state (load-bearing).** Key the state by the SOURCE
too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
tainted by source A is marked visited and a later flow from source B is dropped
before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
- **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
site) is fragile; the callee identity is exact and context-insensitivity
taints the callee's param identically at every call site.
- Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
in `reason` via the same codec; confidence < the intra-procedural 1.0.
**Context-insensitivity** is the accepted trade-off at this tier: one summary
per function, return/call-site merging accepted (security-conservative). Expect
some FP from merging; the bigger FN sources are unmodeled features (below).
## Known false-negative classes (documented, deferred)
The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
into a callback is dropped without per-library models (true of CodeQL's JS libs
too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
flows, promise/async-await threading, and **destructured/rest params before a
tainted simple param** (the summary port index is the binding ordinal, not the
formal arg position — needs a formal-param index threaded from the worker
`BindingEntry`). The interprocedural join is also context-insensitive: when one
caller invokes two distinct **same-named callees**, a flow into one
over-attributes to both (sound — over-report, never a missed flow). Absence of a
finding is NOT proof of safety.
## GitNexus-specific gotchas
- **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
`Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
ambiguous → drop (the summary driver counts `unresolved`) rather than
cross-wire.
- **No rel-property index (S1).** Kuzu has no secondary index on relationship
properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
traversed live; `explain` reads it source-anchored + LIMIT-guarded.
- **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
schema (pinned in `security.test.ts`). `explain` enumerates both layers
(cross-function findings carry `interprocedural: true`).
- **One shared codec.** Both the emit path and `explain` import
`taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
fork it. New metadata extends the format WITHIN the version when writer +
reader ship together.
- **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
full writeback. Model content rides `taintModelVersion`.
## Adding a source / sink / sanitizer
Edit the language model in `taint/typescript-model.ts` (registered via the
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
finding (or its absence) in `test/unit/taint/` (real-source harness:
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
`test/integration/cfg/`.
## Validation checklist for any `--pdg` change
```
1. tsc clean (schema additions are exhaustiveness-checked; watch the
api.ts getNodeQuery runtime read-path if a node label is added).
2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
test/integration/cfg) — verify by ISOLATION, not full-suite exit
(known load-flakes). `node scripts/build.js` before worker/integration runs.
3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
5. detect_changes() before commit; impact({direction:'upstream'}) before
editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
```
## Prior art (for deeper design questions)
Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
notation, incl. callback ports); Infer (content-keyed incremental summaries).

View file

@ -35,6 +35,12 @@ export interface AIContextOptions {
* plain caller that omits it gets "main", preserving prior behavior.
*/
defaultBranch?: string;
/**
* Whether the index was built with `--pdg` (#2086 M6). Gates the `pdg_query`
* line in the generated block — without the PDG layer the tool only returns a
* "no PDG layer" note, so advertising it on a non-`--pdg` index is noise.
*/
hasPdg?: boolean;
}
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
@ -105,26 +111,45 @@ export function markdownSafeBranch(branch: string): string {
return branch.replace(/`/g, '');
}
/** Options for {@link generateGitNexusContent} (collapsed from positional
* params, #2188 review — six `undefined`s to reach `hasPdg` was the smell). */
export interface GitNexusContentOptions {
generatedSkills?: GeneratedSkillInfo[];
groupNames?: string[];
noStats?: boolean;
skipSkills?: boolean;
/** Project-relative path to the runner `gitnexus analyze` drops next to the
* index (#1945). Referenced by docs so a single CLI-neutral command resolves
* the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time. */
runnerPath?: string;
/** Default branch for the regression-compare example (#243). Configurable so
* projects on `develop`/`master`/etc. don't get `base_ref: "main"` rewritten
* back over their fix on every analyze. The value is embedded inside a
* Markdown inline-code span: validateBranchName rejects backticks upstream,
* and `markdownSafeBranch` strips any remaining backtick here as defense in
* depth, so JSON.stringify's quote/escape handling is sufficient and the
* branch cannot break out of the span (#1996 tri-review P1). */
defaultBranch?: string;
/** Whether the index was built with `--pdg` (#2086 M6). Gates the pdg_query
* line below — false (default) omits it, so a non-pdg index doesn't advertise
* a tool that only returns a "no PDG layer" note. */
hasPdg?: boolean;
}
export function generateGitNexusContent(
projectName: string,
stats: RepoStats,
generatedSkills?: GeneratedSkillInfo[],
groupNames?: string[],
noStats?: boolean,
skipSkills?: boolean,
// Project-relative path to the runner `gitnexus analyze` drops next to the
// index (#1945). Referenced by docs so a single CLI-neutral command resolves
// the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time.
runnerPath: string = '.gitnexus/run.cjs',
// Default branch for the regression-compare example (#243). Configurable so
// projects on `develop`/`master`/etc. don't get `base_ref: "main"` rewritten
// back over their fix on every analyze. The value is embedded inside a
// Markdown inline-code span: validateBranchName rejects backticks upstream,
// and `markdownSafeBranch` strips any remaining backtick here as defense in
// depth, so JSON.stringify's quote/escape handling is sufficient and the
// branch cannot break out of the span (#1996 tri-review P1).
defaultBranch: string = 'main',
opts: GitNexusContentOptions = {},
): string {
const {
generatedSkills,
groupNames,
noStats,
skipSkills,
runnerPath = '.gitnexus/run.cjs',
defaultBranch = 'main',
hasPdg = false,
} = opts;
const generatedRows =
generatedSkills && generatedSkills.length > 0
? generatedSkills
@ -177,8 +202,13 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
- **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. For regression review, compare against the default branch: \`detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use \`query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- 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"})\`.
- For security review, \`explain({target: "fileOrSymbol"})\` lists taint findings (source→sink flows; needs \`analyze --pdg\`).${
hasPdg
? `\n- For control/data dependence, \`pdg_query({mode: "controls", target: "fileOrSymbol"})\` answers "under what condition does X run?" (CDG, incl. guard clauses) and \`pdg_query({mode: "flows", target, variable})\` traces "where does variable Y flow?" (REACHING_DEF). \`--pdg\` layer.`
: ''
}
## Never Do
@ -446,16 +476,15 @@ export async function generateAIContextFiles(
logger.warn(`Could not write GitNexus runner to ${runnerPath}: ${String(err)}`);
}
const content = generateGitNexusContent(
projectName,
stats,
const content = generateGitNexusContent(projectName, stats, {
generatedSkills,
groupNames,
options?.noStats,
options?.skipSkills,
noStats: options?.noStats,
skipSkills: options?.skipSkills,
runnerPath,
options?.defaultBranch ?? 'main',
);
defaultBranch: options?.defaultBranch ?? 'main',
hasPdg: options?.hasPdg ?? false,
});
const createdFiles: string[] = [];
if (!options?.skipAgentsMd) {

View file

@ -107,6 +107,16 @@ const KEY_SPECS: Record<string, KeySpec> = {
// built-in convention set, is otherwise invisible to route_map consumers.
// Listing it here adds it to the cross-file consumer scan.
fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' },
// Auth token AND dims are intentionally CLI/env-only — no embeddingAuthToken
// or embeddingDims key here:
// - the token keeps secrets out of a committed .gitnexusrc;
// - dims cannot take effect from .gitnexusrc anyway — schema.ts reads
// GITNEXUS_EMBEDDING_DIMS at module-load (before .gitnexusrc is loaded in
// analyzeCommandImpl), so a config value would size nothing and silently
// mismatch the vector column. Use --embedding-dims or GITNEXUS_EMBEDDING_DIMS.
// (URL/MODEL are safe as config keys: they are read lazily at runtime, not at module-load.)
embeddingBaseUrl: { target: 'embeddingBaseUrl', kind: 'string' },
embeddingModel: { target: 'embeddingModel', kind: 'string' },
};
/** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */

View file

@ -41,8 +41,10 @@ import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './opt
import { glob } from 'glob';
import fs from 'fs/promises';
import { cliError } from './cli-message.js';
import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.js';
import { formatElapsed } from './format-elapsed.js';
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
import { safeUrl } from '../core/embeddings/http-client.js';
import { isLocalEmbeddingRuntimeBlockerMessage } from '../core/embeddings/runtime-support.js';
import { warnIfNpm11NpxRisk } from './resolve-invocation.js';
@ -560,6 +562,10 @@ const ANALYZE_CLI_ENV_KEYS = [
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
'GITNEXUS_EMBEDDING_DEVICE',
'GITNEXUS_ANALYZE_PROGRESS_ACTIVE',
'GITNEXUS_EMBEDDING_URL',
'GITNEXUS_EMBEDDING_MODEL',
'GITNEXUS_EMBEDDING_API_KEY',
'GITNEXUS_EMBEDDING_DIMS',
] as const;
type AnalyzeEnvSnapshot = Record<(typeof ANALYZE_CLI_ENV_KEYS)[number], string | undefined>;
@ -677,6 +683,14 @@ export interface AnalyzeOptions {
* outside the built-in convention still produces `route_map` consumers.
*/
fetchWrappers?: string[];
/** OpenAI-compatible embeddings base URL (incl. /v1). Overrides GITNEXUS_EMBEDDING_URL. */
embeddingBaseUrl?: string;
/** Embedding model name. Overrides GITNEXUS_EMBEDDING_MODEL. */
embeddingModel?: string;
/** Bearer token for the embeddings endpoint. Overrides GITNEXUS_EMBEDDING_API_KEY. Never logged. */
embeddingAuthToken?: string;
/** Embedding vector dimensions (positive integer string). Overrides GITNEXUS_EMBEDDING_DIMS. */
embeddingDims?: string;
}
/**
@ -958,6 +972,109 @@ const analyzeCommandImpl = async (
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
}
// --- Custom HTTP embedding endpoint flags (override GITNEXUS_EMBEDDING_* env vars) ---
const anyHttpEmbedFlag =
options.embeddingBaseUrl !== undefined ||
options.embeddingModel !== undefined ||
options.embeddingAuthToken !== undefined ||
options.embeddingDims !== undefined;
if (options.embeddingBaseUrl !== undefined) {
const url = options.embeddingBaseUrl.trim();
if (url.length === 0) {
cliError(' --embedding-base-url must not be empty.\n');
process.exitCode = 1;
return;
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
cliError(` --embedding-base-url is not a valid URL: "${url}".\n`);
process.exitCode = 1;
return;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
cliError(' --embedding-base-url must use http:// or https://.\n');
process.exitCode = 1;
return;
}
// http-client strips trailing slashes; store as given (trimmed).
process.env.GITNEXUS_EMBEDDING_URL = url;
}
if (options.embeddingModel !== undefined) {
const model = options.embeddingModel.trim();
if (model.length === 0) {
cliError(' --embedding-model must not be empty.\n');
process.exitCode = 1;
return;
}
process.env.GITNEXUS_EMBEDDING_MODEL = model;
}
if (options.embeddingAuthToken !== undefined) {
const token = options.embeddingAuthToken.trim();
if (token.length === 0) {
cliError(' --embedding-auth-token must not be empty.\n');
process.exitCode = 1;
return;
}
// Never log the token value.
process.env.GITNEXUS_EMBEDDING_API_KEY = token;
}
// Validate + normalize dims through the same shared helper the preAction
// hook uses, so the CLI path, this direct/programmatic-call path, schema.ts
// (parseInt) and http-client (/^\d+$/) all agree on one canonical value.
if (options.embeddingDims !== undefined) {
const dims = normalizeEmbeddingDims(options.embeddingDims);
if (dims === null) {
cliError(` ${EMBEDDING_DIMS_ERROR}\n`);
process.exitCode = 1;
return;
}
process.env.GITNEXUS_EMBEDDING_DIMS = dims;
}
// Custom-endpoint UX, emitting at most ONE message that reflects THIS run's
// intent (not ambient env). Order matters — the first matching branch wins:
// 1. flags given but --embeddings absent: the endpoint won't be used, so
// say only that (no contradictory "Using…" line).
// 2. embeddings enabled + a complete endpoint (flags or env): confirm it,
// masking the URL via safeUrl() since a base URL may carry credentials
// in userinfo (http://user:pass@host) or a query token (?api_key=…)
// that must not land in stdout/CI logs. The auth token is never printed.
// 3. embeddings enabled but only one of URL/MODEL supplied via flags:
// http-client.isHttpMode() needs BOTH, so warn about the fallback.
// Gating on embeddingsEnabled also stops the old behaviour of printing
// "Using custom embedding endpoint" on every analyze run whenever the env
// vars happened to be set.
if (anyHttpEmbedFlag && !embeddingsEnabled) {
console.log(
' Note: --embedding-* flags only apply when --embeddings is also passed; ' +
'no embeddings will be generated this run.\n',
);
} else if (
embeddingsEnabled &&
process.env.GITNEXUS_EMBEDDING_URL &&
process.env.GITNEXUS_EMBEDDING_MODEL
) {
console.log(
` Using custom embedding endpoint: ${safeUrl(process.env.GITNEXUS_EMBEDDING_URL)} ` +
`(model: ${process.env.GITNEXUS_EMBEDDING_MODEL})\n`,
);
} else if (
embeddingsEnabled &&
anyHttpEmbedFlag &&
(process.env.GITNEXUS_EMBEDDING_URL || process.env.GITNEXUS_EMBEDDING_MODEL)
) {
console.log(
' Note: custom HTTP embeddings require BOTH --embedding-base-url and --embedding-model ' +
'(or the matching env vars). Falling back to local ONNX embeddings.\n',
);
}
if (options.repairFts && options.force) {
cliError(
' Cannot combine `--repair-fts` with `--force`. ' +
@ -1286,6 +1403,7 @@ const analyzeCommandImpl = async (
// Mirror runFullAnalysis `noStats` bridge (#1477) — same expression;
// exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
noStats: options.stats === false,
hasPdg: options.pdg === true,
},
);
}

View file

@ -0,0 +1,41 @@
/**
* Strict positive-integer normalization for the `--embedding-dims` flag /
* `GITNEXUS_EMBEDDING_DIMS` value.
*
* Single source of truth shared by two write paths:
* 1. the `analyze` `preAction` hook (CLI path) — it must set the env var
* BEFORE `schema.ts` reads `GITNEXUS_EMBEDDING_DIMS` at module-load time
* (the static import chain `analyze.ts → run-analyze.ts → schema.ts`
* bakes `FLOAT[dims]` into the vector-table DDL), and
* 2. `analyzeCommandImpl` (direct / programmatic-call path, which bypasses
* the commander hook).
*
* Keep this module dependency-free. `index.ts` imports it eagerly, so pulling
* in anything that transitively loads `schema.ts` (e.g. `analyze.ts`) — or
* even `cli-message.ts`, which drags in the logger + i18n — would defeat the
* lazy `import('./analyze.js')` the hook exists to enable. Callers print the
* error themselves (the hook to stderr, the impl via `cliError`).
*
* Trim-then-validate, matching the sibling URL/MODEL/TOKEN flags: surrounding
* whitespace is tolerated, but the remaining value must be all digits and
* `> 0`. This rejects scientific notation (`1e3`), hex (`0x10`), fractions
* (`3.5`), signs (`+5`/`-5`), and trailing junk (`4096x`) so the three
* downstream readers — `schema.ts` (`parseInt`), `http-client.ts` (`/^\d+$/`),
* and this helper — all agree on one canonical value. Without it, `1e3` parsed
* to `FLOAT[1]` at module-load but requested 1000-dim vectors at runtime.
*/
/** Shared error message so both call sites surface identical wording. */
export const EMBEDDING_DIMS_ERROR = '--embedding-dims must be a positive integer.';
/**
* Returns the canonical positive-integer string (e.g. `"007"` → `"7"`), or
* `null` when the input is not a strict positive integer.
*/
export const normalizeEmbeddingDims = (raw: string): string | null => {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed) || parseInt(trimmed, 10) <= 0) {
return null;
}
return String(parseInt(trimmed, 10));
};

View file

@ -47,6 +47,7 @@ const COMMAND_DESCRIPTION_KEYS = {
const OPTION_DESCRIPTION_KEYS = {
'|-V, --version': 'help.option.version',
'setup|-c, --coding-agent <agents>': 'help.option.setup.codingAgent',
'ci-setup|--ci <system>': 'help.option.ciSetup.ci',
'ci-setup|--deploy <target>': 'help.option.ciSetup.deploy',
'ci-setup|--port <port>': 'help.option.ciSetup.port',
@ -79,6 +80,10 @@ const OPTION_DESCRIPTION_KEYS = {
'analyze|--embedding-device <device>': 'help.option.analyze.embeddingDevice',
'index|-f, --force': 'help.option.index.force',
'index|--allow-non-git': 'help.option.index.allowNonGit',
'mcp|--http': 'help.option.mcp.http',
'mcp|-p, --port <port>': 'help.option.port',
'mcp|--host <host>': 'help.option.mcp.host',
'mcp|--auth-token <token>': 'help.option.mcp.authToken',
'serve|-p, --port <port>': 'help.option.port',
'serve|--host <host>': 'help.option.serve.host',
'uninstall|-f, --force': 'help.option.uninstall.force',

View file

@ -124,7 +124,8 @@ export const en = {
'help.command.index.description':
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
'help.command.serve.description': 'Start local HTTP server for web UI connection',
'help.command.mcp.description': 'Start MCP server (stdio) — serves all indexed repos',
'help.command.mcp.description':
'Start MCP server. Default: stdio. Use --http for a remote HTTP server (Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).',
'help.command.list.description': 'List all indexed repositories',
'help.command.status.description': 'Show index status for current repo',
'help.command.doctor.description':
@ -161,6 +162,8 @@ export const en = {
'Cross-repo impact for a symbol in one member repo of a group',
'help.command.group.query.description': 'Search execution flows across all repos in a group',
'help.command.group.contracts.description': 'Inspect Contract Registry',
'help.option.setup.codingAgent':
'Configure only these coding agents (comma-separated or repeatable)',
'help.option.ciSetup.ci': 'CI/CD system: github-actions, azure-devops, or both',
'help.option.ciSetup.deploy': 'Deploy target: docker, azure-container-app, or both',
'help.option.ciSetup.port': 'Host port to bind (container always runs on 4747)',
@ -209,6 +212,11 @@ export const en = {
'help.option.index.allowNonGit': 'Allow registering folders that are not Git repositories',
'help.option.port': 'Port number',
'help.option.serve.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)',
'help.option.mcp.http': 'Serve MCP over HTTP instead of stdio (for remote clients)',
'help.option.mcp.host':
'HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.',
'help.option.mcp.authToken':
'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.',
'help.option.force.confirmation': 'Skip confirmation prompt',
'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
'help.option.clean.all': 'Clean all indexed repos',

View file

@ -125,7 +125,8 @@ export const zhCN = {
'help.command.analyze.description': '索引仓库(完整分析)',
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
'help.command.mcp.description': '启动 MCP 服务器(stdio)— 提供所有已索引仓库',
'help.command.mcp.description':
'启动 MCP 服务器。默认为 stdio。使用 --http 启动远程 HTTP 服务器(Streamable HTTP: POST /mcp + 遗留 SSE: GET /sse, POST /messages)。',
'help.command.list.description': '列出所有已索引仓库',
'help.command.status.description': '显示当前仓库的索引状态',
'help.command.doctor.description': '显示运行平台能力和嵌入配置',
@ -154,6 +155,7 @@ export const zhCN = {
'help.command.group.impact.description': '分析仓库组中某个成员仓库符号的跨仓库影响',
'help.command.group.query.description': '跨仓库组所有仓库搜索执行流程',
'help.command.group.contracts.description': '查看 Contract Registry',
'help.option.setup.codingAgent': '仅配置这些编码代理(逗号分隔或重复传入)',
'help.option.ciSetup.ci': 'CI/CD 系统:github-actions、azure-devops 或 both',
'help.option.ciSetup.deploy': '部署目标:docker、azure-container-app 或 both',
'help.option.ciSetup.port': '绑定的主机端口(容器始终运行在 4747)',
@ -197,6 +199,11 @@ export const zhCN = {
'help.option.index.allowNonGit': '允许注册非 Git 仓库文件夹',
'help.option.port': '端口号',
'help.option.serve.host': '绑定地址(默认:127.0.0.1;远程访问可用 0.0.0.0)',
'help.option.mcp.http': '使用 HTTP 代替 stdio 提供 MCP 服务(适合远程客户端)',
'help.option.mcp.host':
'HTTP 绑定地址(仅与 --http 搭配使用)。默认:127.0.0.1(回环)。使用 0.0.0.0 向所有接口开放。',
'help.option.mcp.authToken':
'要求 Authorization 头携带此 Bearer Token(仅与 --http 搭配使用);也可通过 GITNEXUS_MCP_AUTH_TOKEN 环境变量设置。非回环绑定(--host 0.0.0.0/::)时必填,否则拒绝启动。',
'help.option.force.confirmation': '跳过确认提示',
'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
'help.option.clean.all': '清理所有已索引仓库',

View file

@ -6,6 +6,7 @@
import { Command } from 'commander';
import { createRequire } from 'node:module';
import { createLazyAction, createLbugLazyAction } from './lazy-action.js';
import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.js';
import { registerGroupCommands } from './group.js';
import { localizeCliHelp } from './help-i18n.js';
import { t } from './i18n/index.js';
@ -14,6 +15,10 @@ const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
const program = new Command();
function collectCodingAgents(value: string, previous: string[] | undefined): string[] {
return [...(previous ?? []), ...value.split(',')];
}
program.name('gitnexus').description('GitNexus local CLI and MCP server').version(pkg.version);
program
@ -21,6 +26,11 @@ program
.description(
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, Codex',
)
.option(
'-c, --coding-agent <agents>',
'Configure only these coding agents (comma-separated or repeatable)',
collectCodingAgents,
)
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
program
@ -31,6 +41,15 @@ program
.option('-f, --force', 'Apply the changes (default is a dry-run preview)')
.action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));
// Baseline of GITNEXUS_EMBEDDING_DIMS captured by the analyze preAction hook
// before it overwrites the var, so the postAction hook can restore it. The
// analyzeCommand env snapshot is taken AFTER this hook runs, so it cannot undo
// the hook's write on its own — without this restore a CLI --embedding-dims
// would leak into a later in-process program.parseAsync (tests / long-running
// hosts). Single-shot CLI exits the process, making the restore a no-op there.
let dimsEnvBaseline: string | undefined;
let dimsEnvCaptured = false;
program
.command('ci-setup')
.description(
@ -128,7 +147,63 @@ program
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
.option('--embedding-device <device>', 'Embedding device: auto, cpu, dml, cuda, or wasm')
.option(
'--embedding-base-url <url>',
'OpenAI-compatible embeddings base URL including the /v1 suffix ' +
'(e.g. http://10.219.32.29:11434/v1 for Ollama). Overrides GITNEXUS_EMBEDDING_URL.',
)
.option(
'--embedding-model <model>',
'Embedding model name (e.g. qwen3-embedding:8b). Overrides GITNEXUS_EMBEDDING_MODEL.',
)
.option(
'--embedding-auth-token <token>',
'Bearer token for the embeddings endpoint (omit for unauthenticated servers like Ollama). ' +
'Overrides GITNEXUS_EMBEDDING_API_KEY.',
)
.option(
'--embedding-dims <number>',
'Embedding vector dimensions (positive integer; e.g. 4096 for Qwen3-Embedding-8B). ' +
'Must match what the index was built with. Overrides GITNEXUS_EMBEDDING_DIMS.',
)
.addHelpText('after', () => t('help.analyze.environment'))
.hook('preAction', (thisCommand: Command) => {
// ONLY GITNEXUS_EMBEDDING_DIMS must be set here: schema.ts reads it at
// module-load time during the lazy import('./analyze.js') below (via the
// static chain analyze.ts → run-analyze.ts → schema.ts), so deferring to
// analyzeCommandImpl would be too late. URL / MODEL / API_KEY are read
// lazily at runtime (readConfig), so analyzeCommandImpl is their sole
// setter — keeping them out of this hook means they fall under the impl's
// env snapshot/restore and don't leak across in-process invocations.
const dimsOpt = thisCommand.opts()['embeddingDims'];
if (dimsOpt !== undefined) {
// Validate + normalize BEFORE writing the env var: schema.ts throws on a
// bad value at module-load, which — on the synchronous program.parse()
// path, before the analyze fatal-handlers are installed — would surface
// as a raw unhandled rejection instead of this friendly message.
const dims = normalizeEmbeddingDims(String(dimsOpt));
if (dims === null) {
process.stderr.write(`\n ${EMBEDDING_DIMS_ERROR}\n\n`);
process.exit(1);
}
dimsEnvBaseline = process.env.GITNEXUS_EMBEDDING_DIMS;
dimsEnvCaptured = true;
process.env.GITNEXUS_EMBEDDING_DIMS = dims;
}
})
.hook('postAction', () => {
// Restore the pre-hook GITNEXUS_EMBEDDING_DIMS so a CLI override doesn't
// persist into a later program.parseAsync in the same process. (Fires on a
// microtask after a successful parse; the crash path never reaches here,
// but the hook validates dims before writing, so there's nothing to undo.)
if (!dimsEnvCaptured) return;
dimsEnvCaptured = false;
if (dimsEnvBaseline === undefined) {
delete process.env.GITNEXUS_EMBEDDING_DIMS;
} else {
process.env.GITNEXUS_EMBEDDING_DIMS = dimsEnvBaseline;
}
})
.action(createLbugLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
program
@ -149,7 +224,21 @@ program
program
.command('mcp')
.description('Start MCP server (stdio) — serves all indexed repos')
.description(
'Start MCP server. Default: stdio. Use --http for a remote HTTP server ' +
'(Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).',
)
.option('--http', 'Serve MCP over HTTP instead of stdio (for remote clients)')
.option('-p, --port <port>', 'HTTP port (only with --http). Default: 3000', '3000')
.option(
'--host <host>',
'HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.',
'127.0.0.1',
)
.option(
'--auth-token <token>',
'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.',
)
.action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand'));
program

View file

@ -29,7 +29,12 @@
import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js';
export const mcpCommand = async () => {
export const mcpCommand = async (options?: {
http?: boolean;
port?: string;
host?: string;
authToken?: string;
}) => {
// Install the global stdout sentinel as the very first thing — before
// ANY other module loads. The static-import closure above is leaf-only
// (stdio-context → stdio-capture, zero non-`node:` deps), so this is
@ -80,6 +85,37 @@ export const mcpCommand = async () => {
);
}
// Start HTTP server or fall back to stdio (default).
if (options?.http) {
// Dynamically import the HTTP transport module AFTER the sentinel installs.
// http-transport.ts pulls in express/cors/MCP SDK HTTP transport; these must
// not load before installGlobalStdoutSentinel() runs (see module doc above).
const port = Number(options.port ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
logger.error(
{ port: options.port },
`Invalid --port value: "${options.port ?? ''}". Must be an integer between 1 and 65535.`,
);
process.exit(1);
}
// Dynamic import keeps express/cors out of mcp.ts's static graph (stdio sentinel).
const { startMcpHttpServer, resolveAuthToken } = await import('../mcp/http-transport.js');
try {
await startMcpHttpServer(backend, {
port,
host: options.host ?? '127.0.0.1',
authToken: resolveAuthToken(options.authToken, process.env),
});
} catch (err) {
logger.error(
{ err: err instanceof Error ? err.message : err },
'Failed to start the MCP HTTP server',
);
process.exit(1);
}
return;
}
// Start MCP server (serves all repos, discovers new ones lazily)
await startMCPServer(backend);
};

View file

@ -21,6 +21,7 @@ import {
skillTarget,
hookTarget,
detectIndentation,
type EditorId,
} from './editor-targets.js';
const __filename = fileURLToPath(import.meta.url);
@ -85,6 +86,37 @@ interface SetupResult {
errors: string[];
}
const CODING_AGENT_IDS = {
cursor: 'cursor',
claude: 'claude',
antigravity: 'antigravity',
opencode: 'opencode',
codex: 'codex',
} as const satisfies Record<EditorId, EditorId>;
const SUPPORTED_CODING_AGENTS = Object.values(CODING_AGENT_IDS);
function selectedCodingAgents(values: string[] | string | undefined): Set<EditorId> | null {
if (values == null) return new Set(SUPPORTED_CODING_AGENTS);
const rawValues = Array.isArray(values) ? values : [values];
const requested = rawValues
.flatMap((value) => value.split(','))
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
const invalid = requested.filter(
(value): value is string => !SUPPORTED_CODING_AGENTS.includes(value as EditorId),
);
if (requested.length === 0 || invalid.length > 0) {
const detail =
requested.length === 0
? 'No coding agents were provided.'
: `Unknown: ${invalid.join(', ')}.`;
process.stderr.write(`${detail} Valid values: ${SUPPORTED_CODING_AGENTS.join(', ')}.\n`);
process.exitCode = 1;
return null;
}
return new Set(requested as EditorId[]);
}
/**
* Resolve the absolute path to the `gitnexus` binary if it's installed
* globally (or via npm -g / yarn global). Returns null when not found.
@ -968,7 +1000,11 @@ async function installCodexSkills(result: SetupResult): Promise<void> {
// ─── Main command ──────────────────────────────────────────────────
export const setupCommand = async () => {
export const setupCommand = async (options?: { codingAgent?: string[] | string }) => {
const explicitSelection = options?.codingAgent != null;
const selected = selectedCodingAgents(options?.codingAgent);
if (!selected) return;
console.log('');
console.log(' GitNexus Setup');
console.log(' ==============');
@ -985,20 +1021,24 @@ export const setupCommand = async () => {
};
// Detect and configure each editor's MCP
await setupCursor(result);
await setupClaudeCode(result);
await setupAntigravity(result);
await setupOpenCode(result);
await setupCodex(result);
if (selected.has('cursor')) await setupCursor(result);
if (selected.has('claude')) await setupClaudeCode(result);
if (selected.has('antigravity')) await setupAntigravity(result);
if (selected.has('opencode')) await setupOpenCode(result);
if (selected.has('codex')) await setupCodex(result);
// Install global skills for platforms that support them
await installClaudeCodeSkills(result);
await installClaudeCodeHooks(result);
await installAntigravitySkills(result);
await installAntigravityHooks(result);
await installCursorSkills(result);
await installOpenCodeSkills(result);
await installCodexSkills(result);
if (selected.has('claude')) {
await installClaudeCodeSkills(result);
await installClaudeCodeHooks(result);
}
if (selected.has('antigravity')) {
await installAntigravitySkills(result);
await installAntigravityHooks(result);
}
if (selected.has('cursor')) await installCursorSkills(result);
if (selected.has('opencode')) await installOpenCodeSkills(result);
if (selected.has('codex')) await installCodexSkills(result);
// Print results
if (result.configured.length > 0) {
@ -1032,10 +1072,17 @@ export const setupCommand = async () => {
console.log(
` Skills installed to: ${result.configured.filter((c) => c.includes('skills')).length > 0 ? result.configured.filter((c) => c.includes('skills')).join(', ') : 'none'}`,
);
const configurationSucceeded = result.configured.length > 0;
if (explicitSelection && !configurationSucceeded) {
process.stderr.write('None of the explicitly selected coding agents were configured.\n');
process.exitCode = 1;
}
console.log('');
console.log(' Next steps:');
console.log(' 1. cd into any git repo');
console.log(' 2. Run: gitnexus analyze');
console.log(' 3. Open the repo in your editor — MCP is ready!');
if (configurationSucceeded) {
console.log(' Next steps:');
console.log(' 1. cd into any git repo');
console.log(' 2. Run: gitnexus analyze');
console.log(' 3. Open the repo in your editor — MCP is ready!');
}
console.log('');
};

View file

@ -651,9 +651,12 @@ const renderSkillMarkdown = (
lines.push('');
lines.push(`1. \`context({name: "${firstEntry}"})\` \u2014 see callers and callees`);
lines.push(
`2. \`query({query: "${community.label.toLowerCase()}"})\` \u2014 find related execution flows`,
`2. \`query({search_query: "${community.label.toLowerCase()}"})\` \u2014 find related execution flows`,
);
lines.push('3. Read key files listed above for implementation details');
lines.push(
'4. `explain({target: "<file or symbol>"})` — persisted taint findings (source→sink data flows), when indexed with `--pdg`',
);
lines.push('');
return lines.join('\n');

View file

@ -76,7 +76,8 @@ export async function queryCommand(
const backend = await getBackend();
const result = await backend.callTool('query', {
query: queryText,
// #2175: canonical param is search_query; the backend still accepts legacy "query".
search_query: queryText,
task_context: options?.context,
goal: options?.goal,
limit: options?.limit ? parseInt(options.limit) : undefined,
@ -204,7 +205,8 @@ export async function cypherCommand(
const backend = await getBackend();
const result = await backend.callTool('cypher', {
query,
// #2175: canonical param is statement; the backend still accepts legacy "query".
statement: query,
repo: options?.repo,
branch: options?.branch,
});

View file

@ -70,10 +70,12 @@ export const isHttpMode = (): boolean => readConfig() !== null;
export const getHttpDimensions = (): number | undefined => readConfig()?.dimensions;
/**
* Return a safe representation of a URL for error messages.
* Strips query string (may contain tokens) and userinfo.
* Return a safe representation of a URL for logs and error messages.
* Strips query string (may contain tokens) and userinfo (may contain
* credentials), keeping protocol + host + path. Exported so the CLI's
* custom-endpoint confirmation can mask the same way.
*/
const safeUrl = (url: string): string => {
export const safeUrl = (url: string): string => {
try {
const u = new URL(url);
return `${u.protocol}//${u.host}${u.pathname}`;

View file

@ -49,6 +49,10 @@ export interface GroupToolPort {
query(
repo: GroupRepoHandle,
params: {
// GroupService always supplies `query` as a string (it resolves the #2175
// search_query alias before calling the port), so the port contract keeps it
// required here even though the LocalBackend implementation accepts the wider
// `{ query?, search_query? }` shape for the direct MCP callTool path.
query: string;
task_context?: string;
goal?: string;

Binary file not shown.

View file

@ -54,6 +54,16 @@ import type { KnowledgeGraph } from '../graph/types.js';
const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process';
/**
* Relationship types whose VALIDITY is a whole-program property, not a
* function of their endpoints' files (#2084 M4 U6). `TAINT_PATH` (cross-
* function taint) can be invalidated by a change to an INTERMEDIATE function
* on a third file, so the endpoint-writability rule below would skip a stale
* A→C edge. These are always extracted (and the orchestrator delete-alls them
* first, like Community/Process) so they rebuild from the fresh graph.
*/
const isGraphWideRelType = (type: string): boolean => type === 'TAINT_PATH';
/**
* Build a Map<nodeId, filePath> for every File-bound node in the graph.
* Graph-wide nodes (Community/Process) have no filePath and are filtered.
@ -84,7 +94,11 @@ export const extractChangedSubgraph = (
});
fullGraph.forEachRelationship((r: GraphRelationship) => {
if (writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.targetId)) {
if (
writableNodeIds.has(r.sourceId) ||
writableNodeIds.has(r.targetId) ||
isGraphWideRelType(r.type)
) {
sub.addRelationship(r);
}
});

View file

@ -0,0 +1,172 @@
/**
* Control dependence (#2085 M5 U3) — Ferrante, Ottenstein & Warren §3.1.1 over
* the post-dominator tree. A block `dependent` is control-dependent on a branch
* block `controller` when `controller` decides whether `dependent` executes:
* formally, there is a CFG edge `controller → B` such that `dependent`
* post-dominates `B` but does NOT strictly post-dominate `controller`.
*
* Construction (§3.1.1): for each CFG edge `(A, B)` where `B` does NOT
* post-dominate `A`, walk UP the post-dom tree from `B` to (but not including)
* `ipdom(A)`; every block on that path is control-dependent on `A`. The branch
* SENSE of the edge ('T' | 'F') becomes the edge label (KTD4 / KTD3 — it rides
* the persisted relation's `reason` column).
*
* PURE AND DETERMINISTIC (mirrors post-dominators.ts / reaching-defs.ts): no
* graph, no logger, importable outside the worker; output is deduped per
* (controller, dependent, label) and sorted, so snapshot tests and
* content-derived edge ids are stable. The loop header legitimately appears as
* control-dependent on ITSELF (`controller === dependent`) — the loop predicate
* gates its own re-execution; this is standard PDG behavior, not a bug.
*/
import {
computePostDominators,
postDominates,
NO_IPDOM,
type PostDomTree,
} from './post-dominators.js';
import type { CfgEdgeKind, FunctionCfg } from './types.js';
export type CdgLabel = 'T' | 'F';
export interface ControlDepEdge {
/** The branch block whose outcome controls `dependentBlock`. */
readonly controllerBlock: number;
/** The block that executes only because `controllerBlock` took `label`. */
readonly dependentBlock: number;
/** Branch sense of the controlling CFG edge — see {@link branchSense}. */
readonly label: CdgLabel;
}
export interface ControlDepResult {
/** Deduped, sorted (controller, dependent, label) control-dependence edges. */
readonly edges: readonly ControlDepEdge[];
/**
* True when the `maxEdges` ceiling was reached; `edges` is then a
* deterministic prefix (CFG-edge iteration order, sorted), never a silent
* drop. Mirrors {@link computeReachingDefs}'s `truncated`.
*/
readonly truncated: boolean;
}
/**
* Per-controller branch-arm senses, derived from the controller block's OUTGOING
* edge kinds. The CFG edge kind alone cannot name a branch sense: the M1 visitor
* emits an explicit `cond-true`/`cond-false` only for a `then`/`else` arm, but a
* condition's FALL-THROUGH false arm (no-`else`, or a guard's `if (!ok) return;`)
* is wired as `seq`, and an `if` ending a loop body falls through as `loop-back`
* — while a `do/while` bottom-test's TRUE arm is also a `loop-back`. So `seq`
* and `loop-back` are genuinely ambiguous in isolation (issue #2188 F1).
*
* The fix reads the sense from the CONTROLLER's structure: a 2-way branch emits
* exactly one explicitly-sensed arm (`cond-true`/`switch-case` ⇒ true, or
* `cond-false` ⇒ false), and its other (ambiguous) arm is the COMPLEMENT. This
* map records which explicit senses each block emits so {@link labelFor} can
* resolve an ambiguous edge against its sibling.
*/
interface ArmSenses {
hasTrueArm: boolean; // emits a cond-true or switch-case edge
hasFalseArm: boolean; // emits a cond-false edge
}
function buildArmSenses(cfg: FunctionCfg): ArmSenses[] {
const n = cfg.blocks.length;
const senses: ArmSenses[] = Array.from({ length: n }, () => ({
hasTrueArm: false,
hasFalseArm: false,
}));
for (const e of cfg.edges) {
if (e.from < 0 || e.from >= n) continue;
if (e.kind === 'cond-true' || e.kind === 'switch-case') senses[e.from].hasTrueArm = true;
else if (e.kind === 'cond-false') senses[e.from].hasFalseArm = true;
}
return senses;
}
/**
* The CDG label ('T'|'F') for a control-dependence edge, given the controlling
* block's arm senses. An explicitly-sensed edge is taken at face value; an
* ambiguous fall-through edge (`seq`/`loop-back`/`fallthrough`/jump) is the
* COMPLEMENT of the controller's explicit sibling arm. Per-case `switch` value
* labels are deferred to #2086 — every `switch-case` is 'T' in M5.
*/
function labelFor(kind: CfgEdgeKind, controller: ArmSenses): CdgLabel {
if (kind === 'cond-true' || kind === 'switch-case') return 'T';
if (kind === 'cond-false') return 'F';
// Ambiguous structural kind: take the complement of the controller's explicit
// arm. A block with a true arm reaches here via its false fall-through; a
// do/while bottom-test (false arm = cond-false) reaches here via its true
// loop-back. With neither explicit arm (a degenerate / exit-unreachable
// region — see #2188 F2, where the dependence itself is unsound) the sense is
// indeterminate; default 'F' since fall-through is the common case.
if (controller.hasTrueArm) return 'F';
if (controller.hasFalseArm) return 'T';
return 'F';
}
/**
* Compute control-dependence edges for one function's CFG. `postDom` may be
* supplied to reuse an already-built tree; otherwise it is computed. See the
* module doc for the purity/determinism contract.
*/
export function computeControlDependence(
cfg: FunctionCfg,
postDom?: PostDomTree,
// Heap-safety ceiling on materialized edges, mirroring computeReachingDefs'
// `maxFacts` (#2188 review): the pre-dedup walk is O(edges × post-dom depth),
// so bound it before it can spike. `0` ⇒ unbounded. On overflow `edges` is a
// deterministic prefix and `truncated` is set — never a silent drop.
maxEdges: number = 0,
): ControlDepResult {
const tree = postDom ?? computePostDominators(cfg);
const { ipdom } = tree;
const n = cfg.blocks.length;
const armSenses = buildArmSenses(cfg);
const cap = maxEdges > 0 ? maxEdges : Infinity;
const out: ControlDepEdge[] = [];
const seen = new Set<string>();
let truncated = false;
scan: for (const e of cfg.edges) {
const a = e.from;
const b = e.to;
if (a < 0 || a >= n || b < 0 || b >= n) continue;
// No control dependence when B post-dominates A — every path leaving A
// through this edge still reaches B, so A does not decide B's execution.
// This guard is exactly AC2: a dependence exists IFF post-dominance fails.
if (postDominates(tree, b, a)) continue;
// Sense is read from the CONTROLLER's arms, not this edge's kind alone —
// seq/loop-back fall-through false arms would otherwise mislabel as 'T'
// (#2188 F1).
const label = labelFor(e.kind, armSenses[a]);
const stop = ipdom[a]; // walk up to ipdom(A), EXCLUSIVE (NO_IPDOM ⇒ to root)
let cur = b;
let steps = 0;
// `steps <= n` is defensive — the ipdom chain is a finite tree.
while (cur !== NO_IPDOM && cur !== stop && steps <= n) {
const key = `${a}:${cur}:${label}`;
if (!seen.has(key)) {
// Check BEFORE pushing so `truncated` means a genuine overflow (a new
// unique edge had to be dropped), not merely "reached the ceiling" —
// exactly `cap` edges is a full, non-truncated result.
if (out.length >= cap) {
truncated = true;
break scan;
}
seen.add(key);
out.push({ controllerBlock: a, dependentBlock: cur, label });
}
cur = ipdom[cur];
steps += 1;
}
}
out.sort(
(x, y) =>
x.controllerBlock - y.controllerBlock ||
x.dependentBlock - y.dependentBlock ||
(x.label < y.label ? -1 : x.label > y.label ? 1 : 0),
);
return { edges: out, truncated };
}

View file

@ -21,6 +21,12 @@
import type { KnowledgeGraph } from '../../graph/types.js';
import { generateId } from '../../../lib/utils.js';
import { computeReachingDefs } from './reaching-defs.js';
import { computeControlDependence } from './control-dependence.js';
import {
computePostDominators,
isExitReachableFromAllBlocks,
NO_IPDOM,
} from './post-dominators.js';
import type { BindingEntry, FunctionCfg } from './types.js';
/**
@ -41,6 +47,38 @@ export const DEFAULT_MAX_CFG_EDGES_PER_FUNCTION = 5000;
*/
export const DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION = 4000;
/**
* Default per-function CDG edge cap (#2085 M5). CDG edge count is bounded by
* (blocks × control-nesting-depth) — comparable to the CFG edge count — so it
* reuses the CFG default of 5000. Counts DEDUPED (controller, dependent, label)
* edges (the pure {@link computeControlDependence} already dedups). `0` ⇒
* unlimited; `undefined` ⇒ this default. Folded into the `RepoMeta.pdg` stamp
* (U5) so introducing CDG forces a full writeback for pre-CDG `--pdg` indexes.
*/
export const DEFAULT_PDG_MAX_CDG_EDGES_PER_FUNCTION = 5000;
/**
* Heap-safety ceiling on {@link computeControlDependence}'s pre-dedup
* materialization (#2188 review). The walk is O(edges × post-dom depth), and its
* `out` IS the deduped-edge quantity the per-function cap trims — so, UNLIKE
* REACHING_DEF's facts ceiling, this is deliberately NOT derived from the
* runtime edge cap (doing so would pre-truncate the very set the cap reports on,
* losing the exact dropped count). A fixed, generous multiple of the default
* edge cap: far above any real function — a catastrophe backstop only. When hit,
* the per-function cap reporting plus the `truncated` flag keep it observable
* (never a silent drop).
*/
export const DEFAULT_PDG_MAX_CDG_MATERIALIZATION_PER_FUNCTION =
8 * DEFAULT_PDG_MAX_CDG_EDGES_PER_FUNCTION;
/**
* Env flag that additionally emits diagnostic `POST_DOMINATE` edges
* (block → its immediate post-dominator) alongside CDG (#2085 M5 KTD8). Off in
* every normal `--pdg` run — these are for inspecting the post-dom tree, not a
* queryable product surface. Accepts `1`/`true` (case-insensitive).
*/
export const POST_DOMINATE_DEBUG_ENV = 'GITNEXUS_PDG_EMIT_POST_DOMINATE';
/**
* Fact-materialization headroom over the edge cap (#2082 M2 U3/F3): facts are
* O(defs×uses) BY SPEC in merge-heavy code, and the edge cap alone bounds the
@ -65,7 +103,13 @@ export interface CfgEmitResult {
cappedFunctions: number;
}
const basicBlockId = (
/**
* The single BasicBlock id template (module doc). Exported for the M3 taint
* emit path (taint/emit.ts), whose TAINTED/SANITIZES edges must address the
* SAME persisted block nodes — a re-derived copy of this template would
* silently dangle the moment either drifted.
*/
export const basicBlockId = (
filePath: string,
functionStartLine: number,
functionStartColumn: number,
@ -259,9 +303,10 @@ export interface ReachingDefEmitResult {
* Stable identity for a binding inside edge ids (#2082 M2 KTD3/KTD9):
* `name:declLine:declCol` for declared bindings, `name@module` for synthetic
* ones. Distinct same-name bindings never share a key; identifier characters
* cannot contain the id separators.
* cannot contain the id separators. Exported for the M3 taint emit path —
* TAINTED/SANITIZES ids key bindings with the same discipline.
*/
const bindingKey = (b: BindingEntry): string =>
export const bindingKey = (b: BindingEntry): string =>
b.synthetic ? `${b.name}@module` : `${b.name}:${b.declLine}:${b.declColumn}`;
/**
@ -410,3 +455,153 @@ export function emitFileReachingDefs(
return result;
}
export interface CdgEmitResult {
/** Deduped (controller, dependent, label) CDG edges persisted. */
edges: number;
/** CDG edges dropped by the per-function edge cap. */
droppedEdges: number;
/** Functions that hit the CDG edge cap. */
cappedFunctions: number;
/** Diagnostic POST_DOMINATE edges emitted (0 unless the debug env is set). */
postDominateEdges: number;
/**
* Functions skipped because EXIT was not reachable from every entry-reachable
* block — post-dominance would be unsound (#2188 review). CFG/REACHING_DEF for
* those functions are kept; only their CDG projection is omitted.
*/
skippedUnsoundFunctions: number;
}
/** Whether the POST_DOMINATE debug env flag is enabled (`1`/`true`). */
const postDominateDebugEnabled = (): boolean => {
const v = process.env[POST_DOMINATE_DEBUG_ENV];
return v === '1' || v?.toLowerCase() === 'true';
};
/**
* Compute control dependence per function and persist the bounded CDG
* projection (#2085 M5 U4). Mirrors {@link emitFileReachingDefs}: the pure
* {@link computeControlDependence} already dedups to (controller, dependent,
* label), so the per-function cap applies to deduped edges and overflow logs
* one unconditional `onWarn` naming the dropped count — no silent truncation
* (R6/R7). The branch label ('T'|'F') rides the `reason` column (KTD3),
* mirroring how CFG stores its edge kind.
*
* When {@link POST_DOMINATE_DEBUG_ENV} is set, also emits diagnostic
* `POST_DOMINATE` edges (block → its immediate post-dominator). These are NOT
* capped or counted against the CDG budget — they exist only for inspecting the
* post-dom tree and never appear in a normal run.
*/
export function emitFileCdg(
graph: KnowledgeGraph,
cfgs: readonly FunctionCfg[],
maxEdgesPerFunction: number = DEFAULT_PDG_MAX_CDG_EDGES_PER_FUNCTION,
onWarn?: (message: string) => void,
): CdgEmitResult {
const result: CdgEmitResult = {
edges: 0,
droppedEdges: 0,
cappedFunctions: 0,
postDominateEdges: 0,
skippedUnsoundFunctions: 0,
};
const cap = maxEdgesPerFunction > 0 ? maxEdgesPerFunction : Infinity;
const emitPostDom = postDominateDebugEnabled();
for (const cfg of cfgs) {
const { filePath, functionStartLine, functionStartColumn } = cfg;
// Sound post-dominance requires EXIT reachable from every entry-reachable
// block (#2188 review). A CFG that violates it — a future visitor's
// multi-terminal / non-terminating shape — would yield a CDG that both
// drops real and invents spurious dependences, so skip CDG for it. CFG and
// REACHING_DEF (emitted elsewhere, independent of post-dominance) are kept.
if (!isExitReachableFromAllBlocks(cfg)) {
result.skippedUnsoundFunctions++;
onWarn?.(
`[cdg] ${filePath}:${functionStartLine}: EXIT not reachable from all ` +
`blocks — CDG skipped for this function (CFG/REACHING_DEF unaffected)`,
);
continue;
}
// Compute the post-dom tree once and feed it to the control-dependence
// pass (avoids recomputing it) and to the optional POST_DOMINATE emit.
const tree = computePostDominators(cfg);
// Bound the pre-dedup materialization (heap parity with REACHING_DEF). The
// fixed ceiling is a catastrophe backstop; the per-function edge cap below
// remains the reporting authority. A ceiling hit is surfaced, not silent.
const { edges: cdgEdges, truncated } = computeControlDependence(
cfg,
tree,
DEFAULT_PDG_MAX_CDG_MATERIALIZATION_PER_FUNCTION,
);
if (truncated) {
onWarn?.(
`[cdg] ${filePath}:${functionStartLine}: control-dependence materialization ` +
`ceiling (${DEFAULT_PDG_MAX_CDG_MATERIALIZATION_PER_FUNCTION}) reached — ` +
`edge counts for this function are a floor`,
);
}
let emittedForFn = 0;
for (const edge of cdgEdges) {
if (emittedForFn >= cap) {
const dropped = cdgEdges.length - emittedForFn;
result.droppedEdges += dropped;
result.cappedFunctions++;
onWarn?.(
`[cdg] ${filePath}:${functionStartLine}: per-function CDG edge cap ` +
`(${maxEdgesPerFunction}) reached — dropped ${dropped} of ${cdgEdges.length} edges`,
);
break;
}
const sourceId = basicBlockId(
filePath,
functionStartLine,
functionStartColumn,
edge.controllerBlock,
);
const targetId = basicBlockId(
filePath,
functionStartLine,
functionStartColumn,
edge.dependentBlock,
);
graph.addRelationship({
id: generateId(
'CDG',
`${filePath}:${functionStartLine}:${functionStartColumn}:` +
`${edge.controllerBlock}->${edge.dependentBlock}:${edge.label}`,
),
type: 'CDG',
sourceId,
targetId,
confidence: 1.0,
reason: edge.label, // 'T' | 'F' — queryable, mirrors CFG's kind-in-reason
});
result.edges++;
emittedForFn++;
}
if (emitPostDom) {
for (let b = 0; b < tree.ipdom.length; b++) {
const ip = tree.ipdom[b];
if (ip === NO_IPDOM) continue;
graph.addRelationship({
id: generateId(
'POST_DOMINATE',
`${filePath}:${functionStartLine}:${functionStartColumn}:${b}->${ip}`,
),
type: 'POST_DOMINATE',
sourceId: basicBlockId(filePath, functionStartLine, functionStartColumn, b),
targetId: basicBlockId(filePath, functionStartLine, functionStartColumn, ip),
confidence: 1.0,
reason: '',
});
result.postDominateEdges++;
}
}
}
return result;
}

View file

@ -0,0 +1,218 @@
/**
* Post-dominators (#2085 M5 U2) — the immediate-post-dominator tree of one
* function's CFG, the substrate the Ferrante control-dependence pass walks.
*
* A block `p` post-dominates a block `b` iff every path from `b` to the
* function EXIT passes through `p`. Post-dominators are exactly the DOMINATORS
* of the REVERSE CFG rooted at EXIT, so this is the Cooper–Harvey–Kennedy
* "A Simple, Fast Dominance Algorithm" run over reversed edges. KTD2 of the M5
* plan picks CHK over Lengauer–Tarjan: per-function CFGs are small and
* line-capped, CHK is near-linear in practice, and its iterative shape matches
* the reaching-defs fixpoint already in this module.
*
* PURE AND DETERMINISTIC (load-bearing, mirrors reaching-defs.ts): no graph, no
* logger, importable outside the worker; predecessors/successors are sorted and
* iteration is reverse-postorder so the `ipdom` array is identical across runs
* (snapshot tests and content-derived edge ids depend on it).
*
* The single-EXIT invariant the M1 TS visitor preserves (visitors/typescript.ts)
* makes EXIT the unique reverse-CFG root. Blocks that cannot reach EXIT in the
* forward CFG (an exit-less infinite loop) are not reverse-reachable from it and
* have NO post-dominator: their `ipdom` is {@link NO_IPDOM}. The control-
* dependence pass treats "no post-dominator" as "does not post-dominate" (KTD5).
*
* NOTE (issue #2188 F2): this is NOT a fully sound over-approximation. Inside a
* region where NO block reaches EXIT, every `ipdom` is `NO_IPDOM`, so the
* Ferrante walk degenerates to one edge per control point — it can both DROP a
* real control dependence and INVENT a spurious one. This does not arise for the
* current TS visitor (every loop is given a structural `header → loopExit`
* `cond-false` edge, so EXIT stays reverse-reachable), but it is unsound for
* hand-built CFGs and any future language visitor lacking that exit edge.
* Nontermination-sensitive post-dominance (a virtual root over the
* non-terminating SCCs) would be the correct treatment — tracked for follow-up.
*/
import type { FunctionCfg } from './types.js';
/**
* Sentinel `ipdom` value: the block has no immediate post-dominator. True for
* the EXIT block itself (the reverse-CFG root) and for any block that cannot
* reach EXIT. Chosen as -1 so the {@link postDominates} climb terminates
* naturally instead of self-looping on the root.
*/
export const NO_IPDOM = -1;
export interface PostDomTree {
/**
* `ipdom[b]` = the index of `b`'s immediate post-dominator, or
* {@link NO_IPDOM} when `b` has none (EXIT, or a block that cannot reach EXIT).
*/
readonly ipdom: readonly number[];
}
/**
* Compute the immediate-post-dominator tree for one function's CFG. See the
* module doc for the purity/determinism contract and EXIT-root assumptions.
*/
export function computePostDominators(cfg: FunctionCfg): PostDomTree {
const n = cfg.blocks.length;
const exit = cfg.exitIndex;
if (n === 0 || exit < 0 || exit >= n) {
return { ipdom: new Array<number>(n).fill(NO_IPDOM) };
}
// Forward adjacency (sorted for deterministic intersect order). The reverse
// CFG, on which we compute dominators, flips these: a node's reverse-CFG
// successors are its CFG predecessors, and its reverse-CFG predecessors
// (the "preds" CHK intersects over) are its CFG successors.
const cfgPreds: number[][] = Array.from({ length: n }, () => []);
const cfgSuccs: number[][] = Array.from({ length: n }, () => []);
for (const e of cfg.edges) {
if (e.from < 0 || e.from >= n || e.to < 0 || e.to >= n) continue;
cfgSuccs[e.from].push(e.to);
cfgPreds[e.to].push(e.from);
}
for (const l of cfgPreds) l.sort((a, b) => a - b);
for (const l of cfgSuccs) l.sort((a, b) => a - b);
// Postorder of the reverse CFG from EXIT (traversing CFG-predecessor edges).
// Iterative DFS with an explicit phase stack; children pushed in sorted order
// for determinism. postNum is the CHK comparison key: higher = closer to root.
const postNum = new Array<number>(n).fill(-1);
const postorder: number[] = [];
const visited = new Array<boolean>(n).fill(false);
const stack: { node: number; childIdx: number }[] = [{ node: exit, childIdx: 0 }];
visited[exit] = true;
while (stack.length) {
const top = stack[stack.length - 1];
const revSuccs = cfgPreds[top.node]; // reverse-CFG successors
if (top.childIdx < revSuccs.length) {
const next = revSuccs[top.childIdx];
top.childIdx += 1;
if (!visited[next]) {
visited[next] = true;
stack.push({ node: next, childIdx: 0 });
}
} else {
postNum[top.node] = postorder.length;
postorder.push(top.node);
stack.pop();
}
}
const rpo = [...postorder].reverse();
// CHK fixpoint. ipdom[exit] = exit DURING computation (the root dominates
// itself, so the intersect climb has a common terminus); it is reset to
// NO_IPDOM before returning so callers' climbs terminate at the root.
const ipdom = new Array<number>(n).fill(NO_IPDOM);
ipdom[exit] = exit;
const intersect = (a: number, b: number): number => {
let f1 = a;
let f2 = b;
while (f1 !== f2) {
while (postNum[f1] < postNum[f2]) f1 = ipdom[f1];
while (postNum[f2] < postNum[f1]) f2 = ipdom[f2];
}
return f1;
};
let changed = true;
while (changed) {
changed = false;
for (const b of rpo) {
if (b === exit) continue;
// CHK "predecessors in the reverse CFG" = this block's CFG successors.
// Fold only those already processed (ipdom assigned); RPO guarantees at
// least one for every block reverse-reachable from EXIT.
let newIpdom = NO_IPDOM;
for (const s of cfgSuccs[b]) {
if (ipdom[s] !== NO_IPDOM) {
newIpdom = newIpdom === NO_IPDOM ? s : intersect(s, newIpdom);
}
}
if (newIpdom !== NO_IPDOM && ipdom[b] !== newIpdom) {
ipdom[b] = newIpdom;
changed = true;
}
}
}
ipdom[exit] = NO_IPDOM; // root: no post-dominator above it
return { ipdom };
}
/**
* Does block `p` post-dominate block `b`? Climbs the post-dom tree from `b`
* toward EXIT and tests membership of `p`. Reflexive: a block post-dominates
* itself. A block with no post-dominator (EXIT, or one that cannot reach EXIT)
* is post-dominated only by itself. The step guard is purely defensive — the
* `ipdom` chain is a tree and always terminates at {@link NO_IPDOM}.
*/
export function postDominates(tree: PostDomTree, p: number, b: number): boolean {
const { ipdom } = tree;
const n = ipdom.length;
if (p < 0 || b < 0 || p >= n || b >= n) return false;
let cur = b;
let steps = 0;
while (cur !== NO_IPDOM && steps <= n) {
if (cur === p) return true;
cur = ipdom[cur];
steps += 1;
}
return false;
}
/**
* Precondition for SOUND post-dominance (#2188 review): EXIT must be reachable
* (forward) from every block that is itself reachable from ENTRY. When it
* fails — an entry-reachable region that cannot reach EXIT, e.g. a
* non-terminating loop or a multi-terminal CFG a future language visitor might
* emit — the EXIT-rooted reverse walk degenerates (every such block gets
* {@link NO_IPDOM}), which both DROPS real control dependences and INVENTS
* spurious ones (the unsoundness documented in the module header). Consumers
* ({@link emitFileCdg}) check this and skip CDG for the function rather than
* persist an unsound projection — CFG and REACHING_DEF, which do not depend on
* post-dominance, are unaffected.
*
* The current TS visitor always satisfies this (every loop is given a
* structural `header → loopExit` edge, keeping EXIT reverse-reachable), so this
* is a guard for future visitors and hand-built CFGs, not a behavior change
* today. Pure and O(V+E).
*/
export function isExitReachableFromAllBlocks(cfg: FunctionCfg): boolean {
const n = cfg.blocks.length;
if (n === 0) return true;
const { entryIndex, exitIndex } = cfg;
if (entryIndex < 0 || entryIndex >= n || exitIndex < 0 || exitIndex >= n) return false;
const succ: number[][] = Array.from({ length: n }, () => []);
const pred: number[][] = Array.from({ length: n }, () => []);
for (const e of cfg.edges) {
if (e.from < 0 || e.from >= n || e.to < 0 || e.to >= n) continue;
succ[e.from].push(e.to);
pred[e.to].push(e.from);
}
const reach = (start: number, adj: readonly number[][]): Uint8Array => {
const seen = new Uint8Array(n);
const stack = [start];
seen[start] = 1;
while (stack.length > 0) {
const b = stack.pop() as number;
for (const next of adj[b]) {
if (!seen[next]) {
seen[next] = 1;
stack.push(next);
}
}
}
return seen;
};
const fromEntry = reach(entryIndex, succ); // forward-reachable from ENTRY
const canReachExit = reach(exitIndex, pred); // can reach EXIT (reverse from EXIT)
for (let i = 0; i < n; i++) {
if (fromEntry[i] && !canReachExit[i]) return false;
}
return true;
}

View file

@ -43,6 +43,16 @@ export interface ProgramPoint {
readonly line: number;
}
/**
* Canonical `block:stmt` string key for a program point. Colon-separated to
* match the codebase's `blockIndex:stmtIndex` id conventions. Shared by the
* taint propagation engine (dedup/state keys) and the taint emit path
* (persisted edge-id material) so the two never drift.
*/
export function pointKey(p: ProgramPoint): string {
return `${p.blockIndex}:${p.stmtIndex}`;
}
/** One def→use fact: the definition at `def` reaches the use at `use`. */
export interface DefUseFact {
/** Index into {@link FunctionDefUse.bindings}. */

View file

@ -42,6 +42,92 @@ export interface BindingEntry {
readonly synthetic?: boolean;
}
/**
* One occurrence of a binding inside a call/new site's argument position
* (#2083 M3 U1). A bare `number` is a DIRECT occurrence (binding index into
* {@link FunctionCfg.bindings}); a `[bindingIdx, viaSiteIdx]` tuple marks an
* occurrence that reaches this argument THROUGH the nested site at
* `viaSiteIdx` (an index into the SAME statement's {@link StatementFacts.sites}
* array). The tag is load-bearing for sanitizer interposition (plan KTD4a):
* a flat per-arg binding set cannot distinguish `exec(escape(x))` (kill) from
* `exec(x)` (finding) — the single most common safe pattern would
* false-positive without it.
*/
export type SiteArgOccurrence = number | readonly [number, number];
/**
* One call site, constructor call, or value-position member read harvested
* from a statement (#2083 M3 U1, plan KTD2). Worker-side substrate for the M3
* taint pass: the M2 facts carry no expression structure, and the main thread
* cannot re-parse (the #1983 OOM shape). Spec-AGNOSTIC — records structure
* only, never source/sink/sanitizer-ness (matching is a main-thread concern).
*
* Integer indices: binding fields (`receiver`/`object`/`resultDefs`/arg
* occurrences) index {@link FunctionCfg.bindings}; site references (`parent`,
* via-tags) index the OWNING statement's `sites` array. JSON-plain; NO field
* here may be named `nodeId` (durable parsedfile-store reviver hazard — see
* {@link BindingEntry}).
*/
export interface SiteRecord {
readonly kind: 'call' | 'new' | 'member-read';
/**
* Dotted callee path for call/new sites whose callee chain is rooted at an
* identifier/`this`/`super` (`child_process.exec`, `req.body.toString`).
* Optional chaining is normalized (`a?.b()` ⇒ `a.b`); string-literal
* subscripts fold into the path (`cp["exec"]` ⇒ `cp.exec`). Absent when the
* chain is not statically resolvable (dynamic key, call-rooted chain).
*/
readonly callee?: string;
/**
* Binding index of the callee chain's ROOT identifier when the callee is a
* member chain (`userInput.trim()` ⇒ `userInput`). Method calls launder
* taint without it (plan KTD5 receiver-position TITO). Absent for bare
* calls (`exec(x)`) and non-identifier roots.
*/
readonly receiver?: number;
/**
* Per-argument-position occurrence entries (trailing empty positions are
* trimmed; absent when no argument carries a binding occurrence). For
* `template: true` sites every substitution occurrence aggregates at
* position 0 (tagged templates have no positional argument list).
*/
readonly args?: ReadonlyArray<readonly SiteArgOccurrence[]>;
/**
* Bindings defined by a declarator/assignment whose ENTIRE value (after
* unwrapping parens/`await`/`as`/`!`) is this call — `const b = escape(t)`
* ⇒ `[b]`. Per-declarator: `const a = t, b = escape(t)` attaches `[b]`
* only. Kill placement (KTD4b) keys on this: a sanitizer kills exactly the
* defs that receive its result directly.
*/
readonly resultDefs?: readonly number[];
/**
* `[siteIdx, argIdx]` of the innermost enclosing call/new site argument
* position this site occurs in (`exec(escape(x))` ⇒ escape's parent is
* `[execSiteIdx, 0]`). Absent for top-level sites.
*/
readonly parent?: readonly [number, number];
/**
* Index of the FIRST spread argument (`exec(...args)` ⇒ 0). Presence means
* position matching must degrade soundly (any sink position ≥ this index —
* plan KTD2/U2). A number (not boolean) because the matcher needs the index.
*/
readonly spread?: number;
/** Tagged-template call (`sql\`…${id}\``) — argument positions are not positional. */
readonly template?: boolean;
/**
* String-literal first argument when the callee is bare `require` —
* CommonJS aliases resolve like ESM imports on the main thread (KTD7).
*/
readonly requireArg?: string;
/** Member read: binding index of the object root (`req.body` ⇒ `req`). */
readonly object?: number;
/**
* Member read: property name (`req.body` ⇒ `'body'`; `req["body"]`
* included; dynamic `req[key]` is never recorded — documented KTD10 FN).
*/
readonly property?: string;
}
/**
* Def/use facts for one harvested statement (or construct header), in
* execution order within its block (#2082 M2 U1). `defs`/`uses` are indices
@ -57,12 +143,19 @@ export interface BindingEntry {
* treating them as must-defs would falsely kill the prior def on the
* not-taken path (a taint false negative on core JS idioms). Optional —
* absent means none.
*
* `sites` (#2083 M3 U1): call/member-read structure for the taint pass —
* see {@link SiteRecord}. Optional and omit-when-empty; absent on pre-M3
* channels and on statements with no calls or member reads. Sites inside
* nested functions are NOT recorded (consistent with def/use invisibility —
* the enclosing `arr.forEach(...)` call IS, with receiver `arr`).
*/
export interface StatementFacts {
readonly line: number;
readonly defs: readonly number[];
readonly uses: readonly number[];
readonly mayDefs?: readonly number[];
readonly sites?: readonly SiteRecord[];
}
/** A basic block: a maximal straight-line run of statements between leaders. */

View file

@ -39,7 +39,7 @@
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import type { BindingEntry, SiteArgOccurrence, SiteRecord, StatementFacts } from '../types.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
@ -83,6 +83,31 @@ const TYPE_CONTEXT_TYPES = new Set([
'asserts_annotation',
]);
/**
* Wrappers that don't change which VALUE flows through them (#2083 M3 U1) —
* unwrapped when resolving call-result attribution (`const b = (await
* escape(t))!` still attaches `resultDefs: [b]` to the escape site) and
* member-chain roots. Distinct from {@link TsHarvester.unwrapLvalue}, which is
* the narrower LVALUE set.
*/
const VALUE_WRAPPER_TYPES = new Set([
'parenthesized_expression',
'non_null_expression',
'as_expression',
'satisfies_expression',
'await_expression',
]);
/** Literal text of a `string` node (concatenated fragments; raw escapes kept). */
const stringLiteralText = (node: SyntaxNode): string => {
let out = '';
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'string_fragment' || c?.type === 'escape_sequence') out += c.text;
}
return out;
};
interface Scope {
readonly parent: Scope | null;
/** name → binding index */
@ -112,6 +137,16 @@ export class TsHarvester {
* here falsely kills the prior def on the not-taken path).
*/
private conditionalDepth = 0;
/**
* Call/new node id → bindings whose declarator/assignment VALUE is exactly
* that call (#2083 M3 U1). Registered by the declarator/assignment handlers
* BEFORE the value walk, consumed by {@link visitCall} when it reaches the
* node — the indirection keeps result-def attribution per-declarator
* (`const a = t, b = escape(t)` attaches `[b]` to the escape site only) and
* top-level-only (`const c = cond ? escape(b) : b` attaches nothing — the
* bypass occurrence must keep `c` taintable, plan KTD4a).
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -458,7 +493,9 @@ export class TsHarvester {
// live def (`x = source(); var x; sink(x)` must keep source→sink;
// tri-review P2). `let`/`const` declarators genuinely initialize.
if (name && (value || t === 'lexical_declaration')) {
const snap = acc.defSnapshot();
this.walkDefPattern(name, acc);
if (value) this.registerResultDefs(value, acc.defsSince(snap));
}
if (value) this.walkValue(value, acc);
}
@ -466,7 +503,11 @@ export class TsHarvester {
case 'assignment_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (left) this.walkDefPattern(this.unwrapLvalue(left), acc);
if (left) {
const snap = acc.defSnapshot();
this.walkDefPattern(this.unwrapLvalue(left), acc);
if (right) this.registerResultDefs(right, acc.defsSince(snap));
}
if (right) this.walkValue(right, acc);
return;
}
@ -552,6 +593,39 @@ export class TsHarvester {
if (body) this.walkValue(body, acc);
return;
}
case 'call_expression':
// #2083 M3 U1: explicit case (previously default-descended) — same
// uses, plus a taint-site record. MUST keep defs/uses byte-identical.
this.visitCall(node, acc, 'call');
return;
case 'new_expression':
this.visitCall(node, acc, 'new');
return;
case 'member_expression':
case 'subscript_expression':
// #2083 M3 U1: value-position member chain — same uses as the old
// default descent (root identifier + dynamic subscript indices), plus
// a member-read site for the innermost identifier-rooted access.
this.walkChain(node, acc, false);
return;
case 'sequence_expression': {
// Comma operator: only the LAST operand's value flows. Earlier operands
// are evaluated for side effects — record their uses but suppress
// occurrence fan-out so `exec((log(x), 'safe'))` does not taint exec's
// arg 0 with `x` (review fix). Defs/uses stay byte-identical to the old
// default descent; only the sites layer narrows.
const operands: SyntaxNode[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) operands.push(c);
}
const last = operands.length - 1;
operands.forEach((op, i) => {
if (i === last) this.walkValue(op, acc);
else acc.suppressOccurrences(() => this.walkValue(op, acc));
});
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
@ -593,8 +667,11 @@ export class TsHarvester {
case 'member_expression':
case 'subscript_expression':
// Property/element write — NOT a scalar def (KTD4); its identifiers
// (object, computed key) are uses.
this.walkValue(node, acc);
// (object, computed key) are uses. WRITE position (#2083 M3 U1): the
// written access itself is not a value read — no member-read site for
// it (`obj.p = q` records nothing; `req.body.x = v`'s mid-chain LOAD
// of `req.body` still does).
this.walkChain(node, acc, true);
return;
default:
for (let i = 0; i < node.namedChildCount; i++) {
@ -603,6 +680,201 @@ export class TsHarvester {
}
}
}
// ── taint-site harvest (#2083 M3 U1) ────────────────────────────────────
/** Strip value-transparent wrappers (`(x)`, `x!`, `x as T`, `await x`). */
private unwrapValueWrappers(node: SyntaxNode): SyntaxNode {
let n = node;
while (VALUE_WRAPPER_TYPES.has(n.type)) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
/**
* When `value`'s root (after unwrapping) is a call/new node, remember that
* its site should carry `resultDefs: defs` — consumed by {@link visitCall}
* once the value walk reaches the node.
*/
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
if (defs.length === 0) return;
const root = this.unwrapValueWrappers(value);
if (root.type === 'call_expression' || root.type === 'new_expression') {
this.resultDefTargets.set(root.id, [...defs]);
}
}
/**
* Explicit call/new handler: records a call site (callee path, receiver,
* per-arg occurrence entries, spread/template markers, require literal,
* result defs) while reproducing EXACTLY the uses the old default descent
* recorded — callee chain root + dynamic subscript indices + arguments.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
const calleeNode = node.childForFieldName(kind === 'new' ? 'constructor' : 'function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite(kind);
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
const callee = this.unwrapValueWrappers(calleeNode);
if (callee.type === 'identifier') {
// The callee NAME is a statement-level use but NOT a value occurrence
// flowing into any enclosing argument — `exec(escape(x))` must not
// put the `escape` binding itself into exec's arg 0 (only x, tagged
// via the escape site). Receiver-chain roots DO fan out (KTD5 TITO).
acc.addUseWithoutOccurrence(this.resolve(callee));
calleePath = callee.text;
} else if (callee.type === 'member_expression' || callee.type === 'subscript_expression') {
// skipFinalRead: the final access IS the callee, carried by the
// dotted path — recording it as a member read would double-count.
// Mid-chain reads (`req.body` inside `req.body.toString()`) ARE
// recorded (plan KTD2).
const chain = this.walkChain(callee, acc, true);
calleePath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else {
// Call-rooted chains, IIFEs, function expressions — no dotted path;
// the walk still records uses and nested sites.
this.walkValue(callee, acc);
}
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode?.type === 'template_string') {
// Tagged template (`sql\`…${id}\``): the `arguments` field is a
// template_string, not an arguments node — substitution occurrences
// aggregate at position 0 and the site is marked non-positional.
acc.setSiteTemplate(siteIdx);
acc.setFrameArg(0);
this.walkValue(argsNode, acc);
} else if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || arg.type === 'comment') continue;
acc.setFrameArg(pos);
if (arg.type === 'spread_element') {
acc.setSiteSpread(siteIdx, pos);
const inner = arg.namedChild(0);
if (inner) this.walkValue(inner, acc);
} else {
if (kind === 'call' && pos === 0 && calleePath === 'require' && arg.type === 'string') {
// CommonJS `require('lit')` — record the literal so the matcher
// resolves require'd aliases like ESM imports (plan KTD7).
acc.setSiteRequireArg(siteIdx, stringLiteralText(arg));
}
this.walkValue(arg, acc);
}
pos++;
}
}
acc.popFrame();
}
/**
* Member/subscript chain walk shared by value position, write position, and
* callee position. Use-recording is identical to the old default descent
* (chain-root identifier once, dynamic subscript index expressions, full
* walk of non-identifier roots) — NO double-recording. Member-read sites:
* at most ONE per chain — the INNERMOST access — and only when the chain
* root is an identifier and the access's key is static (`.prop` or a
* string-literal subscript); `skipFinalRead` suppresses it when that access
* is the final one (callee / write target). Optional chaining (`?.`) never
* appears in the output (field-based traversal normalizes it); dynamic
* computed keys record nothing (documented KTD10 FN).
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
// Collect accesses outer→inner (unshift), then resolve the root.
const accesses: Array<{ prop?: string; dynamicIndex?: SyntaxNode }> = [];
let cur: SyntaxNode = this.unwrapValueWrappers(node);
for (;;) {
if (cur.type === 'member_expression') {
const prop = cur.childForFieldName('property');
accesses.unshift({ prop: prop?.text });
const obj = cur.childForFieldName('object');
if (!obj) break;
cur = this.unwrapValueWrappers(obj);
} else if (cur.type === 'subscript_expression') {
const index = cur.childForFieldName('index');
if (index?.type === 'string') {
accesses.unshift({ prop: stringLiteralText(index) });
} else {
accesses.unshift({ dynamicIndex: index ?? undefined });
}
const obj = cur.childForFieldName('object');
if (!obj) break;
cur = this.unwrapValueWrappers(obj);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else if (cur.type === 'this' || cur.type === 'super') {
rootSegment = cur.text; // path segment only — `this`/`super` never bind
} else {
this.walkValue(cur, acc); // call-rooted etc. — uses + nested sites
}
// Dynamic subscript index expressions are real value reads (old default
// descent walked them) — inner→outer matches the old recording order.
for (const a of accesses) {
if (a.dynamicIndex) this.walkValue(a.dynamicIndex, acc);
}
const innermost = accesses[0];
if (
rootIdx !== undefined &&
innermost?.prop !== undefined &&
!(skipFinalRead && accesses.length === 1)
) {
acc.addMemberRead(rootIdx, innermost.prop);
}
const path =
rootSegment !== undefined && accesses.every((a) => a.prop !== undefined)
? [rootSegment, ...accesses.map((a) => a.prop as string)].join('.')
: undefined;
return { path, rootIdx };
}
}
/** Mutable build-time view of a {@link SiteRecord}. */
interface MutableSite {
kind: SiteRecord['kind'];
parent?: [number, number];
callee?: string;
receiver?: number;
args?: SiteArgOccurrence[][];
resultDefs?: number[];
spread?: number;
template?: boolean;
requireArg?: string;
object?: number;
property?: string;
}
/**
* One open call/new site during the walk (#2083 M3 U1). `argIdx` is the
* argument position currently being walked, or -1 while outside any argument
* (callee walk) — occurrences recorded then do NOT land in this frame's args
* (they still fan out to enclosing arg-active frames, via-tagged through this
* frame's site: the receiver of a nested call flows into the outer argument
* through that call).
*/
interface SiteFrame {
siteIdx: number;
argIdx: number;
}
/** Ordered, deduplicating def/use collector for one statement record. */
@ -613,6 +885,13 @@ class FactAccumulator {
private readonly defSeen = new Set<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
/** Taint sites recorded for this statement (#2083 M3 U1). */
private readonly sites: MutableSite[] = [];
/** Composite (object|property|parent) keys of recorded member-read sites, so
* dedup is O(1) instead of a rescan of `sites` per read. */
private readonly memberReadKeys = new Set<string>();
/** Stack of open call/new sites — the occurrence fan-out targets. */
private readonly frames: SiteFrame[] = [];
constructor(private readonly line: number) {}
@ -630,6 +909,17 @@ class FactAccumulator {
}
addUse(idx: number): void {
// Occurrence fan-out happens BEFORE the statement-level dedup: `exec(x, x)`
// records x at BOTH arg positions even though `uses` lists it once.
this.recordOccurrence(idx);
this.addUseWithoutOccurrence(idx);
}
/**
* Statement-level use that is NOT a value occurrence in any open site
* argument — bare callee names only (#2083 M3 U1, see visitCall).
*/
addUseWithoutOccurrence(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
@ -643,6 +933,147 @@ class FactAccumulator {
return this.uses.length;
}
// ── site machinery (#2083 M3 U1) ─────────────────────────────────────────
/** `[defs.length, mayDefs.length]` marker for {@link defsSince}. */
defSnapshot(): readonly [number, number] {
return [this.defs.length, this.mayDefs.length];
}
/** Binding indices def'd (must- OR may-) since the snapshot was taken. */
defsSince(snap: readonly [number, number]): number[] {
return [...this.defs.slice(snap[0]), ...this.mayDefs.slice(snap[1])];
}
/** Open a call/new site; parent = innermost enclosing argument position. */
openCallSite(kind: 'call' | 'new'): number {
const site: MutableSite = { kind };
const parent = this.innermostArgPosition();
if (parent) site.parent = parent;
this.sites.push(site);
return this.sites.length - 1;
}
pushFrame(siteIdx: number): void {
this.frames.push({ siteIdx, argIdx: -1 });
}
popFrame(): void {
this.frames.pop();
}
/** Set the argument position the top frame is currently walking. */
setFrameArg(argIdx: number): void {
const top = this.frames[this.frames.length - 1];
if (top) top.argIdx = argIdx;
}
/**
* Run `fn` with all open arg frames temporarily detached (argIdx = -1), so
* identifier reads inside still record USES but do NOT fan occurrences into
* the enclosing sink-argument position. Used for the non-value operands of a
* sequence (comma) expression — only the final operand's value flows.
*/
suppressOccurrences(fn: () => void): void {
const saved = this.frames.map((f) => f.argIdx);
for (const f of this.frames) f.argIdx = -1;
try {
fn();
} finally {
this.frames.forEach((f, i) => {
f.argIdx = saved[i];
});
}
}
setSiteCallee(siteIdx: number, callee: string): void {
this.sites[siteIdx].callee = callee;
}
setSiteReceiver(siteIdx: number, receiver: number): void {
this.sites[siteIdx].receiver = receiver;
}
setSiteResultDefs(siteIdx: number, resultDefs: readonly number[]): void {
this.sites[siteIdx].resultDefs = [...resultDefs];
}
setSiteSpread(siteIdx: number, firstSpreadArg: number): void {
const site = this.sites[siteIdx];
if (site.spread === undefined) site.spread = firstSpreadArg;
}
setSiteTemplate(siteIdx: number): void {
this.sites[siteIdx].template = true;
}
setSiteRequireArg(siteIdx: number, literal: string): void {
this.sites[siteIdx].requireArg = literal;
}
/**
* Record a value-position member read. Exact duplicates within the
* statement (same object/property/parent position) dedup; reads at
* DIFFERENT argument positions stay distinct (`exec(req.body, req.body)`
* is two occurrences — KTD6 finding identity needs both).
*/
addMemberRead(object: number, property: string): void {
const parent = this.innermostArgPosition();
const dedupKey = `${object}|${property}|${parent ? `${parent[0]}:${parent[1]}` : 'top'}`;
if (this.memberReadKeys.has(dedupKey)) return;
this.memberReadKeys.add(dedupKey);
const site: MutableSite = { kind: 'member-read' };
if (parent) site.parent = parent;
site.object = object;
site.property = property;
this.sites.push(site);
}
private innermostArgPosition(): [number, number] | undefined {
for (let i = this.frames.length - 1; i >= 0; i--) {
const f = this.frames[i];
if (f.argIdx >= 0) return [f.siteIdx, f.argIdx];
}
return undefined;
}
/**
* Fan a binding occurrence out to every arg-active open frame. The entry is
* via-tagged with the site of the IMMEDIATELY nested frame when one exists:
* `exec(escape(x))` puts a plain `x` in escape's arg 0 and `[x, escapeIdx]`
* in exec's arg 0 — the KTD4a interposition substrate.
*/
private recordOccurrence(idx: number): void {
for (let i = this.frames.length - 1; i >= 0; i--) {
const f = this.frames[i];
if (f.argIdx < 0) continue;
const via = i + 1 < this.frames.length ? this.frames[i + 1].siteIdx : undefined;
this.pushArgEntry(f.siteIdx, f.argIdx, idx, via);
}
}
private pushArgEntry(
siteIdx: number,
argIdx: number,
bindingIdx: number,
via: number | undefined,
): void {
const site = this.sites[siteIdx];
const args = (site.args ??= []);
while (args.length <= argIdx) args.push([]);
const list = args[argIdx];
// Dedup exact (binding, via) pairs per position — `f(x + x)` is one entry;
// `f(x + g(x))` keeps the plain AND the via-tagged entry (distinct paths).
for (const e of list) {
const match =
typeof e === 'number'
? via === undefined && e === bindingIdx
: via !== undefined && e[0] === bindingIdx && e[1] === via;
if (match) return;
}
list.push(via === undefined ? bindingIdx : [bindingIdx, via]);
}
finish(): StatementFacts {
return {
line: this.line,
@ -651,6 +1082,21 @@ class FactAccumulator {
// Optional field stays absent when empty — keeps the serialized
// side-channel payload lean (most statements have no may-defs).
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
// Sites likewise omit-when-empty (#2083 M3 U1): flag-off runs never
// harvest, and most fact-bearing statements carry no calls.
...(this.sites.length > 0 ? { sites: this.sites.map(finalizeSite) } : {}),
};
}
}
/** Trim trailing empty arg positions; drop `args` entirely when all-empty. */
const finalizeSite = (site: MutableSite): SiteRecord => {
const args = site.args;
if (args !== undefined) {
let end = args.length;
while (end > 0 && args[end - 1].length === 0) end--;
if (end === 0) delete site.args;
else if (end < args.length) site.args = args.slice(0, end);
}
return site as SiteRecord;
};

View file

@ -21,6 +21,7 @@ export {
type ScopeResolutionOutput,
} from '../scope-resolution/pipeline/phase.js';
export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js';
export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js';
export { mroPhase, type MROOutput } from './mro.js';
export { communitiesPhase, type CommunitiesOutput } from './communities.js';
export { processesPhase, type ProcessesOutput } from './processes.js';

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