mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
Merge branch 'main' into feature/ci-setup-wizard
This commit is contained in:
commit
8666342b9f
197 changed files with 22709 additions and 1349 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
89
.claude/skills/gitnexus/gitnexus-pdg-query/SKILL.md
Normal file
89
.claude/skills/gitnexus/gitnexus-pdg-query/SKILL.md
Normal 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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
178
.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md
Normal file
178
.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md
Normal 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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
394
.github/scripts/test_check_tree_sitter_upgrade_readiness.py
vendored
Normal file
394
.github/scripts/test_check_tree_sitter_upgrade_readiness.py
vendored
Normal 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()
|
||||
171
.github/scripts/update-vendored-grammars.mjs
vendored
171
.github/scripts/update-vendored-grammars.mjs
vendored
|
|
@ -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
26
.github/vendored-grammars.json
vendored
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
7
.github/workflows/grammar-update-monitor.yml
vendored
7
.github/workflows/grammar-update-monitor.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
89
gitnexus-claude-plugin/skills/gitnexus-pdg-query/SKILL.md
Normal file
89
gitnexus-claude-plugin/skills/gitnexus-pdg-query/SKILL.md
Normal 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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
178
gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md
Normal file
178
gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md
Normal 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).
|
||||
|
|
@ -241,7 +241,18 @@ function main() {
|
|||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stays silent by default; surfaced only under the cursor
|
||||
// hook's own GITNEXUS_DEBUG (truthy) convention. NOTE: unlike the
|
||||
// claude/plugin/antigravity adapters this integration does not install
|
||||
// hook-db-lock-probe.cjs, so its augment child is not guard-wrapped
|
||||
// yet — tracked on the #2163 follow-up list ("cursor probe").
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ description: Trace bugs through call chains using knowledge graph
|
|||
## Workflow
|
||||
|
||||
```
|
||||
1. 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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__;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 : ''}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
46
gitnexus-web/src/lib/apply-connect-result.ts
Normal file
46
gitnexus-web/src/lib/apply-connect-result.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
84
gitnexus-web/src/lib/graph-load-decision.ts
Normal file
84
gitnexus-web/src/lib/graph-load-decision.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@
|
|||
"configureAI": "配置 AI",
|
||||
"connecting": "连接中"
|
||||
},
|
||||
"chatOnly": {
|
||||
"banner": "图谱未加载(大型项目)。对话功能正常;内联节点引用不会在图谱视图中高亮。"
|
||||
},
|
||||
"roles": {
|
||||
"you": "你",
|
||||
"assistant": "Nexus AI"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"timeout": "服务器响应超时,请稍后重试。",
|
||||
"rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。",
|
||||
"notFound": "未找到请求的仓库或资源。",
|
||||
"originBlocked": "此操作无法从托管界面执行。请通过服务器自身地址(例如 http://localhost:4747)打开 GitNexus 后再继续。",
|
||||
"client": "请求失败:{{message}}",
|
||||
"server": "服务器错误:{{message}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "未知",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
gitnexus-web/src/vite-env.d.ts
vendored
14
gitnexus-web/src/vite-env.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
145
gitnexus-web/test/unit/graph-load-decision.test.ts
Normal file
145
gitnexus-web/test/unit/graph-load-decision.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
242
gitnexus-web/test/unit/load-graph-anyway.test.tsx
Normal file
242
gitnexus-web/test/unit/load-graph-anyway.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
214
gitnexus/package-lock.json
generated
214
gitnexus/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
89
gitnexus/skills/gitnexus-pdg-query.md
Normal file
89
gitnexus/skills/gitnexus-pdg-query.md
Normal 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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
178
gitnexus/skills/gitnexus-taint-analysis.md
Normal file
178
gitnexus/skills/gitnexus-taint-analysis.md
Normal 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).
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
41
gitnexus/src/cli/embedding-dims.ts
Normal file
41
gitnexus/src/cli/embedding-dims.ts
Normal 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));
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '清理所有已索引仓库',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
172
gitnexus/src/core/ingestion/cfg/control-dependence.ts
Normal file
172
gitnexus/src/core/ingestion/cfg/control-dependence.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
218
gitnexus/src/core/ingestion/cfg/post-dominators.ts
Normal file
218
gitnexus/src/core/ingestion/cfg/post-dominators.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}. */
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue