Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd8eb4757c | ||
|
|
bb84ccb27e | ||
|
|
16883f2d79 | ||
|
|
35a37d9240 | ||
|
|
e2641628c7 | ||
|
|
ddc31ba72e | ||
|
|
dc122897a9 |
@@ -11,7 +11,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.8",
|
||||
"version": "1.3.3",
|
||||
"source": "./gitnexus-claude-plugin",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. query({search_query: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
@@ -28,11 +28,11 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] query for error text or related code
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] context to see callers and callees
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] cypher for custom call chain traces if needed
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
@@ -40,58 +40,46 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `query` for error text → `context` on throw sites |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call |
|
||||
|
||||
## Tools
|
||||
|
||||
**query** — find code related to error:
|
||||
**gitnexus_query** — find code related to error:
|
||||
|
||||
```
|
||||
query({search_query: "payment validation error"})
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**context** — full context for a suspect:
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**cypher** — custom call chain traces:
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
```
|
||||
|
||||
**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops:
|
||||
|
||||
```
|
||||
trace({ from: "processCheckout", to: "fetchRates" })
|
||||
→ status: ok, hopCount: 3
|
||||
→ hops: processCheckout → validatePayment → verifyCard → fetchRates
|
||||
→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0)
|
||||
```
|
||||
|
||||
When no path exists, `trace` reports the furthest reachable node — exactly where the chain breaks (dynamic dispatch, reflection, or an external boundary).
|
||||
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. query({search_query: "payment error handling"})
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. context({name: "validatePayment"})
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
|
||||
@@ -18,8 +18,8 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
@@ -29,9 +29,9 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
@@ -47,18 +47,18 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
|
||||
## Tools
|
||||
|
||||
**query** — find execution flows related to a concept:
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
query({search_query: "payment processing"})
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**context** — 360-degree view of a symbol:
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
context({name: "validateUser"})
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
@@ -68,10 +68,10 @@ context({name: "validateUser"})
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. query({search_query: "payment processing"})
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. context({name: "processPayment"})
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
|
||||
@@ -35,74 +35,10 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
||||
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `trace` | Shortest path between two symbols — "how does A reach B?" in one call |
|
||||
| `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`
|
||||
|
||||
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"repositories": [
|
||||
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
|
||||
],
|
||||
"pagination": {
|
||||
"total": 437,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"returned": 50,
|
||||
"hasMore": true,
|
||||
"nextOffset": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
|
||||
|
||||
```text
|
||||
list_repos {} → repos 1–50, nextOffset 50, hasMore true
|
||||
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
|
||||
…
|
||||
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
|
||||
```
|
||||
|
||||
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
|
||||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
|
||||
|
||||
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
|
||||
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
|
||||
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
|
||||
|
||||
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
|
||||
|
||||
### 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.
|
||||
|
||||
### Shortest path between two symbols (`trace`)
|
||||
|
||||
`trace` answers "how does A reach B?" in one call — the shortest directed path over `CALLS` (plus `HAS_METHOD`, so a class-rooted trace descends into its methods) instead of chaining 3–8 `context`/`impact` hops by hand.
|
||||
|
||||
- `trace { from: "validateUser", to: "executeQuery" }` — shortest path between two symbols.
|
||||
- Disambiguate common names with `from_uid`/`to_uid` (zero-ambiguity) or `from_file`/`to_file`; an ambiguous name returns ranked candidates.
|
||||
- `maxDepth` (default 10, max 30) bounds the search; `includeTests` (default false) lets the traversal pass through test-file symbols.
|
||||
|
||||
Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`.
|
||||
| `list_repos` | Discover indexed repos |
|
||||
|
||||
## Resources Reference
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ description: "Use when the user wants to know what will break if they change som
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. detect_changes() → Map current git changes to affected flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
@@ -28,11 +28,11 @@ description: "Use when the user wants to know what will break if they change som
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] detect_changes() for pre-commit check
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
@@ -55,10 +55,10 @@ description: "Use when the user wants to know what will break if they change som
|
||||
|
||||
## Tools
|
||||
|
||||
**impact** — the primary tool for symbol blast radius:
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
impact({
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
@@ -73,10 +73,10 @@ impact({
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**detect_changes** — git-diff based impact analysis:
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
detect_changes({scope: "staged"})
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
@@ -86,7 +86,7 @@ detect_changes({scope: "staged"})
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. impact({target: "validateUser", direction: "upstream"})
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -18,10 +18,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. context({name: "<key symbol>"}) → Understand callers/callees
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
@@ -32,10 +32,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] detect_changes to map changes to affected execution flows
|
||||
- [ ] impact on each non-trivial changed symbol
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] context on key changed symbols to understand full picture
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
@@ -63,20 +63,20 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
## Tools
|
||||
|
||||
**detect_changes** — map PR diff to affected execution flows:
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
detect_changes({scope: "compare", base_ref: "main"})
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**impact** — blast radius per changed symbol:
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream"})
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
@@ -86,20 +86,20 @@ impact({target: "validatePayment", direction: "upstream"})
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**impact with tests** — check test coverage:
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**context** — understand a changed symbol's role:
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
@@ -112,20 +112,20 @@ context({name: "validatePayment"})
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. detect_changes({scope: "compare", base_ref: "main"})
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. impact({target: "validatePayment", direction: "upstream"})
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. impact({target: "PaymentInput", direction: "upstream"})
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. context({name: "formatAmount"})
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
|
||||
@@ -16,9 +16,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. query({search_query: "X"}) → Find execution flows involving X
|
||||
3. context({name: "X"}) → See all incoming/outgoing refs
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
@@ -29,65 +29,65 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] context({name: target}) — understand all callees
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**rename** — automated multi-file rename:
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**impact** — map all dependents first:
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
|
||||
```
|
||||
impact({target: "validateUser", direction: "upstream"})
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**detect_changes** — verify your changes after refactoring:
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
detect_changes({scope: "all"})
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**cypher** — custom reference queries:
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
@@ -98,24 +98,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use rename for automated updates |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | query to find them |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. detect_changes({scope: "all"})
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
---
|
||||
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).
|
||||
+67
-16
@@ -22,10 +22,26 @@
|
||||
# --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. 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).
|
||||
# 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).
|
||||
ARG BUN_VERSION
|
||||
ARG TZ=UTC
|
||||
ARG USERNAME=node
|
||||
@@ -34,7 +50,10 @@ 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 BUN_VERSION=${BUN_VERSION} \
|
||||
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
|
||||
CODEX_VERSION=${CODEX_VERSION} \
|
||||
CURSOR_VERSION=${CURSOR_VERSION} \
|
||||
BUN_VERSION=${BUN_VERSION} \
|
||||
BUN_INSTALL=/home/${USERNAME}/.bun \
|
||||
TZ=${TZ} \
|
||||
DEVCONTAINER=true \
|
||||
@@ -67,19 +86,51 @@ RUN mkdir -p \
|
||||
|
||||
USER ${USERNAME}
|
||||
|
||||
# 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 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 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 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 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
|
||||
|
||||
@@ -310,8 +310,8 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
|
||||
|
||||
- **LadybugDB integration tests may fail in containers** (file-locking, `AGENTS.md` § Testing). Default to `npm run test:unit` inside the container; run integration tests on the host. Tracking issue: documented as a known limitation.
|
||||
- **Single-writer LadybugDB constraint** (`GUARDRAILS.md` § LadybugDB lock). Don't run `gitnexus analyze` on the host and inside the container against the same `.gitnexus/` directory simultaneously — the second writer will get `database busy`.
|
||||
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift/Kotlin are all vendored uniformly: `node-gyp-build` picks a committed GitNexus-built prebuilt `.node` at install time (no compile), and only falls back to compiling from the vendored source during `postinstall` if no prebuild matches the host (then a toolchain is needed). Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` (in your shell or `remoteEnv`, then rebuild) to skip all four; each loses parsing for the affected language(s), and the install still succeeds.
|
||||
- **`tree-sitter-kotlin`/`tree-sitter-swift` warnings on install** only appear when no prebuild matches the platform-arch (per `AGENTS.md`); they are non-fatal — parsing for that language is simply unavailable.
|
||||
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift grammars build during `gitnexus`'s `postinstall`. To skip them (loses parsing for those three languages), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` in your shell or add it to `remoteEnv` and rebuild.
|
||||
- **`tree-sitter-kotlin` warnings on install** are expected (per `AGENTS.md`). Ignore them.
|
||||
- **`.mcp.json` works inside the container**: `npx -y gitnexus@latest mcp` resolves cleanly because npm registry is reachable and the workspace bind mount exposes the same `.mcp.json` the host sees.
|
||||
- **Husky pre-commit fires inside the container** without extra setup. The root `npm install` (run automatically in `postCreateCommand`) installs the hook via `package.json` `prepare`.
|
||||
|
||||
|
||||
@@ -14,6 +14,16 @@
|
||||
"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
|
||||
@@ -314,6 +324,17 @@
|
||||
// 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,12 +141,15 @@ 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 — 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).
|
||||
# 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).
|
||||
node "$SCRIPT_DIR/seed-claude-config.cjs"
|
||||
|
||||
# Codex auth. Some hosts store credentials in the OS keyring instead of on disk
|
||||
|
||||
@@ -17,9 +17,3 @@ WEB_HOST_PORT=4173
|
||||
# Optional read-only mount, exposed to the server as /workspace.
|
||||
# Override with the directory that contains the repos you want to index.
|
||||
WORKSPACE_DIR=./
|
||||
|
||||
# Azure DevOps Server Integration (passed to the server container)
|
||||
# Prefer https:// — the PAT rides in an Authorization header, so cleartext
|
||||
# http:// exposes it on the wire (still supported for internal-only instances).
|
||||
# AZURE_DEVOPS_URL=https://azuredevops.example.com
|
||||
# AZURE_DEVOPS_PAT=your-pat-here
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Code owners
|
||||
|
||||
* @Arvuno
|
||||
* @magyargergo
|
||||
@@ -1,19 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Monitor tree-sitter 0.25 upgrade readiness — two things Dependabot can't see:
|
||||
"""Monitor tree-sitter 0.25 upgrade readiness.
|
||||
|
||||
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.
|
||||
Tracks two things Dependabot cannot see:
|
||||
|
||||
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]
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
@@ -26,11 +38,6 @@ 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).
|
||||
@@ -71,11 +78,15 @@ GRAMMARS: dict[str, tuple[str, str, str]] = {
|
||||
"tree-sitter-proto": ("coder3101/tree-sitter-proto", "main", "src/parser.c"),
|
||||
}
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
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 "
|
||||
@@ -84,56 +95,6 @@ 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:
|
||||
@@ -173,27 +134,12 @@ 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"})
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
# OSError covers read-phase transport failures (ConnectionResetError,
|
||||
# ssl.SSLError, socket.timeout) that escape resp.read() AFTER urlopen
|
||||
# returns — urllib only wraps connect-phase OSErrors into URLError, so these
|
||||
# are not URLError subclasses. http.client.IncompleteRead is an HTTPException,
|
||||
# not an OSError, so it must be named explicitly. Returning None routes the
|
||||
# grammar to the fetch_failed blocker bucket (a complete report) instead of
|
||||
# crashing main() to empty stdout.
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
urllib.error.HTTPError,
|
||||
OSError,
|
||||
http.client.IncompleteRead,
|
||||
json.JSONDecodeError,
|
||||
):
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -244,8 +190,6 @@ 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).
|
||||
@@ -263,16 +207,7 @@ def fetch_text(url: str, timeout: int = 8) -> str | None:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read().decode("utf-8", errors="ignore")
|
||||
# See npm_view_json: OSError + http.client.IncompleteRead catch read-phase
|
||||
# transport failures that escape resp.read() and are not URLError subclasses,
|
||||
# so a transient network blip yields None (→ fetch_failed) rather than
|
||||
# crashing the report to empty stdout.
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
urllib.error.HTTPError,
|
||||
OSError,
|
||||
http.client.IncompleteRead,
|
||||
):
|
||||
except (urllib.error.URLError, urllib.error.HTTPError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -296,8 +231,14 @@ def md_h(text: str, level: int = 2) -> str:
|
||||
|
||||
|
||||
def _first_sentence(text: str) -> str:
|
||||
"""Return the leading sentence of a `_vendoredBy` rationale (the rest tails off
|
||||
into install-script breadcrumbs); fall back to the whole string."""
|
||||
"""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.
|
||||
"""
|
||||
text = text.strip()
|
||||
match = re.search(r"\.\s+[A-Z]", text)
|
||||
return text[: match.start() + 1] if match else text
|
||||
@@ -321,26 +262,21 @@ def range_includes(spec: str | None, version: str) -> bool:
|
||||
return spec.strip() == version.strip()
|
||||
|
||||
|
||||
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 is_vendored_pin(spec: str | None) -> bool:
|
||||
return bool(spec) and spec.startswith(("file:", "git", "http"))
|
||||
|
||||
|
||||
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 its
|
||||
package.json ``version`` + ``_vendoredBy`` (the rationale, kept next to the
|
||||
sources), the vendored ABI, and a comparison against upstream main.
|
||||
"""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.
|
||||
"""
|
||||
vendor_dir = GITNEXUS_DIR / "vendor" / name
|
||||
pkg: dict = {}
|
||||
@@ -354,7 +290,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 = vendored_abi_from_repo(name, parser_path)
|
||||
vendored_abi = extract_language_version(vendored_parser)
|
||||
|
||||
upstream_url = (
|
||||
f"https://raw.githubusercontent.com/{upstream_repo}/"
|
||||
@@ -366,13 +302,10 @@ def vendored_drift_summary(
|
||||
sha_text = fetch_text(
|
||||
f"https://api.github.com/repos/{upstream_repo}/commits/{upstream_branch}"
|
||||
)
|
||||
# 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"
|
||||
upstream_sha = "?"
|
||||
if sha_text:
|
||||
try:
|
||||
upstream_sha = json.loads(sha_text).get("sha", "unknown")[:12]
|
||||
upstream_sha = json.loads(sha_text).get("sha", "?")[:12]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
@@ -388,9 +321,7 @@ def vendored_drift_summary(
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
# 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_version": pkg.get("version", "?"),
|
||||
"vendored_by": pkg.get("_vendoredBy"),
|
||||
"vendored_abi": vendored_abi,
|
||||
"upstream_repo": upstream_repo,
|
||||
@@ -405,14 +336,27 @@ def vendored_drift_summary(
|
||||
|
||||
|
||||
def assert_current() -> int:
|
||||
"""Assert every grammar's compiled ABI loads on the CURRENT runtime.
|
||||
"""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.
|
||||
|
||||
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)
|
||||
@@ -436,20 +380,13 @@ def assert_current() -> int:
|
||||
|
||||
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
|
||||
pinned_spec = pinned_versions.get(name, "—")
|
||||
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 = ""
|
||||
pin_note = f" [intentional pin: {pinned_spec}]" if name in INTENTIONAL_PINS else ""
|
||||
|
||||
# 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 is_vendored_pin(pinned_spec):
|
||||
v = vendored_drift_summary(name, upstream_repo, upstream_branch, parser_path)
|
||||
abi = v["vendored_abi"]
|
||||
if abi is None:
|
||||
# Prebuilt-only vendor (e.g. a binary-only grammar): no parser.c to
|
||||
# Prebuilt-only vendor (e.g. tree-sitter-swift): no parser.c to
|
||||
# introspect. The runtime load-smoke covers it instead.
|
||||
skipped.append(f"{name} (vendored, prebuilt — covered by load-smoke)")
|
||||
continue
|
||||
@@ -516,18 +453,32 @@ def _classify_grammar(
|
||||
) -> dict:
|
||||
"""Decide a single primary disposition + a separate bump-now hint.
|
||||
|
||||
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``).
|
||||
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`.
|
||||
"""
|
||||
# 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).
|
||||
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.
|
||||
bump_now = behind_latest and current_compat and name not in INTENTIONAL_PINS
|
||||
|
||||
if fetch_failed:
|
||||
@@ -545,9 +496,6 @@ 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,
|
||||
@@ -555,105 +503,15 @@ 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))
|
||||
@@ -667,9 +525,10 @@ def main() -> int:
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# 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).
|
||||
# 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.
|
||||
grammar_rows: list[dict] = []
|
||||
raw_matrix: list[str] = [
|
||||
"| Grammar | Pinned | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |",
|
||||
@@ -681,17 +540,17 @@ def main() -> int:
|
||||
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
|
||||
pinned_spec = pinned_versions.get(name, "—")
|
||||
|
||||
# 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:
|
||||
# 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):
|
||||
v = vendored_drift_summary(name, upstream_repo, upstream_branch, parser_path)
|
||||
v["pinned_spec"] = pinned_spec
|
||||
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).
|
||||
# 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).
|
||||
if v["vendored_abi"] is None:
|
||||
v["target_compat"] = True
|
||||
v["abi_state"] = "prebuilt"
|
||||
@@ -708,37 +567,13 @@ 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"{vendored_abi_cell} | {upstream_abi_cell} | {status} |"
|
||||
f"{v['vendored_abi'] or '?'} | {v['upstream_abi'] or '?'} | {status} |"
|
||||
)
|
||||
vendored_grammars.append(v)
|
||||
continue
|
||||
@@ -758,7 +593,7 @@ def main() -> int:
|
||||
peer_optional = ts_meta.get("optional", False) if peer_range else True
|
||||
|
||||
if fetch_failed:
|
||||
peer_display = f"n/a ({miss_label})"
|
||||
peer_display = "? (fetch failed)"
|
||||
target_compat = False
|
||||
current_compat = False
|
||||
else:
|
||||
@@ -774,9 +609,7 @@ def main() -> int:
|
||||
# Fallback to default location.
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c"
|
||||
installed_abi = extract_language_version(installed_parser)
|
||||
# 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)"
|
||||
abi_display = str(installed_abi) if installed_abi else "?"
|
||||
|
||||
# Check upstream (main/master branch) ABI for unreleased work.
|
||||
upstream_url = (
|
||||
@@ -785,19 +618,23 @@ 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 "n/a"
|
||||
upstream_abi_display = str(upstream_abi) if upstream_abi else "?"
|
||||
|
||||
# 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 = 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"
|
||||
status = "Unknown (fetch failed)"
|
||||
blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep"
|
||||
elif name in INTENTIONAL_PINS:
|
||||
# A held-back grammar: treated as a blocker until the pin is lifted
|
||||
# (entry removed from INTENTIONAL_PINS), then reclassified next run.
|
||||
# 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.
|
||||
status = "Intentionally pinned"
|
||||
blockers[name] = (
|
||||
f"`{name}` intentionally pinned at `{pinned_spec}` "
|
||||
@@ -838,11 +675,8 @@ 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_cell} | {peer_display} | "
|
||||
f"| `{name}` | {pinned_spec} | {npm_version} | {peer_display} | "
|
||||
f"{compat_icon} | {abi_display} | {upstream_abi_display} | {status} |"
|
||||
)
|
||||
|
||||
@@ -892,8 +726,7 @@ 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']:
|
||||
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})")
|
||||
lines.append(f"- {len(by_bucket['fetch_failed'])} could not be checked (npm registry unreachable)")
|
||||
if bump_now:
|
||||
lines.append(
|
||||
f"- **{len(bump_now)} bump candidate(s) you can take TODAY** (npm-latest "
|
||||
@@ -912,7 +745,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_label']}` "
|
||||
f"- `{r['name']}`: `{r['pinned_spec']}` → `{r['npm_version']}` "
|
||||
f"(peer `{r['peer_range'] or 'none'}`)"
|
||||
)
|
||||
lines.append("")
|
||||
@@ -935,7 +768,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_label']}`"
|
||||
f"- `{r['name']}` — pinned `{r['pinned_spec']}`, npm latest `{r['npm_version']}`"
|
||||
+ (" _(also a bump candidate — see above)_" if r["bump_now"] else "")
|
||||
),
|
||||
)
|
||||
@@ -951,7 +784,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_label']}`)\n {reason}"
|
||||
f"(npm latest `{r['npm_version']}`)\n {reason}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
@@ -961,7 +794,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_label']}` — peer `{r['peer_range'] or 'none'}`. "
|
||||
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`. "
|
||||
f"_{r['upstream_progress']}_"
|
||||
),
|
||||
)
|
||||
@@ -971,23 +804,90 @@ 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_label']}` — peer `{r['peer_range'] or 'none'}`",
|
||||
lambda r: (
|
||||
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`"
|
||||
+ (" _(vendored)_" if r["is_vendored"] else "")
|
||||
),
|
||||
)
|
||||
|
||||
_emit_bucket(
|
||||
"Could not check",
|
||||
(
|
||||
"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."
|
||||
),
|
||||
"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 ────────────────────────────────────────────
|
||||
lines.extend(_render_vendored_section(vendored_grammars, target_abi_range, blockers))
|
||||
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("")
|
||||
|
||||
# ── Raw matrix (for completeness + workflow row-diff) ────────────
|
||||
lines.append(md_h("Full grammar matrix", 2))
|
||||
@@ -1011,11 +911,6 @@ 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.
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
#!/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 http.client
|
||||
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)
|
||||
# Mirrors the scheduled issue-update summary extraction in
|
||||
# tree-sitter-upgrade-readiness.yml. If the report prose changes again, the issue
|
||||
# comment should not silently degrade to "?/? ready. ? blocker(s)".
|
||||
_ISSUE_READY_RE = re.compile(
|
||||
r"- (\d+)/(\d+) npm-installed grammars already accept tree-sitter@"
|
||||
)
|
||||
_ISSUE_BLOCKER_RE = re.compile(r"\*\*Blocked\*\* — (\d+) grammars? ")
|
||||
|
||||
|
||||
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 blockers
|
||||
left are the held vendored grammars (tree-sitter-c, tree-sitter-kotlin) plus
|
||||
the intentionally-pinned tree-sitter-cpp — letting us assert holds are
|
||||
load-bearing (exit code stays non-zero because of them).
|
||||
- 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 FetchHelperReadPhaseErrors(TestCase):
|
||||
"""Read-phase transport failures — raised by resp.read() AFTER urlopen has
|
||||
returned (ConnectionResetError, ssl.SSLError, socket.timeout,
|
||||
http.client.IncompleteRead) — are NOT urllib.error.URLError subclasses
|
||||
(urllib only wraps connect-phase OSErrors). A prior revision's narrow except
|
||||
tuple let them escape npm_view_json / fetch_text, crash main(), and empty
|
||||
stdout — which makes the workflow's requireMatch throw on a non-drift
|
||||
scheduled run. The helpers must swallow them to None so the grammar routes to
|
||||
the fetch_failed blocker bucket and the report still renders completely."""
|
||||
|
||||
@staticmethod
|
||||
def _patch_urlopen(*, read_returns=None, read_raises=None):
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self, *a, **k):
|
||||
if read_raises is not None:
|
||||
raise read_raises
|
||||
return read_returns
|
||||
|
||||
def _fake_urlopen(*a, **k):
|
||||
return _Resp()
|
||||
|
||||
import urllib.request
|
||||
|
||||
return mock.patch.object(urllib.request, "urlopen", side_effect=_fake_urlopen)
|
||||
|
||||
def test_npm_view_json_swallows_read_phase_connection_reset(self):
|
||||
# ConnectionResetError is an OSError but NOT a URLError — the broadened
|
||||
# OSError clause must catch it so the helper returns None, not raises.
|
||||
with mock.patch.object(readiness, "OFFLINE", False), self._patch_urlopen(
|
||||
read_raises=ConnectionResetError("peer reset mid-body")
|
||||
):
|
||||
self.assertIsNone(readiness.npm_view_json("tree-sitter-anything"))
|
||||
|
||||
def test_fetch_text_swallows_read_phase_incomplete_read(self):
|
||||
# http.client.IncompleteRead is an HTTPException (not OSError), so it must
|
||||
# be named explicitly in the except tuple.
|
||||
with mock.patch.object(readiness, "OFFLINE", False), self._patch_urlopen(
|
||||
read_raises=http.client.IncompleteRead(partial=b"half")
|
||||
):
|
||||
self.assertIsNone(readiness.fetch_text("https://example.com/parser.c"))
|
||||
|
||||
def test_npm_view_json_still_swallows_bad_json(self):
|
||||
# JSONDecodeError is a ValueError, not an OSError — broadening the tuple
|
||||
# must not drop it. Non-JSON body still yields None.
|
||||
with mock.patch.object(readiness, "OFFLINE", False), self._patch_urlopen(
|
||||
read_returns=b"<<not json>>"
|
||||
):
|
||||
self.assertIsNone(readiness.npm_view_json("tree-sitter-anything"))
|
||||
|
||||
|
||||
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 test_issue_update_summary_regex_matches_current_report(self):
|
||||
ready = _ISSUE_READY_RE.search(self.report)
|
||||
blockers = _ISSUE_BLOCKER_RE.search(self.report)
|
||||
self.assertIsNotNone(ready)
|
||||
self.assertIsNotNone(blockers)
|
||||
# Counts are derived from _render_report()'s mock corpus (all npm peer
|
||||
# deps mocked permissive): of the 10 npm-installed grammars, 9 render
|
||||
# Ready and 1 — tree-sitter-cpp — is the intentional pin (#1242), so it is
|
||||
# not counted ready. The 3 blockers are that same pinned tree-sitter-cpp
|
||||
# plus two held vendored grammars: ABI-held tree-sitter-c (#1242/#858) and
|
||||
# tree-sitter-kotlin (pinned to an unreleased fwcd main commit for `fun
|
||||
# interface` support — ABI 14 is in range, but a hold counts as a blocker
|
||||
# until it is lifted). If a grammar is added/removed or a pin/hold changes,
|
||||
# update _render_report()'s mock AND these expected counts together; a
|
||||
# mismatch here means the report prose drifted, not the regex.
|
||||
self.assertEqual(ready.groups(), ("9", "10"))
|
||||
self.assertEqual(blockers.group(1), "3")
|
||||
|
||||
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()
|
||||
@@ -1,363 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Vendored tree-sitter grammar update monitor.
|
||||
*
|
||||
* Checks each vendored grammar against its upstream source-of-origin and, for an
|
||||
* available AND ABI-compatible update, re-vendors the grammar source in place so
|
||||
* a PR can be opened. The version bump in vendor/<name>/package.json then triggers
|
||||
* .github/workflows/build-tree-sitter-prebuilds.yml, which cross-builds + ABI-
|
||||
* validates the prebuilds — so even an imperfect re-vendor can never silently
|
||||
* ship: its PR's CI goes red.
|
||||
*
|
||||
* ABI awareness is load-bearing. Every grammar is pinned to tree-sitter@0.21.1
|
||||
* (LANGUAGE_VERSION 13–14, the #1922 gate). Most upstream grammar releases target
|
||||
* a newer tree-sitter, so a blind "bump to latest" would pull an ABI-incompatible
|
||||
* parser and open doomed PRs. This monitor fetches the candidate source, reads its
|
||||
* parser.c `#define LANGUAGE_VERSION`, and only re-vendors when it is 13 or 14;
|
||||
* incompatible updates are reported (and surfaced as a workflow notice), not
|
||||
* applied.
|
||||
*
|
||||
* Usage:
|
||||
* node update-vendored-grammars.mjs # detect only → JSON report on stdout
|
||||
* node update-vendored-grammars.mjs --apply X # re-vendor grammar X in place
|
||||
*
|
||||
* tree-sitter-c is MONITORED but report-only (`hold`): it is ABI-pinned at 0.21.4
|
||||
* (#1242/#858) and must not auto-bump without a tree-sitter runtime upgrade, so an
|
||||
* available c update is detected + reported but never auto-applied — even if it is
|
||||
* ABI-13/14. A maintainer re-vendors it deliberately.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
||||
const VENDOR = path.join(REPO_ROOT, 'gitnexus', 'vendor');
|
||||
|
||||
const COMPATIBLE_ABI = new Set([13, 14]); // tree-sitter@0.21.1 LANGUAGE_VERSION range
|
||||
|
||||
// Source-of-origin per grammar. npm grammars resolve `latest` via the registry;
|
||||
// github grammars (no usable npm release) track the default branch HEAD. A `hold`
|
||||
// reason makes a grammar report-only: updates are detected + surfaced but never
|
||||
// auto-applied (c is ABI-pinned and must not move without a runtime upgrade).
|
||||
//
|
||||
// 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();
|
||||
|
||||
const clean = (v) =>
|
||||
String(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);
|
||||
}
|
||||
|
||||
/** Resolve the upstream candidate: { version, ref, kind }. */
|
||||
function resolveUpstream(g) {
|
||||
if (g.npm) {
|
||||
const version = clean(sh('npm', ['view', g.npm, 'version']));
|
||||
return { version, ref: version, kind: 'npm' };
|
||||
}
|
||||
// github: no reliable release tags here, so track the default branch HEAD sha.
|
||||
const meta = JSON.parse(sh('gh', ['api', `repos/${g.github}`]));
|
||||
const branch = meta.default_branch;
|
||||
const sha = JSON.parse(sh('gh', ['api', `repos/${g.github}/commits/${branch}`])).sha;
|
||||
// Version key: "<upstreamPkgVersion>-g<sha7>" — safeRef-compatible (no `+`,
|
||||
// which the build workflow's ref validator rejects) and changes on every commit.
|
||||
let base = '0.0.0';
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
Buffer.from(
|
||||
JSON.parse(sh('gh', ['api', `repos/${g.github}/contents/package.json?ref=${sha}`])).content,
|
||||
'base64',
|
||||
).toString('utf8'),
|
||||
);
|
||||
if (pkg.version) base = clean(pkg.version);
|
||||
} catch {
|
||||
/* no upstream package.json — base stays 0.0.0 */
|
||||
}
|
||||
return { version: `${base}-g${sha.slice(0, 7)}`, ref: sha, kind: 'github' };
|
||||
}
|
||||
|
||||
/** Fetch the candidate source into a temp dir; return the package root. */
|
||||
function fetchSource(g, ref) {
|
||||
const work = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `revendor-${Object.keys(GRAMMARS).find((k) => GRAMMARS[k] === g)}-`),
|
||||
);
|
||||
if (g.npm) {
|
||||
sh('npm', ['pack', `${g.npm}@${ref}`, '--silent'], { cwd: work });
|
||||
const tgz = fs.readdirSync(work).find((f) => f.endsWith('.tgz'));
|
||||
sh('tar', ['xzf', tgz], { cwd: work });
|
||||
return path.join(work, 'package');
|
||||
}
|
||||
// github tarball at the resolved sha. Download + extract WITHOUT a shell
|
||||
// (no `bash -c`/redirect): `gh api` writes the binary tarball to stdout, which
|
||||
// we capture as a Buffer and write to a fixed path, then extract with execFile.
|
||||
// Avoids the shell-command-injection surface CodeQL flags when an API-derived
|
||||
// ref is interpolated into a `bash -c` string.
|
||||
const tgz = path.join(work, 'src.tgz');
|
||||
fs.writeFileSync(
|
||||
tgz,
|
||||
execFileSync('gh', ['api', `repos/${g.github}/tarball/${ref}`], {
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
sh('tar', ['xzf', tgz], { cwd: work });
|
||||
const dir = fs.readdirSync(work).find((f) => fs.statSync(path.join(work, f)).isDirectory());
|
||||
return path.join(work, dir);
|
||||
}
|
||||
|
||||
/** Read parser.c's LANGUAGE_VERSION (ABI). Prefer the ABI-14 default parser.c. */
|
||||
function readAbi(srcRoot) {
|
||||
const candidates = ['src/parser.c', 'parser.c'];
|
||||
for (const rel of candidates) {
|
||||
const p = path.join(srcRoot, rel);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
// Read only the head — the #define is near the top.
|
||||
const head = fs.readFileSync(p, 'utf8').slice(0, 4000);
|
||||
const m = head.match(/#define\s+LANGUAGE_VERSION\s+(\d+)/);
|
||||
if (m) return Number(m[1]);
|
||||
}
|
||||
return null; // unknown (e.g. parser.c only generated at build time)
|
||||
}
|
||||
|
||||
// `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 = getVendored(g);
|
||||
let up;
|
||||
try {
|
||||
up = resolveUp(g);
|
||||
} catch (err) {
|
||||
report.push({ grammar: key, error: String(err.message || err) });
|
||||
continue;
|
||||
}
|
||||
const newer = isNewer(up, have);
|
||||
let abi = null;
|
||||
if (newer) {
|
||||
try {
|
||||
abi = readAbiFn(fetchSrc(g, up.ref));
|
||||
} catch {
|
||||
/* fetch/abi best-effort; null = unknown */
|
||||
}
|
||||
}
|
||||
report.push({
|
||||
grammar: key,
|
||||
vendored: have,
|
||||
upstream: up.version,
|
||||
ref: up.ref,
|
||||
kind: up.kind,
|
||||
update: newer,
|
||||
abi,
|
||||
abiCompatible: abi == null ? null : COMPATIBLE_ABI.has(abi),
|
||||
hold: g.hold || null,
|
||||
// Auto-appliable only when there's an update, the ABI is known-compatible,
|
||||
// AND the grammar is not on a policy hold (c).
|
||||
applicable: newer && abi != null && COMPATIBLE_ABI.has(abi) && !g.hold,
|
||||
});
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
const copyFile = (srcRoot, dest, rel) => {
|
||||
const from = path.join(srcRoot, rel);
|
||||
if (!fs.existsSync(from)) return false;
|
||||
const to = path.join(dest, rel);
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.copyFileSync(from, to);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-vendor one grammar in place from its ABI-compatible upstream candidate.
|
||||
* Copies ONLY the generated source-build + runtime files; deliberately KEEPS the
|
||||
* GitNexus-hardened binding.gyp (Windows cflags, target_name), README (vendor
|
||||
* notice), LICENSE, and prebuilds/ (the build workflow refreshes those). Bumps the
|
||||
* stripped vendor package.json version + provenance — never re-introduces
|
||||
* scripts/dependencies (#836/#1728). Returns the new version.
|
||||
*
|
||||
* 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, 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) 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,
|
||||
);
|
||||
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.`);
|
||||
return have;
|
||||
}
|
||||
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,
|
||||
);
|
||||
|
||||
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);
|
||||
// The source-build inputs + runtime entrypoints that change between versions.
|
||||
// binding.gyp / README / LICENSE / prebuilds are intentionally NOT touched.
|
||||
for (const rel of [
|
||||
'src/parser.c',
|
||||
'src/scanner.c',
|
||||
'src/node-types.json',
|
||||
'src/tree_sitter/alloc.h',
|
||||
'src/tree_sitter/array.h',
|
||||
'src/tree_sitter/parser.h',
|
||||
'bindings/node/binding.cc',
|
||||
'bindings/node/index.js',
|
||||
'bindings/node/index.d.ts',
|
||||
]) {
|
||||
copyFile(srcRoot, dest, rel);
|
||||
}
|
||||
|
||||
const pkgPath = path.join(dest, 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
pkg.version = up.version;
|
||||
pkg._vendoredBy =
|
||||
`gitnexus - re-vendored from ${g.npm ? `npm ${g.npm}@${up.version}` : `${g.github}@${up.ref}`} ` +
|
||||
`by grammar-update-monitor on ABI ${abi}. Source-build inputs (parser.c/scanner.c/src/) refreshed; ` +
|
||||
`the GitNexus-hardened binding.gyp + vendor README + prebuilds are preserved (prebuilds are ` +
|
||||
`rebuilt by build-tree-sitter-prebuilds.yml on this version change). No scripts/dependencies here ` +
|
||||
`(#836/#1728).`;
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
|
||||
console.log(`${key}: re-vendored ${g.name} → ${up.version} (ABI ${abi}).`);
|
||||
return up.version;
|
||||
}
|
||||
|
||||
// Run the CLI only when invoked directly (not when imported by a test) — detect()
|
||||
// makes live network calls, so importing must be side-effect-free.
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) {
|
||||
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,
|
||||
loadManifestGrammars,
|
||||
GRAMMARS,
|
||||
COMPATIBLE_ABI,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"_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" },
|
||||
"hold": "pinned to unreleased fwcd main commit c8ac3d26 for `fun interface` support (fwcd/tree-sitter-kotlin#169, closes #87) — npm latest (0.3.8) lacks the fix, so the monitor must NOT auto-revert (isNewer is strict-inequality: 0.3.8 != 0.4.0). Drop this hold and bump when upstream cuts a release that includes the fix"
|
||||
},
|
||||
"dart": {
|
||||
"name": "tree-sitter-dart",
|
||||
"upstream": { "github": "UserNobody14/tree-sitter-dart" }
|
||||
},
|
||||
"proto": {
|
||||
"name": "tree-sitter-proto",
|
||||
"upstream": { "github": "coder3101/tree-sitter-proto" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,632 +0,0 @@
|
||||
name: Build tree-sitter prebuilds
|
||||
|
||||
# Cross-builds the native tree-sitter prebuilds GitNexus vendors itself, so that
|
||||
# grammars whose upstream packages ship SOURCE ONLY (no usable prebuilds/) never
|
||||
# require a C/C++ toolchain at a user's install. This is the "no operational
|
||||
# risk for any tree-sitter grammar" pipeline.
|
||||
#
|
||||
# Grammars covered here (the at-risk set — everything else already ships 6
|
||||
# upstream prebuilds AND stays dependency-review-tracked, so it is left alone).
|
||||
# All five are vendored under gitnexus/vendor/; `kind` (below) only picks where
|
||||
# the build job fetches the C source to compile:
|
||||
# - tree-sitter-c (vendored prebuild-only; built from the published npm
|
||||
# package — closes upstream's 4/6 ARM gap #2116 for a
|
||||
# REQUIRED grammar)
|
||||
# - tree-sitter-dart (vendored source; built from gitnexus/vendor/)
|
||||
# - tree-sitter-proto (vendored source; built from gitnexus/vendor/)
|
||||
# - tree-sitter-kotlin (vendored source; built from gitnexus/vendor/ — pinned to
|
||||
# an unreleased main commit for `fun interface` support
|
||||
# (#169) that no npm release carries yet)
|
||||
# - tree-sitter-swift (vendored source; built from gitnexus/vendor/ — its
|
||||
# prebuilds were originally upstream-shipped, now
|
||||
# GitNexus-cross-built like the rest for uniformity)
|
||||
#
|
||||
# Output: gitnexus/vendor/<grammar>/prebuilds/<platform-arch>/<grammar>.node for
|
||||
# all 6 targets ({linux,darwin,win32}-{x64,arm64}). tree-sitter grammars are
|
||||
# N-API, so one ABI-stable .node per platform-arch works across all Node majors.
|
||||
#
|
||||
# COST DISCIPLINE — this is a HEAVY native matrix (up to 3 grammars x 6 runners,
|
||||
# incl. macOS + arm64). It is DELIBERATELY NOT wired into normal PR/push CI. It
|
||||
# runs only:
|
||||
# 1. on manual dispatch (workflow_dispatch); or
|
||||
# 2. when a covered grammar's VENDORED SOURCE changes in a PR — a version bump
|
||||
# OR an edit to the grammar's build-affecting source (parser.c / grammar.js /
|
||||
# binding.gyp / scanner / bindings). The `guard` job is the real gate (it
|
||||
# diffs BOTH the recorded version AND the source files vs the PR base); the
|
||||
# `paths:` filter below keeps ordinary code PRs at ZERO matrix time and
|
||||
# excludes the prebuilds the job commits back, so it never retriggers itself.
|
||||
# Net effect: an ordinary code PR triggers nothing; touching one grammar's source
|
||||
# costs exactly one matrix run for that grammar. Delivery of the rebuilt binaries:
|
||||
# - same-repo PR -> committed straight onto the PR's own branch (in the SAME PR);
|
||||
# - manual dispatch (open_pr=true) -> a fresh chore/ PR;
|
||||
# - fork PR -> the trusted commit-fork-prebuilds.yml (workflow_run) pushes them
|
||||
# onto the fork branch when "Allow edits by maintainers" is on, else
|
||||
# comments download-and-commit instructions. That consumer must be
|
||||
# on the DEFAULT branch to run, so it activates once merged to main.
|
||||
#
|
||||
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
|
||||
#
|
||||
# NOTE: every action below is pinned to a release commit SHA (with the matching
|
||||
# `# vX.Y.Z` tag comment verified against the GitHub API). If a future bump adds
|
||||
# a new action, pin its real release SHA and allowlist it in .github/zizmor.yml /
|
||||
# Scorecard before merge.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
grammars:
|
||||
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,swift), or "all".'
|
||||
required: false
|
||||
type: string
|
||||
default: 'all'
|
||||
ref:
|
||||
description: 'Upstream version/tag/sha override (only honored when exactly one grammar is selected).'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
force:
|
||||
description: 'Build even if the recorded version is unchanged (re-cut a broken prebuild).'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
open_pr:
|
||||
description: 'Open a PR with the rebuilt prebuilds (false = artifacts only).'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
# Any build-affecting change under a vendored grammar triggers a rebuild —
|
||||
# not just a version bump — so editing the vendored source (parser.c,
|
||||
# grammar.js, binding.gyp, scanner, bindings) re-cuts the prebuilds too.
|
||||
# The prebuilds we commit back are EXCLUDED (negated last) so the bot's own
|
||||
# in-PR commit can never retrigger this workflow (no build->commit->build loop).
|
||||
- 'gitnexus/vendor/tree-sitter-*/**'
|
||||
- '!gitnexus/vendor/tree-sitter-*/prebuilds/**'
|
||||
# Self-test: re-run the guard if a future grammar pin is reintroduced in
|
||||
# the main package.json (optionalDependencies fallback). No-op otherwise —
|
||||
# all five grammars are now fully vendored (kotlin included).
|
||||
- 'gitnexus/package.json'
|
||||
# Self-test: re-run the guard (normally a no-op) when the recipe changes.
|
||||
- '.github/workflows/build-tree-sitter-prebuilds.yml'
|
||||
|
||||
# Least privilege by default; only `aggregate` opts up.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# One slot per ref. Collapse PR re-pushes, but never cancel a manual re-cut.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# ── Gate: decide which grammars (if any) need a native rebuild, and emit the
|
||||
# {grammar x platform-arch} matrix the build job consumes. ───────────────
|
||||
guard:
|
||||
name: Decide what to build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
any: ${{ steps.decide.outputs.any }}
|
||||
matrix: ${{ steps.decide.outputs.matrix }}
|
||||
release_app: ${{ steps.relapp.outputs.configured }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0 # need base history to diff recorded versions
|
||||
persist-credentials: false
|
||||
|
||||
- name: Decide
|
||||
id: decide
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
# Untrusted dispatch inputs — read via env only, validated in JS.
|
||||
INPUT_GRAMMARS: ${{ inputs.grammars }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.force || 'false' }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module - <<'NODE'
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
// Registry of the at-risk grammars this workflow owns. `kind` drives
|
||||
// how the build job resolves source: 'npm' pulls the published package;
|
||||
// 'vendored' builds from gitnexus/vendor/<name> (which carries the C
|
||||
// source + binding.gyp). Extend this list to cover a new grammar.
|
||||
const REGISTRY = {
|
||||
// c is vendored prebuild-only but BUILT from the published npm
|
||||
// package (kind 'npm'), held at 0.21.4 — it closes upstream's 4/6
|
||||
// ARM gap (#2116) for a REQUIRED grammar that otherwise hard-fails
|
||||
// install on toolchain-less ARM.
|
||||
c: { name: 'tree-sitter-c', kind: 'npm' },
|
||||
dart: { name: 'tree-sitter-dart', kind: 'vendored' },
|
||||
proto: { name: 'tree-sitter-proto', kind: 'vendored' },
|
||||
// kotlin is vendored WITH its source (parser.c/scanner.c/binding.gyp),
|
||||
// so it builds from gitnexus/vendor/ like dart/proto/swift. It was
|
||||
// 'npm' while tracking released versions, but is now pinned to an
|
||||
// unreleased main commit for `fun interface` support (#169) that no
|
||||
// npm release carries yet — so it must build from the vendored source.
|
||||
kotlin: { name: 'tree-sitter-kotlin', kind: 'vendored' },
|
||||
// swift is vendored WITH its source (parser.c/scanner.c/binding.gyp),
|
||||
// so it builds from gitnexus/vendor/ like dart/proto. Its prebuilds
|
||||
// were originally upstream-shipped; rebuilding them here unifies it.
|
||||
swift: { name: 'tree-sitter-swift', kind: 'vendored' },
|
||||
};
|
||||
const PLATFORMS = [
|
||||
{ platform_arch: 'linux-x64', os: 'ubuntu-24.04' },
|
||||
{ platform_arch: 'linux-arm64', os: 'ubuntu-24.04-arm' },
|
||||
{ platform_arch: 'darwin-arm64', os: 'macos-15' },
|
||||
{ platform_arch: 'darwin-x64', os: 'macos-15-intel' }, // macos-13 retired Dec-2025; Intel EOL ~Aug-2027
|
||||
{ platform_arch: 'win32-x64', os: 'windows-2022' },
|
||||
{ platform_arch: 'win32-arm64', os: 'windows-11-arm' },
|
||||
];
|
||||
|
||||
const clean = (v) => (v || '').replace(/^[\^~]/, '').trim();
|
||||
const json = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
|
||||
|
||||
// Durable version key for a grammar at a checkout root. Prefer the
|
||||
// vendor snapshot (the post-vendor source of truth); fall back to the
|
||||
// optionalDependencies pin during the transition window. (A guard keyed
|
||||
// on the node_modules lock entry would self-disable once a grammar is
|
||||
// vendored, because that entry is deleted.)
|
||||
function recordedVersion(root, name) {
|
||||
const v = json(`${root}/gitnexus/vendor/${name}/package.json`);
|
||||
if (v && v.version) return clean(v.version);
|
||||
const pkg = json(`${root}/gitnexus/package.json`);
|
||||
const od = pkg && (pkg.optionalDependencies || {});
|
||||
const d = pkg && (pkg.dependencies || {});
|
||||
return clean((od && od[name]) || (d && d[name]) || '');
|
||||
}
|
||||
|
||||
const event = process.env.EVENT;
|
||||
const force = process.env.FORCE === 'true';
|
||||
|
||||
// Select which grammar shortnames are in play.
|
||||
let selected;
|
||||
if (event === 'workflow_dispatch') {
|
||||
const raw = (process.env.INPUT_GRAMMARS || 'all').trim();
|
||||
selected = raw === 'all' ? Object.keys(REGISTRY)
|
||||
: raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
for (const s of selected) if (!REGISTRY[s]) throw new Error(`unknown grammar '${s}'`);
|
||||
} else {
|
||||
selected = Object.keys(REGISTRY);
|
||||
}
|
||||
|
||||
// Resolve the base-ref recorded versions (pull_request only) so we can
|
||||
// diff. On dispatch, base is irrelevant (manual intent / force wins).
|
||||
const baseRoot = `${process.env.RUNNER_TEMP}/base`;
|
||||
const baseSha = process.env.BASE_SHA;
|
||||
// Defense in depth: baseSha is interpolated into git commands below, so
|
||||
// reject anything that is not a plain commit-ish before we touch a shell.
|
||||
if (event === 'pull_request' && baseSha && !/^[0-9a-fA-F]{7,40}$/.test(baseSha)) {
|
||||
throw new Error(`unexpected base sha '${baseSha}'`);
|
||||
}
|
||||
if (event === 'pull_request') {
|
||||
for (const s of selected) {
|
||||
const name = REGISTRY[s].name;
|
||||
for (const rel of [`gitnexus/vendor/${name}/package.json`, `gitnexus/package.json`]) {
|
||||
const dst = `${baseRoot}/${rel}`;
|
||||
fs.mkdirSync(dst.slice(0, dst.lastIndexOf('/')), { recursive: true });
|
||||
try {
|
||||
const buf = execSync(`git show ${baseSha}:${rel}`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
fs.writeFileSync(dst, buf);
|
||||
} catch { /* file absent at base — fine */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The single-ref override is only meaningful for a one-grammar dispatch.
|
||||
const refOverride = clean(process.env.INPUT_REF);
|
||||
if (refOverride && !(event === 'workflow_dispatch' && selected.length === 1)) {
|
||||
throw new Error('ref override requires exactly one grammar selected');
|
||||
}
|
||||
const safeRef = (r) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(r);
|
||||
|
||||
const include = [];
|
||||
const built = [];
|
||||
for (const short of selected) {
|
||||
const { name, kind } = REGISTRY[short];
|
||||
const head = recordedVersion('.', name);
|
||||
const ref = refOverride || head;
|
||||
if (!ref) { console.log(`skip ${short}: no recorded version`); continue; }
|
||||
if (!safeRef(ref)) throw new Error(`unsafe ref for ${short}: '${ref}'`);
|
||||
|
||||
let build = false;
|
||||
if (event === 'workflow_dispatch') {
|
||||
build = true; // manual intent (force toggles only the unchanged-guard, which is bypassed here)
|
||||
} else {
|
||||
// pull_request: build when the recorded version changed OR any
|
||||
// build-affecting source file under the vendored grammar changed vs
|
||||
// the PR base. The prebuilds/ subtree is excluded from the diff so
|
||||
// the bot's own in-PR commit (which adds ONLY prebuilds) never reads
|
||||
// as a source change — this is the other half of the no-loop guard.
|
||||
const base = recordedVersion(baseRoot, name);
|
||||
const versionChanged = !!head && head !== base;
|
||||
let sourceChanged = false;
|
||||
try {
|
||||
const diff = execSync(
|
||||
`git diff --name-only ${baseSha} -- gitnexus/vendor/${name} ` +
|
||||
`':(exclude)gitnexus/vendor/${name}/prebuilds/**'`,
|
||||
{ stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
).toString().trim();
|
||||
sourceChanged = diff.length > 0;
|
||||
} catch { /* base unavailable -> fall back to the version gate */ }
|
||||
build = versionChanged || sourceChanged;
|
||||
console.log(`${short}: version ${versionChanged ? 'changed' : 'same'}, source ${sourceChanged ? 'changed' : 'same'} -> ${build ? 'BUILD' : 'skip'}`);
|
||||
}
|
||||
if (force) build = true;
|
||||
if (!build) continue;
|
||||
built.push(short);
|
||||
for (const p of PLATFORMS) include.push({ grammar: short, name, kind, ref, ...p });
|
||||
}
|
||||
|
||||
const out = process.env.GITHUB_OUTPUT;
|
||||
appendFileSync(out, `any=${include.length > 0}\n`);
|
||||
appendFileSync(out, `matrix=${JSON.stringify({ include })}\n`);
|
||||
if (include.length === 0) {
|
||||
console.log('::notice::No covered grammar version changed — skipping native matrix.');
|
||||
} else {
|
||||
console.log(`Building: ${built.join(', ')} (${include.length} jobs)`);
|
||||
}
|
||||
NODE
|
||||
|
||||
# The aggregate job opens a PR via a GitHub App token; without the App
|
||||
# secrets it would hard-fail AFTER a full native build. Surface their
|
||||
# presence as a guard output so aggregate skips cleanly (the build job's
|
||||
# artifacts still upload). secrets aren't available in a job-level `if:`,
|
||||
# so we compute the boolean here (a step CAN read secrets) and gate on it.
|
||||
- name: Check release App secret
|
||||
id: relapp
|
||||
env:
|
||||
HAS_APP: ${{ secrets.RELEASE_APP_ID != '' && secrets.RELEASE_APP_PRIVATE_KEY != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "configured=$HAS_APP" >> "$GITHUB_OUTPUT"
|
||||
if [ "$HAS_APP" != "true" ]; then
|
||||
echo "::notice::Release GitHub App secrets (RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY) are not configured — prebuilds will build and upload as artifacts, but the auto-PR is skipped. Provision the App, or run with open_pr=false to suppress this notice."
|
||||
fi
|
||||
|
||||
# ── Fork PRs: emit the PR identity so the trusted `commit-fork-prebuilds`
|
||||
# workflow_run job can push the rebuilt prebuilds back onto the fork's
|
||||
# branch. That job has no PR context of its own (workflow_run.pull_requests
|
||||
# is empty for forks), so it reads this. Same-repo PRs don't need it — the
|
||||
# aggregate job below commits straight onto their branch. This artifact is
|
||||
# untrusted producer output: every field is allowlist-validated again on
|
||||
# the consumer side AND cross-checked against the workflow_run authority.
|
||||
- name: Record fork PR identity
|
||||
id: forkmeta
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && steps.decide.outputs.any == 'true'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/pr-meta"
|
||||
# Values flow through env + jq so an exotic head_ref is quoted, never
|
||||
# interpolated into a shell command.
|
||||
jq -n \
|
||||
--arg schema "gitnexus.ts-prebuild/v1" \
|
||||
--argjson pr_number "$PR_NUMBER" \
|
||||
--arg head_sha "$HEAD_SHA" \
|
||||
--arg head_ref "$HEAD_REF" \
|
||||
--arg head_repo "$HEAD_REPO" \
|
||||
--arg base_repo "$BASE_REPO" \
|
||||
'{schema:$schema, pr_number:$pr_number, head_sha:$head_sha, head_ref:$head_ref, head_repo:$head_repo, base_repo:$base_repo}' \
|
||||
> "$RUNNER_TEMP/pr-meta/metadata.json"
|
||||
cat "$RUNNER_TEMP/pr-meta/metadata.json"
|
||||
|
||||
- name: Upload fork PR meta
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && steps.decide.outputs.any == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pr-meta
|
||||
path: ${{ runner.temp }}/pr-meta/metadata.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# ── Build one native prebuild per (grammar, platform-arch). No cross-compile. ─
|
||||
build:
|
||||
name: ${{ matrix.grammar }} ${{ matrix.platform_arch }}
|
||||
needs: guard
|
||||
if: needs.guard.outputs.any == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.guard.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
# 45 (not 30) for headroom: the kotlin parser.c is ~23 MB and swift's ~18 MB,
|
||||
# and compiling them under emulation on the arm runners is slow.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false # this job uploads artifacts (artipacked)
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Ensure Python (arm64 Windows only)
|
||||
if: matrix.platform_arch == 'win32-arm64'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Build prebuild
|
||||
id: build
|
||||
shell: bash
|
||||
env:
|
||||
GRAMMAR: ${{ matrix.grammar }}
|
||||
NAME: ${{ matrix.name }}
|
||||
KIND: ${{ matrix.kind }}
|
||||
REF: ${{ matrix.ref }}
|
||||
PLATFORM_ARCH: ${{ matrix.platform_arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
work="$RUNNER_TEMP/ts-build"
|
||||
rm -rf "$work"; mkdir -p "$work"; cd "$work"
|
||||
npm init -y >/dev/null
|
||||
|
||||
# node-addon-api must match what the grammar's binding.cc expects.
|
||||
# GitNexus hoists ^8 for the vendored grammars; npm grammars declare
|
||||
# their own (do NOT pin it for npm grammars — let the dep resolve it).
|
||||
if [ "$KIND" = "vendored" ]; then
|
||||
# Build from the vendored C source (carries parser.c + binding.gyp).
|
||||
srcdir="$work/$NAME"
|
||||
cp -R "$GITHUB_WORKSPACE/gitnexus/vendor/$NAME" "$srcdir"
|
||||
rm -rf "$srcdir/prebuilds" "$srcdir/build" "$srcdir/node_modules"
|
||||
npm install --no-audit --no-fund --ignore-scripts \
|
||||
prebuildify@^6 node-gyp@^11 node-addon-api@^8
|
||||
pkgdir="$srcdir"
|
||||
export npm_config_node_gyp="$work/node_modules/node-gyp/bin/node-gyp.js"
|
||||
else
|
||||
# Pull the published source-only package.
|
||||
npm install --no-audit --no-fund --ignore-scripts \
|
||||
"$NAME@${REF}" prebuildify@^6 node-gyp@^11
|
||||
pkgdir="$work/node_modules/$NAME"
|
||||
fi
|
||||
|
||||
test -f "$pkgdir/binding.gyp" || { echo "::error::no binding.gyp for $NAME@$REF"; exit 1; }
|
||||
|
||||
# Drop any prebuilds the package shipped in its own tarball before we
|
||||
# build. The tree-sitter-org npm grammars (e.g. tree-sitter-c) bundle
|
||||
# prebuilds/ for all 6 tuples; left in place, the `find ... -print -quit`
|
||||
# below would pick a non-host tuple (e.g. win32-x64 on a linux runner)
|
||||
# and the assertion would wrongly fail. prebuildify rebuilds THIS host's
|
||||
# tuple from the source the tarball also ships. (Vendored grammars are
|
||||
# already cleaned above; this also covers the npm branch.)
|
||||
rm -rf "$pkgdir/prebuilds"
|
||||
|
||||
# N-API, stripped, single ABI-stable binary for THIS host's arch. No
|
||||
# `-t <node-version>`: an N-API prebuild is Node-version-agnostic, and
|
||||
# prebuildify parses a bare `-t 22` as the NUMBER 22 and crashes
|
||||
# (`v.indexOf is not a function`). prebuildify emits
|
||||
# prebuilds/<platform>-<arch>/<something>.node.
|
||||
( cd "$pkgdir" && npx --no-install prebuildify --napi --strip )
|
||||
|
||||
out=$(find "$pkgdir/prebuilds" -name '*.node' -print -quit)
|
||||
test -n "$out" || { echo "::error::prebuildify produced no .node"; exit 1; }
|
||||
produced=$(basename "$(dirname "$out")")
|
||||
[ "$produced" = "$PLATFORM_ARCH" ] || { echo "::error::built $produced, expected $PLATFORM_ARCH"; exit 1; }
|
||||
|
||||
stage="$RUNNER_TEMP/stage/$GRAMMAR/$PLATFORM_ARCH"; mkdir -p "$stage"
|
||||
cp "$out" "$stage/$NAME.node"
|
||||
echo "stage=$stage" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate the .node loads and parses on this arch
|
||||
shell: bash
|
||||
env:
|
||||
GRAMMAR: ${{ matrix.grammar }}
|
||||
NAME: ${{ matrix.name }}
|
||||
PLATFORM_ARCH: ${{ matrix.platform_arch }}
|
||||
EXPECT_ARCH: ${{ contains(matrix.platform_arch, 'arm64') && 'arm64' || 'x64' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
probe="$RUNNER_TEMP/probe"; rm -rf "$probe"
|
||||
mkdir -p "$probe/prebuilds/$PLATFORM_ARCH"
|
||||
cp "$RUNNER_TEMP/stage/$GRAMMAR/$PLATFORM_ARCH/$NAME.node" \
|
||||
"$probe/prebuilds/$PLATFORM_ARCH/$NAME.node"
|
||||
cd "$probe"
|
||||
# Pin tree-sitter to the repo's exact runtime peer so an ABI mismatch
|
||||
# fails HERE, not in a user's install (mirrors the #1922 ABI gate).
|
||||
# NOT --ignore-scripts: tree-sitter@0.21.1's tarball ships prebuilds for
|
||||
# the common tuples but NOT linux-arm64 / win32-arm64, so on the arm64
|
||||
# runners node-gyp-build must source-build the runtime — give it node-gyp
|
||||
# + node-addon-api to do so. Where tree-sitter ships a prebuild (x64,
|
||||
# darwin-arm64) node-gyp-build uses it and nothing compiles. The grammar
|
||||
# .node we built is still loaded as a prebuild; only the runtime peer may
|
||||
# compile. The grammar-vs-runtime ABI check still fires at setLanguage.
|
||||
npm install --no-audit --no-fund \
|
||||
node-gyp-build@^4 node-gyp@^11 node-addon-api@^8 tree-sitter@0.21.1
|
||||
# The node script is single-quoted on purpose — its ${...} are JS
|
||||
# template literals read from the environment, not shell expansions.
|
||||
# shellcheck disable=SC2016
|
||||
GRAMMAR="$GRAMMAR" EXPECT_ARCH="$EXPECT_ARCH" node -e '
|
||||
const expect = process.env.EXPECT_ARCH;
|
||||
// Catch an emulated x64 Node silently mis-passing on an arm64 runner.
|
||||
if (process.arch !== expect) throw new Error(`runner arch ${process.arch} != ${expect}`);
|
||||
const snippets = {
|
||||
c: "int main(void) { return 0; }",
|
||||
dart: "void main() { print(\"hi\"); }",
|
||||
proto: "syntax = \"proto3\";\nmessage M { int32 id = 1; }",
|
||||
kotlin: "fun main() { println(\"hi\") }",
|
||||
swift: "func greet() { print(\"hi\") }",
|
||||
};
|
||||
const lang = require("node-gyp-build")(process.cwd());
|
||||
const Parser = require("tree-sitter");
|
||||
const p = new Parser(); p.setLanguage(lang);
|
||||
const tree = p.parse(snippets[process.env.GRAMMAR]);
|
||||
if (!tree || !tree.rootNode || tree.rootNode.hasError) {
|
||||
throw new Error("parse failed/error: " + (tree && tree.rootNode && tree.rootNode.type));
|
||||
}
|
||||
console.log("OK", process.env.GRAMMAR, process.platform + "-" + process.arch, tree.rootNode.type);
|
||||
'
|
||||
|
||||
- name: Upload prebuild artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ts-prebuild-${{ matrix.grammar }}-${{ matrix.platform_arch }}
|
||||
path: ${{ steps.build.outputs.stage }}/${{ matrix.name }}.node
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# ── Aggregate every grammar's six prebuilds, assert completeness, deliver them. ─
|
||||
aggregate:
|
||||
name: Vendor prebuilds + deliver
|
||||
needs: [guard, build]
|
||||
# Runs on a non-fork pull_request whose vendored grammar source changed — the
|
||||
# rebuilt prebuilds are committed straight onto that PR's own branch (same PR)
|
||||
# — or on a manual dispatch with open_pr=true, which opens a fresh chore/ PR.
|
||||
# Fork PRs are excluded: a bot cannot push into a fork branch, so they get
|
||||
# artifacts only. Event-gating is explicit so we never rely on GHA coercing a
|
||||
# null `inputs.open_pr` on pull_request events (Codex F4): `inputs.open_pr` is
|
||||
# null off-dispatch, and `null != false` is direction-ambiguous, so `open_pr`
|
||||
# is only consulted on workflow_dispatch.
|
||||
if: >-
|
||||
needs.guard.outputs.any == 'true' &&
|
||||
needs.guard.outputs.release_app == 'true' &&
|
||||
github.event.pull_request.head.repo.fork != true &&
|
||||
(github.event_name == 'pull_request' || inputs.open_pr == true)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read # actual writes use a short-lived App token below
|
||||
id-token: write # SLSA provenance attestation
|
||||
attestations: write
|
||||
steps:
|
||||
- name: Mint GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# On a (non-fork) PR, check out the PR's HEAD branch — not the merge ref —
|
||||
# so the rebuilt-prebuilds commit lands on the PR's own branch (same PR).
|
||||
# Empty on manual dispatch -> the workflow's default ref.
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || '' }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download all prebuild artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ${{ runner.temp }}/dl
|
||||
pattern: ts-prebuild-*
|
||||
|
||||
- name: Place prebuilds, assert each built grammar has all 6, write SHA256SUMS
|
||||
id: place
|
||||
shell: bash
|
||||
env:
|
||||
MATRIX: ${{ needs.guard.outputs.matrix }}
|
||||
DL: ${{ runner.temp }}/dl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module - <<'NODE'
|
||||
import fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
const include = JSON.parse(process.env.MATRIX).include;
|
||||
const dl = process.env.DL;
|
||||
const byGrammar = {};
|
||||
for (const e of include) (byGrammar[e.grammar] ||= { name: e.name, archs: [] }).archs.push(e.platform_arch);
|
||||
const PLATFORMS = ['linux-x64','linux-arm64','darwin-arm64','darwin-x64','win32-x64','win32-arm64'];
|
||||
const changed = [];
|
||||
for (const [grammar, { name }] of Object.entries(byGrammar)) {
|
||||
const dest = `gitnexus/vendor/${name}/prebuilds`;
|
||||
// A vendored grammar with 5/6 prebuilds silently breaks node-gyp-build
|
||||
// on the 6th platform — refuse a partial result.
|
||||
for (const pa of PLATFORMS) {
|
||||
const art = `${dl}/ts-prebuild-${grammar}-${pa}/${name}.node`;
|
||||
if (!fs.existsSync(art)) throw new Error(`missing ${grammar} prebuild for ${pa}`);
|
||||
fs.mkdirSync(`${dest}/${pa}`, { recursive: true });
|
||||
fs.copyFileSync(art, `${dest}/${pa}/${name}.node`);
|
||||
}
|
||||
execSync(`cd ${dest} && find . -name '*.node' | sort | xargs sha256sum > SHA256SUMS`);
|
||||
changed.push(name);
|
||||
}
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `grammars=${changed.join(',')}\n`);
|
||||
console.log('Vendored prebuilds for:', changed.join(', '));
|
||||
NODE
|
||||
|
||||
- name: Attest build provenance (SLSA)
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'
|
||||
|
||||
- name: Deliver rebuilt prebuilds
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GRAMMARS: ${{ steps.place.outputs.grammars }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { execSync } = require('node:child_process');
|
||||
const run = (c) => execSync(c, { stdio: ['ignore', 'pipe', 'inherit'] }).toString().trim();
|
||||
const grammars = process.env.GRAMMARS;
|
||||
const { owner, repo } = context.repo;
|
||||
const remote = `https://x-access-token:${process.env.GH_TOKEN}@github.com/${owner}/${repo}.git`;
|
||||
|
||||
run('git add gitnexus/vendor/tree-sitter-*/prebuilds');
|
||||
if (!run('git status --porcelain -- gitnexus/vendor/tree-sitter-*/prebuilds')) {
|
||||
core.notice('Prebuilds byte-identical to vendor; nothing to commit.');
|
||||
return;
|
||||
}
|
||||
run('git config user.name "gitnexus-release-bot[bot]"');
|
||||
run('git config user.email "gitnexus-release-bot[bot]@users.noreply.github.com"');
|
||||
run(`git commit -m "chore(vendor): rebuild native prebuilds (${grammars})" -m "Built by ${process.env.RUN_URL}"`);
|
||||
|
||||
// ── Same-repo PR: ride the rebuilt prebuilds into the SAME PR by
|
||||
// pushing one commit onto its head branch. The aggregate checkout
|
||||
// used `ref: head.ref`, so HEAD is the PR branch tip (NOT the merge
|
||||
// ref) and this is a clean fast-forward of exactly our new commit.
|
||||
// Plain push (NOT --force): we only ever ADD on top of head, so we
|
||||
// must never clobber the contributor's commits. If the branch
|
||||
// advanced mid-build the push is rejected — and the PR's
|
||||
// cancel-in-progress concurrency will already have started a fresher
|
||||
// run against the new head — so a rejection is a no-op we just note.
|
||||
if (context.eventName === 'pull_request') {
|
||||
const headRef = context.payload.pull_request.head.ref;
|
||||
try {
|
||||
run(`git push "${remote}" "HEAD:${headRef}"`);
|
||||
core.notice(`Pushed rebuilt prebuilds onto PR branch '${headRef}' (included in this PR).`);
|
||||
} catch (e) {
|
||||
core.warning(`Could not fast-forward '${headRef}' (it likely advanced mid-build); a fresher run will rebuild. ${e.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Manual dispatch: there is no PR to attach to, so open a fresh one
|
||||
// off an ephemeral, run-unique branch. Plain --force is safe here:
|
||||
// the branch is keyed by context.runId and written ONLY by this job,
|
||||
// so there is no concurrent writer to protect against.
|
||||
const slug = grammars.replace(/[^a-z0-9]+/gi, '-');
|
||||
const branch = `chore/vendor-ts-prebuilds-${slug}-${context.runId}`;
|
||||
run(`git checkout -b "${branch}"`);
|
||||
run(`git push --force "${remote}" "HEAD:${branch}"`);
|
||||
const body = [
|
||||
`Rebuilt the vendored native prebuilds for: **${grammars}**.`,
|
||||
'',
|
||||
`Builder run: ${process.env.RUN_URL}`,
|
||||
'Each `.node` was `require()`-loaded + parsed a real snippet on its target',
|
||||
'platform-arch before upload. SLSA build-provenance attested; `SHA256SUMS`',
|
||||
'committed alongside each grammar.',
|
||||
].join('\n');
|
||||
const { data: pr } = await github.rest.pulls.create({
|
||||
owner, repo, head: branch, base: 'main',
|
||||
title: `chore(vendor): tree-sitter prebuilds (${grammars})`, body,
|
||||
});
|
||||
core.info(`Opened PR #${pr.number}`);
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
# persist-credentials: false — this job only reads (tests and syntax
|
||||
# checks) and never pushes. The setting keeps GITHUB_TOKEN out of
|
||||
# .git/config, which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# persist-credentials: false — this is a read-only build smoke that
|
||||
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
|
||||
# which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -14,9 +14,7 @@ jobs:
|
||||
outputs:
|
||||
web_changed: ${{ steps.filter.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -31,9 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Configure e2e GitNexus home
|
||||
run: echo "GITNEXUS_HOME=${RUNNER_TEMP}/gitnexus-home" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -11,9 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -26,9 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -41,9 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
@@ -52,9 +46,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
- run: npx tsc -b --noEmit
|
||||
working-directory: gitnexus-web
|
||||
@@ -75,9 +67,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Validate workflow concurrency convention
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: gitnexus/vitest.config.ts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
name: Scope Resolution Parity
|
||||
|
||||
# Reusable workflow — called from ci.yml. Does NOT declare concurrency;
|
||||
# it inherits the caller's concurrency group per the convention documented
|
||||
# in CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
#
|
||||
# ── Purpose (RFC #909 Ring 3, §6.4 "Observability gates") ──────────────
|
||||
# For every language in `MIGRATED_LANGUAGES` (exported from
|
||||
# `gitnexus/src/core/ingestion/registry-primary-flag.ts`), run the
|
||||
# resolver integration test at `test/integration/resolvers/<slug>.test.ts`
|
||||
# TWICE on every PR:
|
||||
#
|
||||
# 1. `REGISTRY_PRIMARY_<LANG>=0` — legacy DAG path (guarantees we haven't
|
||||
# broken the old path while migrating). Known legacy gaps may be skipped
|
||||
# through the resolver test helper's expected-failure list.
|
||||
# 2. `REGISTRY_PRIMARY_<LANG>=1` — registry-primary path (guarantees the
|
||||
# new path carries the same behavior — the parity gate).
|
||||
#
|
||||
# BOTH must pass. The source of truth is the TypeScript constant — adding
|
||||
# a language to that `Set` is the ONLY contributor action; CI auto-
|
||||
# discovers it, runs parity, and the language's default production path
|
||||
# flips to registry-primary in the same change.
|
||||
#
|
||||
# When the set is empty (e.g. mid-Ring-3 for every language), the parity
|
||||
# matrix is skipped and the workflow reports success — no-op until a
|
||||
# language is explicitly claimed migrated.
|
||||
#
|
||||
# ── Consolidation (chore/vitest-speed-strategy) ────────────────────────
|
||||
# Previously each language was a separate GitHub Actions matrix job,
|
||||
# meaning N languages × 1 checkout+install+build per shard. The build
|
||||
# cost dwarfed the test cost (~5 min setup for ~15 sec test execution).
|
||||
#
|
||||
# Now a single job runs `scripts/run-parity.ts` which loops through all
|
||||
# migrated languages sequentially (2 vitest invocations per language:
|
||||
# legacy + registry-primary). All failures are collected and reported
|
||||
# at the end (equivalent to the old fail-fast: false behavior).
|
||||
#
|
||||
# Adding a new language to MIGRATED_LANGUAGES still requires no workflow
|
||||
# edit — the script auto-discovers the set at runtime.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
name: Discover migrated languages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
has-any: ${{ steps.read.outputs.has-any }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
||||
- name: Extract MIGRATED_LANGUAGES from registry-primary-flag.ts
|
||||
id: read
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LANGS=$(npx tsx scripts/ci-list-migrated-languages.ts)
|
||||
COUNT=$(printf '%s' "$LANGS" | jq 'length')
|
||||
HAS_ANY="false"
|
||||
if [[ "$COUNT" -gt 0 ]]; then HAS_ANY="true"; fi
|
||||
echo "has-any=$HAS_ANY" >> "$GITHUB_OUTPUT"
|
||||
echo "Discovered $COUNT migrated language(s): $LANGS"
|
||||
echo "Parity will run: $HAS_ANY"
|
||||
|
||||
parity:
|
||||
name: scope-resolution parity
|
||||
needs: discover
|
||||
if: needs.discover.outputs.has-any == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Run parity for all migrated languages
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: npx tsx scripts/run-parity.ts
|
||||
@@ -11,16 +11,12 @@ jobs:
|
||||
name: ubuntu / coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
# Fail loudly (don't silently skip) if the FTS extension is unavailable, so
|
||||
# FTS-dependent lbug integration suites are guaranteed to run in CI.
|
||||
env:
|
||||
GITNEXUS_REQUIRE_FTS: '1'
|
||||
steps:
|
||||
# persist-credentials: false — this job runs tests and uploads a
|
||||
# test-reports artifact (if: always()). The default-persisted token in
|
||||
# .git/config must not be capturable through that upload (zizmor
|
||||
# credential-persistence / artipacked audit). The job never pushes.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -81,14 +77,10 @@ jobs:
|
||||
os: [windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
# Same guarantee on the platform-sensitive runners: FTS-dependent suites in
|
||||
# the cross-platform subset must run, not silently skip.
|
||||
env:
|
||||
GITNEXUS_REQUIRE_FTS: '1'
|
||||
steps:
|
||||
# persist-credentials: false — runs tests only, never pushes (zizmor
|
||||
# credential-persistence / artipacked audit).
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -102,9 +94,8 @@ jobs:
|
||||
# 1. Static, offline: assert every grammar's compiled ABI loads on the
|
||||
# pinned runtime (check-tree-sitter-upgrade-readiness.py --assert-current).
|
||||
# 2. Dynamic: run the parser-loader ABI load-smoke on the OS matrix so an
|
||||
# ABI-incompatible committed vendor prebuilt (e.g. Swift's — the static
|
||||
# check introspects source, not the shipped .node) fails on the platform
|
||||
# it ships to.
|
||||
# ABI-incompatible prebuilt (esp. the binary-only Swift vendor, which the
|
||||
# static check can't introspect) fails on the platform it ships to.
|
||||
abi-assert:
|
||||
name: tree-sitter ABI (${{ matrix.os }})
|
||||
strategy:
|
||||
@@ -114,9 +105,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
@@ -148,7 +137,7 @@ jobs:
|
||||
# from a tarball and never pushes back; the token in .git/config would
|
||||
# be at risk of leaking through any future artifact-upload step
|
||||
# (zizmor artipacked audit). Disable upfront.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -256,7 +245,7 @@ jobs:
|
||||
# and never pushes; the default-persisted token in .git/config would be at
|
||||
# risk of leaking through an artifact upload (zizmor credential-persistence
|
||||
# / artipacked audit). Mirrors the packaged-install-smoke job below.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -276,34 +265,6 @@ jobs:
|
||||
run: node --import tsx bench/scope-capture/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: CFG construction time / disk / memory guards (#2081 M1)
|
||||
# Build-free: asserts collectFunctionCfgs output is unchanged
|
||||
# (fingerprint) and that wall-time, cfgSideChannel disk bytes, AND
|
||||
# retained heap all stay sub-quadratic for the straight-line /
|
||||
# many-functions / branchy scenarios. Catches an O(n^2) re-regression in
|
||||
# the per-function CFG builder (e.g. an extendBlock concat chain) and a
|
||||
# memory/disk blow-up. --expose-gc enables the retained-heap measurement.
|
||||
run: node --expose-gc --import tsx bench/cfg/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Emit-persistence throughput / byte-identity guards (#2203)
|
||||
# Build-free: asserts streamAllCSVsToDisk output is byte-identical
|
||||
# (order-independent CSV-line fingerprint — the #2203 U2/U3 emit
|
||||
# optimisations must not change graph content) and that emit wall-time
|
||||
# stays linear in node+edge count. The LadybugDB COPY half needs a real
|
||||
# DB, so its timing lives in the runtime PROF_LBUG_LOAD breakdown.
|
||||
run: node --import tsx bench/emit-persistence/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Streaming PDG-emit byte-identity / bounded-RSS guards (#2202)
|
||||
# Build-free: asserts the streaming PdgEmitSink emits a CSV row SET
|
||||
# byte-identical to the whole-graph streamAllCSVsToDisk emit, AND that
|
||||
# the in-memory graph retains zero BasicBlock nodes (the O(chunk) peak-RSS
|
||||
# bound that unblocks full-kernel-scale repos). Fails on fingerprint drift
|
||||
# or any resident BasicBlock.
|
||||
run: node --import tsx bench/emit-persistence/measure-streaming.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
|
||||
env:
|
||||
GITNEXUS_BENCH: '1'
|
||||
|
||||
@@ -27,8 +27,9 @@ concurrency:
|
||||
# Each concern lives in its own workflow file for maintainability:
|
||||
# ci-quality.yml — typecheck (tsc --noEmit)
|
||||
# ci-tests.yml — unit + integration tests with coverage + cross-platform
|
||||
# (includes the scope-resolution resolver tests)
|
||||
# ci-e2e.yml — E2E tests (only when gitnexus-web/ changes)
|
||||
# ci-scope-parity.yml — RFC #909 Ring 3 parity gate: legacy DAG + registry-primary
|
||||
# both pass, per migrated language in the JSON registry
|
||||
#
|
||||
# Shared setup is DRY via .github/actions/setup-gitnexus composite action.
|
||||
|
||||
@@ -48,6 +49,11 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
scope-parity:
|
||||
uses: ./.github/workflows/ci-scope-parity.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ── Save PR metadata for the reporting workflow ─────────────────
|
||||
# The ci-report.yml workflow (triggered by workflow_run) needs the
|
||||
# PR number and job results to post a comment. We save them as an
|
||||
@@ -56,7 +62,7 @@ jobs:
|
||||
save-pr-meta:
|
||||
name: Save PR Metadata
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs: [quality, tests, e2e]
|
||||
needs: [quality, tests, e2e, scope-parity]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -67,12 +73,14 @@ jobs:
|
||||
QUALITY: ${{ needs.quality.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
SCOPE_PARITY: ${{ needs.scope-parity.result }}
|
||||
run: |
|
||||
mkdir -p pr-meta
|
||||
echo "$PR_NUMBER" > pr-meta/pr_number
|
||||
echo "$QUALITY" > pr-meta/quality_result
|
||||
echo "$TESTS" > pr-meta/tests_result
|
||||
echo "$E2E" > pr-meta/e2e_result
|
||||
echo "$SCOPE_PARITY" > pr-meta/scope_parity_result
|
||||
# TODO(post-merge): remove backward-compat copies once ci-report.yml
|
||||
# on main reads underscore names.
|
||||
# Backward-compat: ci-report.yml on main still reads hyphenated
|
||||
@@ -95,7 +103,7 @@ jobs:
|
||||
# Single required check for branch protection.
|
||||
ci-status:
|
||||
name: CI Gate
|
||||
needs: [quality, tests, e2e]
|
||||
needs: [quality, tests, e2e, scope-parity]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
@@ -109,15 +117,14 @@ jobs:
|
||||
# reusable workflow, so `needs.tests.result` below blocks the merge
|
||||
# on an ABI mismatch. (`jobs.<id>.result` cannot be exposed as a
|
||||
# workflow_call output, so the gate is enforced transitively here.)
|
||||
# The scope-resolution resolver tests also run inside the `tests`
|
||||
# workflow (RING4-1 #942 removed the separate scope-parity gate),
|
||||
# so a resolver regression makes TESTS != success and blocks here.
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
SCOPE_PARITY: ${{ needs.scope-parity.result }}
|
||||
run: |
|
||||
echo "Quality: $QUALITY"
|
||||
echo "Tests: $TESTS"
|
||||
echo "E2E: $E2E"
|
||||
echo "Scope parity: $SCOPE_PARITY"
|
||||
# A failed `abi-assert` job (#1922) inside the tests reusable
|
||||
# workflow makes TESTS != success, so this clause also blocks the
|
||||
# merge on a tree-sitter ABI mismatch.
|
||||
@@ -130,3 +137,14 @@ jobs:
|
||||
echo "::error::E2E job failed"
|
||||
exit 1
|
||||
fi
|
||||
# scope-parity is a reusable workflow. With an empty migrated-
|
||||
# languages list, its parity matrix is skipped and the outer
|
||||
# workflow still reports `success`. If any entry's legacy-DAG or
|
||||
# registry-primary run fails, the workflow reports `failure`.
|
||||
# Accept only `success`; `skipped` would mean the entire
|
||||
# discover job was skipped too (upstream failure), which should
|
||||
# still block.
|
||||
if [[ "$SCOPE_PARITY" != "success" ]]; then
|
||||
echo "::error::Scope-resolution parity gate failed (RFC #909 Ring 3)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
|
||||
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
|
||||
|
||||
@@ -42,13 +42,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
@@ -65,14 +65,10 @@ jobs:
|
||||
- 'gitnexus/src/core/parsing/**/parser.js'
|
||||
# Test fixtures are intentionally synthetic inputs (broken/unused
|
||||
# code, malformed samples) used to exercise the analyzer. CodeQL
|
||||
# findings here are noise, not real bugs. The second glob also
|
||||
# covers fixtures nested deeper in the test tree, e.g.
|
||||
# test/integration/cfg/fixtures/ (the CFG/PDG hazard inputs that
|
||||
# deliberately contain use-before-init / unused-variable shapes).
|
||||
# findings here are noise, not real bugs.
|
||||
- '**/test/fixtures/**'
|
||||
- '**/test/**/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
name: Commit fork prebuilds
|
||||
|
||||
# TRUSTED HALF of the vendored-grammar prebuild pipeline — FORK PRs only.
|
||||
#
|
||||
# `build-tree-sitter-prebuilds.yml` runs in the UNTRUSTED `pull_request`
|
||||
# context. On a fork PR it has a read-only token and no secrets, so it can
|
||||
# build + validate the native prebuilds and upload them as artifacts, but it
|
||||
# cannot commit them back. This workflow is the trusted consumer: triggered by
|
||||
# `workflow_run`, it runs from the DEFAULT BRANCH's copy of this file (the trust
|
||||
# anchor) with a writable token, downloads ONLY the artifacts (data — the
|
||||
# already-built-and-validated `.node` files + a small metadata.json), verifies
|
||||
# the metadata against the GitHub-controlled workflow_run authority, then pushes
|
||||
# the prebuilds onto the fork PR's head branch.
|
||||
#
|
||||
# It NEVER checks out or executes fork-controlled code: the producer already
|
||||
# `require()`-loaded + parsed each `.node` on its target platform in the
|
||||
# untrusted half (the correct place to run untrusted code). Here we only move
|
||||
# bytes and run git. The prebuilds touch ONLY gitnexus/vendor/<g>/prebuilds/**,
|
||||
# never .github/ — so the GITHUB_TOKEN's lack of `workflows` scope is irrelevant.
|
||||
#
|
||||
# Pushing to a fork branch with the GITHUB_TOKEN works only when the contributor
|
||||
# left "Allow edits by maintainers" enabled (the PR default) — the same
|
||||
# constraint as pr-autofix-apply.yml. When it's off we fall back to a comment.
|
||||
#
|
||||
# Same-repo PRs do NOT come here: they have secrets in the producer run, so the
|
||||
# `aggregate` job in build-tree-sitter-prebuilds.yml commits straight onto their
|
||||
# branch. This workflow's `if:` filters to forks.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Build tree-sitter prebuilds']
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
# Per-PR identity, NOT workflow_run.id (which is per-run unique and would
|
||||
# defeat serialization). Fork PRs have an empty pull_requests[] in the
|
||||
# workflow_run payload, so fall back to head-repo + head-branch.
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
deliver:
|
||||
name: deliver-fork-prebuilds
|
||||
# Only a SUCCESSFUL fork pull_request producer run. Same-repo PRs
|
||||
# (head_repository == base) are handled by the producer's aggregate job.
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
&& github.event.workflow_run.conclusion == 'success'
|
||||
&& github.event.workflow_run.head_repository.full_name != github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write # push the prebuilds commit to the fork PR head branch
|
||||
pull-requests: write # comment the delivery outcome
|
||||
actions: read # download artifacts produced by the producer run
|
||||
steps:
|
||||
# Pinned to v8.0.1 (same SHA used across this repo's workflows).
|
||||
- name: Download prebuild artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pattern: ts-prebuild-*
|
||||
path: prebuilds-in
|
||||
|
||||
- name: Download PR meta
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: pr-meta
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: meta-in
|
||||
|
||||
- name: Read and validate metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# No meta => this producer run had no fork-PR prebuilds to deliver
|
||||
# (nothing changed, or it wasn't a fork). Exit cleanly.
|
||||
if [ ! -f meta-in/metadata.json ]; then
|
||||
echo "No pr-meta artifact — nothing to deliver."
|
||||
echo "deliver=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# No prebuild artifacts => same (defensive; producer uploads both together).
|
||||
if ! ls prebuilds-in/ts-prebuild-* >/dev/null 2>&1; then
|
||||
echo "No ts-prebuild-* artifacts — nothing to deliver."
|
||||
echo "deliver=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
jq . meta-in/metadata.json
|
||||
|
||||
# The artifact comes from the untrusted producer running fork code.
|
||||
# Allowlist EVERY field before it flows into $GITHUB_OUTPUT — a newline
|
||||
# in head_ref would otherwise inject a second output line and redirect
|
||||
# this job's write-scoped push/comment onto a victim PR.
|
||||
assert_field() {
|
||||
local key="$1" pattern="$2" value
|
||||
value=$(jq -r ".${key} // empty" meta-in/metadata.json)
|
||||
if [ -z "$value" ] || ! [[ "$value" =~ $pattern ]]; then
|
||||
echo "::error::metadata.${key} failed allowlist (got: $(printf '%q' "$value"))"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
SCHEMA=$(assert_field schema '^gitnexus\.ts-prebuild/v[0-9]+$')
|
||||
PR_NUMBER=$(assert_field pr_number '^[0-9]+$')
|
||||
HEAD_SHA=$(assert_field head_sha '^[0-9a-f]{40}$')
|
||||
HEAD_REF=$(assert_field head_ref '^[A-Za-z0-9._/-]+$')
|
||||
HEAD_REPO=$(assert_field head_repo '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')
|
||||
BASE_REPO=$(assert_field base_repo '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')
|
||||
|
||||
# Defence-in-depth: refuse to act if the artifact claims another repo.
|
||||
if [ "$BASE_REPO" != "${GITHUB_REPOSITORY}" ]; then
|
||||
echo "::error::Artifact base_repo does not match \$GITHUB_REPOSITORY — refusing to deliver."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "deliver=true"
|
||||
echo "schema=${SCHEMA}"
|
||||
echo "pr_number=${PR_NUMBER}"
|
||||
echo "head_sha=${HEAD_SHA}"
|
||||
echo "head_ref=${HEAD_REF}"
|
||||
echo "head_repo=${HEAD_REPO}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cross-verify the artifact's claimed identity against the GitHub-controlled
|
||||
# workflow_run event. The allowlist above only proves the fields are
|
||||
# well-formed — not that they refer to the PR/SHA that actually triggered
|
||||
# us. A fork-controlled build could mutate metadata.json to reference
|
||||
# another PR/SHA and redirect our write-scoped push. Authority sources are
|
||||
# all server-controlled: workflow_run.head_sha, head_repository.full_name,
|
||||
# and pull_requests[].number (empty on forks -> commits/{sha}/pulls).
|
||||
- name: Verify metadata against workflow_run authority
|
||||
if: steps.meta.outputs.deliver == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
META_PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
|
||||
META_HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
|
||||
META_HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
|
||||
WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
WF_PR_NUMBERS: ${{ toJSON(github.event.workflow_run.pull_requests.*.number) }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1) head_sha must match exactly — the commit GitHub ran the producer against.
|
||||
if [ "${META_HEAD_SHA}" != "${WF_HEAD_SHA}" ]; then
|
||||
echo "::error::Artifact head_sha (${META_HEAD_SHA}) != workflow_run.head_sha (${WF_HEAD_SHA}) — refusing."
|
||||
exit 1
|
||||
fi
|
||||
# 2) head_repo must match exactly.
|
||||
if [ "${META_HEAD_REPO}" != "${WF_HEAD_REPO}" ]; then
|
||||
echo "::error::Artifact head_repo (${META_HEAD_REPO}) != workflow_run.head_repository (${WF_HEAD_REPO}) — refusing."
|
||||
exit 1
|
||||
fi
|
||||
# 3) pr_number must reference an open PR with this head SHA. Forks have
|
||||
# an empty pull_requests[] by design — fall back to commits/{sha}/pulls.
|
||||
allowed_numbers=$(jq -c '.' <<< "${WF_PR_NUMBERS}")
|
||||
if [ "${allowed_numbers}" = "[]" ]; then
|
||||
echo "workflow_run.pull_requests empty (fork) — using commits/{sha}/pulls."
|
||||
allowed_numbers=$(gh api "repos/${GH_REPO}/commits/${WF_HEAD_SHA}/pulls" \
|
||||
--jq '[.[] | select(.state == "open") | .number]' 2>/dev/null || echo "[]")
|
||||
if [ "${allowed_numbers}" = "[]" ]; then
|
||||
echo "::error::No open PR for head ${WF_HEAD_SHA} — refusing."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if ! jq -e --argjson n "${META_PR_NUMBER}" 'index($n) != null' <<< "${allowed_numbers}" >/dev/null; then
|
||||
echo "::error::Artifact pr_number (${META_PR_NUMBER}) not in authoritative list (${allowed_numbers}) — refusing."
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified identity: PR=${META_PR_NUMBER} head_sha=${META_HEAD_SHA} head_repo=${META_HEAD_REPO}."
|
||||
|
||||
# Pinned to v6.0.3 (same SHA used by build-tree-sitter-prebuilds.yml).
|
||||
# persist-credentials: false — push auth is provided inline at push time,
|
||||
# never written to .git/config on disk.
|
||||
- name: Checkout fork PR head
|
||||
if: steps.meta.outputs.deliver == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ steps.meta.outputs.head_repo }}
|
||||
ref: ${{ steps.meta.outputs.head_sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
path: pr-checkout
|
||||
|
||||
- name: Place prebuilds into the fork checkout
|
||||
if: steps.meta.outputs.deliver == 'true'
|
||||
env:
|
||||
DL: prebuilds-in
|
||||
CHECKOUT: pr-checkout
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module - <<'NODE'
|
||||
import fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
const dl = process.env.DL;
|
||||
const checkout = process.env.CHECKOUT;
|
||||
const PLATFORMS = ['linux-x64', 'linux-arm64', 'darwin-arm64', 'darwin-x64', 'win32-x64', 'win32-arm64'];
|
||||
// Reconstruct {grammar -> archs} from the downloaded artifact dir names
|
||||
// (ts-prebuild-<grammar>-<platform-arch>; grammar shortnames are dash-free).
|
||||
const byGrammar = {};
|
||||
for (const d of (fs.existsSync(dl) ? fs.readdirSync(dl) : [])) {
|
||||
const m = d.match(/^ts-prebuild-([a-z0-9]+)-(.+)$/);
|
||||
if (m) (byGrammar[m[1]] ||= []).push(m[2]);
|
||||
}
|
||||
const grammars = Object.keys(byGrammar);
|
||||
if (grammars.length === 0) throw new Error('no ts-prebuild-* artifacts present');
|
||||
const changed = [];
|
||||
for (const grammar of grammars) {
|
||||
const name = `tree-sitter-${grammar}`;
|
||||
const dest = `${checkout}/gitnexus/vendor/${name}/prebuilds`;
|
||||
// A grammar with 5/6 prebuilds silently breaks node-gyp-build on the
|
||||
// 6th platform — refuse a partial result.
|
||||
for (const pa of PLATFORMS) {
|
||||
const art = `${dl}/ts-prebuild-${grammar}-${pa}/${name}.node`;
|
||||
if (!fs.existsSync(art)) throw new Error(`missing ${grammar} prebuild for ${pa}`);
|
||||
fs.mkdirSync(`${dest}/${pa}`, { recursive: true });
|
||||
fs.copyFileSync(art, `${dest}/${pa}/${name}.node`);
|
||||
}
|
||||
execSync(`cd ${dest} && find . -name "*.node" | sort | xargs sha256sum > SHA256SUMS`);
|
||||
changed.push(name);
|
||||
}
|
||||
console.log('Placed prebuilds for:', changed.join(', '));
|
||||
NODE
|
||||
|
||||
- name: Commit and push to the fork branch
|
||||
id: push
|
||||
if: steps.meta.outputs.deliver == 'true'
|
||||
working-directory: pr-checkout
|
||||
env:
|
||||
HEAD_REF: ${{ steps.meta.outputs.head_ref }}
|
||||
HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
|
||||
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
|
||||
# Push auth only — supplied via env, never interpolated into the command.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git add gitnexus/vendor/tree-sitter-*/prebuilds
|
||||
if git diff --cached --quiet; then
|
||||
echo "Prebuilds byte-identical to the fork branch — nothing to commit."
|
||||
echo "result=nothing-to-commit" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Loop guard: if HEAD is already our prebuild bot commit, don't stack
|
||||
# another. (The producer's paths filter already excludes prebuilds/**,
|
||||
# so a prebuild-only push cannot retrigger it — this is defence in depth.)
|
||||
head_author=$(git log -1 --format='%ae' HEAD)
|
||||
head_subject=$(git log -1 --format='%s' HEAD)
|
||||
if [ "${head_author}" = "41898282+github-actions[bot]@users.noreply.github.com" ] \
|
||||
&& [[ "${head_subject}" =~ ^chore\(vendor\) ]]; then
|
||||
echo "::warning::HEAD is already a prebuild bot commit — refusing to re-apply."
|
||||
echo "result=loop-prevented" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
grammars=$(git diff --cached --name-only \
|
||||
| sed -n 's#gitnexus/vendor/\(tree-sitter-[a-z0-9]*\)/.*#\1#p' | sort -u | paste -sd, -)
|
||||
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git commit -q -m "chore(vendor): rebuild native prebuilds (${grammars})" \
|
||||
-m "Built + validated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
|
||||
# Push to the fork head with a lease against the resolved SHA, so a
|
||||
# contributor force-push during the build surfaces as lease-failed (not
|
||||
# push-failed, which would mislead them into the maintainer-edit fix).
|
||||
# Auth via per-invocation http.extraheader (never persisted, never in
|
||||
# the process args / git remote -v). Base64-encoded form is masked too.
|
||||
push_url="${GITHUB_SERVER_URL}/${HEAD_REPO}.git"
|
||||
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w0)"
|
||||
echo "::add-mask::${auth_header}"
|
||||
push_stderr=$(mktemp)
|
||||
if git -c http.extraheader="${auth_header}" \
|
||||
push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \
|
||||
"${push_url}" "HEAD:${HEAD_REF}" 2>"$push_stderr"; then
|
||||
echo "result=applied" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
cat "$push_stderr" >&2
|
||||
if grep -qE "stale info|force-with-lease|rejected.*non-fast-forward|remote rejected|! \[rejected\]" "$push_stderr"; then
|
||||
echo "::error::Push lease failed — fork branch moved during build."
|
||||
echo "result=lease-failed" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Push failed — likely a fork without 'Allow edits by maintainers'."
|
||||
echo "result=push-failed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Comment delivery outcome
|
||||
if: always() && steps.meta.outputs.deliver == 'true' && steps.push.outcome != 'skipped'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR: ${{ steps.meta.outputs.pr_number }}
|
||||
RESULT: ${{ steps.push.outputs.result }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
marker="<!-- gitnexus:ts-prebuild-fork -->"
|
||||
case "${RESULT}" in
|
||||
applied)
|
||||
body="${marker}
|
||||
✅ **Rebuilt native prebuilds pushed to this PR branch.** A grammar source change re-cut the vendored \`tree-sitter\` prebuilds for all 6 platforms and they're now committed on your branch. ([builder run](${RUN_URL}))" ;;
|
||||
nothing-to-commit)
|
||||
body="${marker}
|
||||
✅ Native prebuilds are already up to date on this branch — nothing to push." ;;
|
||||
loop-prevented)
|
||||
body="${marker}
|
||||
🔁 Skipping prebuild push: the branch HEAD is already an automated prebuild commit." ;;
|
||||
lease-failed)
|
||||
body="${marker}
|
||||
⏳ The PR head moved while the prebuilds were building, so they weren't pushed. Push another commit (or wait for the next build) and they'll be re-cut. ([builder run](${RUN_URL}))" ;;
|
||||
push-failed)
|
||||
body="${marker}
|
||||
⚠️ Rebuilt native prebuilds are ready but **couldn't be pushed to your fork branch**. Tick **Allow edits by maintainers** in the PR sidebar so CI can commit them — or download them from the [builder run](${RUN_URL}) artifacts (\`ts-prebuild-*\`) and commit them under \`gitnexus/vendor/<grammar>/prebuilds/\` yourself." ;;
|
||||
*)
|
||||
body="${marker}
|
||||
❓ Prebuild delivery finished in an unexpected state (\`${RESULT:-unknown}\`). See the [builder run](${RUN_URL})." ;;
|
||||
esac
|
||||
# Strip the YAML block indent so the rendered comment starts at column 0.
|
||||
body="$(printf '%s\n' "$body" | sed 's/^ //')"
|
||||
|
||||
# Upsert a single sticky comment keyed by the marker; only ever edit our
|
||||
# own bot comment (PATCH on someone else's 403s and would abort).
|
||||
existing=$(gh api "repos/${GH_REPO}/issues/${PR}/comments" --paginate \
|
||||
--jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${marker}\"))) | .id" \
|
||||
| head -n1 || true)
|
||||
if [ -n "${existing}" ]; then
|
||||
gh api -X PATCH "repos/${GH_REPO}/issues/comments/${existing}" -f body="${body}" >/dev/null
|
||||
echo "Updated comment ${existing}."
|
||||
else
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" -f body="${body}" >/dev/null
|
||||
echo "Created delivery comment."
|
||||
fi
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# When triggered by workflow_call the caller passes the RC tag as an input;
|
||||
# we check out that tag so the Dockerfile and package.json match the built image.
|
||||
# For tag-push events github.ref is already the tag ref — no override needed.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
@@ -138,17 +138,17 @@ jobs:
|
||||
|
||||
# Required for multi-platform (linux/arm64) emulation.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
# `akonlabs/gitnexus` and `akonlabs/gitnexus-web` repos.
|
||||
- name: Log in to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
# `github.event_name` would still be "push", not "workflow_call".
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
# Dual-registry publish. metadata-action expands the same tag set
|
||||
# against every image ref listed here, and build-push-action pushes
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Full history needed for the on-push full-history scan; on PRs the
|
||||
# action diffs against the base ref so the cost is bounded by the PR.
|
||||
@@ -38,22 +38,10 @@ jobs:
|
||||
# steps (and Gitleaks itself) don't need it for repo operations.
|
||||
persist-credentials: false
|
||||
|
||||
# gitleaks-action builds `base^..head` for pull_request events; both SHAs
|
||||
# must exist locally (fork PRs and shallow checkouts otherwise fail with
|
||||
# "unknown revision" — see gitleaks/gitleaks-action#199).
|
||||
- name: Fetch PR refs for gitleaks range
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$BASE_SHA"
|
||||
git fetch --no-tags origin "$HEAD_SHA"
|
||||
|
||||
# No GITLEAKS_LICENSE secret is required for OSS / public-repo usage.
|
||||
# If this repo becomes private, the action will require a license key.
|
||||
- name: Gitleaks
|
||||
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
|
||||
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
name: Vendored grammar update monitor
|
||||
|
||||
# Periodically checks each vendored tree-sitter grammar against its
|
||||
# source-of-origin and opens a PR re-vendoring any update that is ABI-COMPATIBLE
|
||||
# with the pinned tree-sitter@0.21.1 (LANGUAGE_VERSION 13–14, #1922). The version
|
||||
# bump then triggers build-tree-sitter-prebuilds.yml, which cross-builds + ABI-
|
||||
# validates the prebuilds — so a re-vendor that is subtly wrong can never silently
|
||||
# ship: its PR's CI goes red.
|
||||
#
|
||||
# ABI-INCOMPATIBLE updates (the common case — upstreams move to newer tree-sitter)
|
||||
# are reported as a notice + job summary, NOT applied, so the monitor never opens
|
||||
# doomed PRs. tree-sitter-c is MONITORED but report-only: it is ABI-pinned at
|
||||
# 0.21.4 (#1242/#858), so an available c update is surfaced (notice + summary) but
|
||||
# never auto-bumped — a maintainer re-vendors it deliberately after a runtime
|
||||
# upgrade.
|
||||
#
|
||||
# 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:
|
||||
schedule:
|
||||
- cron: '17 6 * * 1' # weekly, Monday 06:17 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
# Least privilege; the actual writes use a short-lived App token minted below.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Check upstreams + open update PRs
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# secrets aren't usable in a job/step `if:`, so compute presence here.
|
||||
- name: Check release App secret
|
||||
id: relapp
|
||||
env:
|
||||
HAS_APP: ${{ secrets.RELEASE_APP_ID != '' && secrets.RELEASE_APP_PRIVATE_KEY != '' }}
|
||||
run: echo "configured=$HAS_APP" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Mint GitHub App token
|
||||
id: app-token
|
||||
if: steps.relapp.outputs.configured == 'true'
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Detect updates, re-vendor ABI-compatible ones, open PRs
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
HAS_APP: ${{ steps.relapp.outputs.configured }}
|
||||
# App token writes; falls back to the read-only job token (PRs then skip).
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
script: |
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const SCRIPT = '.github/scripts/update-vendored-grammars.mjs';
|
||||
const run = (cmd, args, opts = {}) =>
|
||||
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
|
||||
|
||||
const report = JSON.parse(run('node', [SCRIPT]));
|
||||
const { owner, repo } = context.repo;
|
||||
const hasApp = process.env.HAS_APP === 'true';
|
||||
const applied = [], held = [], errors = [], skipped = [];
|
||||
|
||||
run('git', ['config', 'user.name', 'gitnexus-release-bot[bot]']);
|
||||
run('git', ['config', 'user.email', 'gitnexus-release-bot[bot]@users.noreply.github.com']);
|
||||
const baseSha = run('git', ['rev-parse', 'HEAD']).trim();
|
||||
|
||||
for (const r of report) {
|
||||
if (r.error) { errors.push(r); continue; }
|
||||
if (!r.update) continue;
|
||||
if (!r.applicable) { held.push(r); continue; } // ABI-incompatible / unknown
|
||||
|
||||
const name = `tree-sitter-${r.grammar}`;
|
||||
const branch = `chore/update-${name}-${r.upstream}`.replace(/[^a-z0-9._/-]+/gi, '-');
|
||||
|
||||
// Idempotency: don't reopen an existing PR for this exact version.
|
||||
const existing = await github.rest.pulls.list({ owner, repo, head: `${owner}:${branch}`, state: 'all' });
|
||||
if (existing.data.length > 0) { skipped.push({ ...r, reason: 'PR exists' }); continue; }
|
||||
|
||||
// Re-vendor in place (refuses + exits non-zero if ABI turns out wrong).
|
||||
try {
|
||||
run('node', [SCRIPT, '--apply', r.grammar]);
|
||||
} catch (e) {
|
||||
errors.push({ ...r, error: `apply failed: ${String(e.message || e).slice(0, 200)}` });
|
||||
run('git', ['checkout', '--', 'gitnexus/vendor']);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasApp) {
|
||||
skipped.push({ ...r, reason: 'no RELEASE_APP secret — PR not opened' });
|
||||
run('git', ['checkout', '--', 'gitnexus/vendor']);
|
||||
continue;
|
||||
}
|
||||
|
||||
const remote = `https://x-access-token:${process.env.GH_TOKEN}@github.com/${owner}/${repo}.git`;
|
||||
run('git', ['checkout', '-B', branch, baseSha]);
|
||||
run('git', ['add', `gitnexus/vendor/${name}`]);
|
||||
run('git', ['commit', '-m', `chore(vendor): update ${name} to ${r.upstream}`]);
|
||||
run('git', ['push', '--force-with-lease', remote, `HEAD:${branch}`]);
|
||||
const body = [
|
||||
`Automated re-vendor of **${name}** to \`${r.upstream}\` (from ${r.kind === 'npm' ? `npm \`${name}\`` : `\`${r.ref}\``}).`,
|
||||
'',
|
||||
`Verified ABI **${r.abi}** — compatible with the pinned \`tree-sitter@0.21.1\` (13–14).`,
|
||||
'Source-build inputs refreshed; the GitNexus binding.gyp / README / prebuilds are preserved.',
|
||||
'The version bump triggers `build-tree-sitter-prebuilds.yml` to rebuild + ABI-validate the',
|
||||
'prebuilds — review its result before merging.',
|
||||
].join('\n');
|
||||
const pr = await github.rest.pulls.create({
|
||||
owner, repo, head: branch, base: 'main',
|
||||
title: `chore(vendor): update ${name} to ${r.upstream}`, body,
|
||||
});
|
||||
applied.push({ ...r, pr: pr.data.number });
|
||||
run('git', ['checkout', '--force', baseSha]);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const s = core.summary.addHeading('Vendored grammar update monitor');
|
||||
if (applied.length) s.addRaw(`\n**Opened PRs:** ${applied.map((a) => `${a.grammar}→${a.upstream} (#${a.pr})`).join(', ')}\n`);
|
||||
if (held.length) s.addRaw(`\n**Held (not auto-applied):** ${held.map((h) => `${h.grammar} ${h.upstream} (${h.hold ? 'report-only: ' + h.hold : 'ABI ' + (h.abi ?? '?') + ' — needs the tree-sitter runtime upgrade'})`).join(', ')}\n`);
|
||||
if (skipped.length) s.addRaw(`\n**Skipped:** ${skipped.map((x) => `${x.grammar} (${x.reason})`).join(', ')}\n`);
|
||||
if (errors.length) s.addRaw(`\n**Errors:** ${errors.map((e) => `${e.grammar}: ${e.error}`).join('; ')}\n`);
|
||||
if (!applied.length && !held.length && !skipped.length && !errors.length) s.addRaw('\nAll vendored grammars are up to date. ✅\n');
|
||||
await s.write();
|
||||
|
||||
for (const h of held) core.notice(`${h.grammar}: update to ${h.upstream} available — ${h.hold ? `report-only (${h.hold})` : `ABI ${h.abi ?? 'unknown'} (need 13/14), held until the tree-sitter runtime upgrade`}.`);
|
||||
if (!hasApp && (applied.length || skipped.some((x) => /secret/.test(x.reason)))) {
|
||||
core.notice('RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY not configured — update PRs were not opened. Provision the App to enable auto-PRs.');
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
name: Impact PDG Mutation Report
|
||||
|
||||
# Off-the-fast-path mutation oracle for the PDG-backed `impact` mode.
|
||||
#
|
||||
# The `--mutation` oracle (bench/impact-pdg/measure.mjs) is a ~280s dynamic
|
||||
# value-diff check: it mutates each fixture, re-analyzes with `--pdg`, and scores
|
||||
# the realized recall of the statement slice against the behavioral diff. It is
|
||||
# far too slow for the PR critical path, so it runs on a nightly schedule (and on
|
||||
# demand via workflow_dispatch) and uploads the JSON report as an artifact rather
|
||||
# than gating merges.
|
||||
#
|
||||
# The harness shells out to `gitnexus analyze --pdg`, which spawns workers from
|
||||
# dist/, so dist must be built first — setup-gitnexus with build: 'true' does
|
||||
# that (mirrors ci-tests.yml).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
mutation-report:
|
||||
name: impact-pdg mutation oracle
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
# persist-credentials: false — this job runs the bench + uploads an
|
||||
# artifact and never pushes; the default-persisted token in .git/config
|
||||
# must not be capturable through that upload (zizmor credential-persistence
|
||||
# / artipacked audit). Mirrors ci-tests.yml.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# setup-gitnexus is the repo's composite install action (Node 22 + npm ci);
|
||||
# build: 'true' runs `node scripts/build.js` so dist/ exists for the
|
||||
# analyze workers the mutation harness spawns.
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Run PDG impact mutation oracle (~280s)
|
||||
run: node --import tsx bench/impact-pdg/measure.mjs --mutation --json > mutation-report.json
|
||||
working-directory: gitnexus
|
||||
|
||||
# Regression gate: write a recall summary to the run AND fail if the
|
||||
# minimum realized recall drops below the (tunable) floor, so a recall
|
||||
# regression surfaces instead of sitting unread in the artifact. Runs
|
||||
# before the (always) upload so the artifact is preserved even on a fail.
|
||||
- name: Gate on mutation recall regression
|
||||
run: node bench/impact-pdg/gate-mutation-recall.mjs mutation-report.json
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
MUTATION_RECALL_FLOOR: '0.5'
|
||||
|
||||
- name: Upload mutation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: impact-pdg-mutation-report
|
||||
path: gitnexus/mutation-report.json
|
||||
retention-days: 14
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
# Push auth is provided inline at push time via the URL.
|
||||
- name: Checkout PR head
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v5.0.4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4
|
||||
with:
|
||||
repository: ${{ steps.locate.outputs.head_repo }}
|
||||
ref: ${{ steps.locate.outputs.head_sha }}
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# PR head commit (not the synthetic merge ref) — we need the
|
||||
# exact tree the contributor pushed so suggestions line up.
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
# Pinned to v7.2.0. Verify SHA via:
|
||||
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
|
||||
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
|
||||
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
|
||||
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
should_run: ${{ steps.decide.outputs.should_run }}
|
||||
head_sha: ${{ steps.decide.outputs.head_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
# ── Phase 3: reusable CI gate ──────────────────────────────────────────────
|
||||
# Runs for both rc (when guard says go) and stable. No `secrets:` passed —
|
||||
# ci.yml and its entire reusable-workflow chain (ci-quality, ci-tests,
|
||||
# ci-e2e, ci-report) reference zero `secrets.*` values;
|
||||
# ci-e2e, ci-scope-parity, ci-report) reference zero `secrets.*` values;
|
||||
# passing any would be unused surface. GITHUB_TOKEN is implicit.
|
||||
ci:
|
||||
needs: [route, rc-guard]
|
||||
@@ -332,7 +332,7 @@ jobs:
|
||||
# on the RC path.
|
||||
- name: Checkout (RC)
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -349,7 +349,7 @@ jobs:
|
||||
|
||||
- name: Checkout (stable)
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
|
||||
# path performs no git pushes; the default scope is sufficient.
|
||||
with:
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -53,6 +53,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
name: Tree-sitter Upgrade Readiness
|
||||
|
||||
# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks:
|
||||
# 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).
|
||||
# 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?
|
||||
# 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:
|
||||
@@ -27,8 +18,6 @@ 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:
|
||||
@@ -39,38 +28,21 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
report:
|
||||
readiness:
|
||||
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
|
||||
outputs:
|
||||
report: ${{ steps.readiness.outputs.report }}
|
||||
exit_code: ${{ steps.readiness.outputs.exit_code }}
|
||||
# Needed to open/update the tracking issue on scheduled runs.
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
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
|
||||
@@ -82,15 +54,10 @@ 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<<${DELIM}"
|
||||
echo 'report<<DRIFT_EOF'
|
||||
cat drift-report.md
|
||||
echo "${DELIM}"
|
||||
echo 'DRIFT_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "=== Report ==="
|
||||
cat drift-report.md
|
||||
@@ -102,22 +69,13 @@ jobs:
|
||||
run: |
|
||||
echo "::warning::Tree-sitter 0.25 upgrade has blockers. See job output for the full readiness report."
|
||||
|
||||
# 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'
|
||||
- name: Upsert tracking issue on scheduled runs
|
||||
if: >
|
||||
github.event_name == 'schedule' &&
|
||||
steps.readiness.outputs.exit_code != '0'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
REPORT: ${{ needs.report.outputs.report }}
|
||||
REPORT: ${{ steps.readiness.outputs.report }}
|
||||
with:
|
||||
script: |
|
||||
const title = 'Tree-sitter 0.25 upgrade readiness';
|
||||
@@ -135,32 +93,11 @@ jobs:
|
||||
const existing = open.find(i => i.title === title);
|
||||
if (existing) {
|
||||
// Extract ready/total count for the changelog comment.
|
||||
// The two report.match() regexes below are mirrored as
|
||||
// _ISSUE_READY_RE / _ISSUE_BLOCKER_RE in
|
||||
// .github/scripts/test_check_tree_sitter_upgrade_readiness.py, which is
|
||||
// the ONLY place the contract is asserted against the rendered report.
|
||||
// Keep all three in sync: changing the report prose means updating both
|
||||
// these literals AND the test mirror, or requireMatch throws on the next
|
||||
// scheduled run (the silent "?" fallback that used to hide drift is gone).
|
||||
const requireMatch = (name, match) => {
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Could not extract ${name} from tree-sitter readiness report`,
|
||||
);
|
||||
}
|
||||
return match;
|
||||
};
|
||||
const readyMatch = requireMatch(
|
||||
'ready npm grammar count',
|
||||
report.match(/- (\d+)\/(\d+) npm-installed grammars already accept tree-sitter@/),
|
||||
);
|
||||
const blockerMatch = requireMatch(
|
||||
'blocker count',
|
||||
report.match(/\*\*Blocked\*\* — (\d+) grammars? /),
|
||||
);
|
||||
const ready = readyMatch[1];
|
||||
const total = readyMatch[2];
|
||||
const blockers = blockerMatch[1];
|
||||
const readyMatch = report.match(/\*\*(\d+)\/(\d+)\*\* grammars ready/);
|
||||
const blockerMatch = report.match(/\*\*(\d+) blocker/);
|
||||
const ready = readyMatch ? readyMatch[1] : '?';
|
||||
const total = readyMatch ? readyMatch[2] : '?';
|
||||
const blockers = blockerMatch ? blockerMatch[1] : '?';
|
||||
|
||||
// Find grammars whose status changed by diffing the old and
|
||||
// new table rows. Each row looks like:
|
||||
@@ -168,11 +105,7 @@ jobs:
|
||||
// | `tree-sitter-foo` | ... | Blocking |
|
||||
const parseRows = (md) => {
|
||||
const map = {};
|
||||
// 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)) {
|
||||
for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*?\| (\S+(?:\s\S+)*?) \|$/gm)) {
|
||||
map[m[1]] = m[2].trim();
|
||||
}
|
||||
return map;
|
||||
@@ -188,7 +121,7 @@ jobs:
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let comment = `**${today}:** ${ready}/${total} npm-installed ready. ${blockers} blocker(s) remaining.`;
|
||||
let comment = `**${today}:** ${ready}/${total} ready. ${blockers} blocker(s) remaining.`;
|
||||
if (changes.length > 0) {
|
||||
comment += '\n\nChanges:\n' + changes.map(c => `- ${c}`).join('\n');
|
||||
} else {
|
||||
@@ -219,8 +152,10 @@ jobs:
|
||||
core.info(`Opened issue #${created.number}`);
|
||||
}
|
||||
|
||||
- name: Close tracking issue on clean runs
|
||||
if: needs.report.outputs.exit_code == '0'
|
||||
- name: Close tracking issue on clean scheduled runs
|
||||
if: >
|
||||
github.event_name == 'schedule' &&
|
||||
steps.readiness.outputs.exit_code == '0'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts/triage
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
@@ -45,15 +45,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build image (load locally for scan)
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.image.dockerfile }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
||||
@@ -23,18 +23,6 @@ rules:
|
||||
# comment in the file documents the split.
|
||||
- pr-autofix-publish.yml
|
||||
|
||||
# workflow_run is the trusted half of the vendored-grammar prebuild
|
||||
# pipeline (commit-fork-prebuilds.yml). The untrusted producer
|
||||
# (build-tree-sitter-prebuilds.yml on a fork pull_request) builds +
|
||||
# validates the .node prebuilds and uploads them as artifacts. This
|
||||
# consumer downloads ONLY those artifacts + metadata.json,
|
||||
# allowlist-validates every metadata field, cross-checks identity against
|
||||
# the workflow_run authority (head_sha / head_repo / pr_number), and
|
||||
# checks out the fork head pinned to that HEAD SHA solely to ADD prebuild
|
||||
# files (never executes fork code) before pushing. Header comment in the
|
||||
# file documents the split.
|
||||
- commit-fork-prebuilds.yml
|
||||
|
||||
# pull_request_target needed by claude-code-action to access secrets
|
||||
# and post review comments on fork PRs. Mitigated by: PR checkouts pin
|
||||
# the fork's HEAD SHA (not the branch ref) to prevent TOCTOU races,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
title = "GitNexus"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
# Fake credentials in unit tests — none are real secrets:
|
||||
# - embedding API keys in the http-embedder tests (regexes below)
|
||||
# - synthetic GitHub PAT fixtures in the git-clone PAT-injection tests
|
||||
# (e.g. ghp_secret123, ghp_uniqueRawSecret_98765) — allowlisted by path
|
||||
# so the exception is bounded to that one test file.
|
||||
[allowlist]
|
||||
description = "fake credentials in unit tests (no real secrets)"
|
||||
regexes = [
|
||||
'''secret-key-12345''',
|
||||
'''test-api-key-redaction-check''',
|
||||
]
|
||||
paths = [
|
||||
'''gitnexus/test/unit/git-clone\.test\.ts''',
|
||||
]
|
||||
@@ -39,7 +39,8 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
||||
## Reference docs
|
||||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
|
||||
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
|
||||
- **Call-resolution DAG (legacy path):** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`.
|
||||
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (see `registry-primary-flag.ts`). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR.
|
||||
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
|
||||
- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below.
|
||||
|
||||
@@ -80,18 +81,18 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `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 run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- 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"})`.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Resources
|
||||
|
||||
@@ -173,6 +174,6 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind
|
||||
|
||||
### Gotchas
|
||||
|
||||
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (materializes the vendored grammars into `node_modules/`, then prefers a committed prebuild per platform-arch and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback.
|
||||
- The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin}` are handled uniformly: c is required; dart/proto/swift/kotlin are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable.
|
||||
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift, builds tree-sitter-proto). Native bindings need `python3`, `make`, `g++`.
|
||||
- `tree-sitter-kotlin` and `tree-sitter-swift` are optional — install warnings expected.
|
||||
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.
|
||||
|
||||
+115
-53
@@ -15,7 +15,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
|
||||
## End-to-end flow: index → graph → tools
|
||||
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 14 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 12 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
|
||||
2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
|
||||
|
||||
@@ -38,18 +38,13 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
| `detect_changes` | Map git diffs to affected symbols and processes |
|
||||
| `rename` | Graph-assisted multi-file rename with `dry_run` preview |
|
||||
| `api_impact` | Pre-change impact report for an API route handler |
|
||||
| `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@<group>"`) for cross-repo traces |
|
||||
| `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 |
|
||||
|
||||
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). `trace` is also group-aware via `repo: "@<groupName>"` — but, unlike the others, it resolves `from`/`to` across **all** members (a `@<groupName>/<memberPath>` suffix is advisory for trace, not a scope); pass `from_uid`/`to_uid` to disambiguate a symbol name that occurs in more than one member.
|
||||
|
||||
Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path that crosses repositories: it resolves `from`/`to` across all members, and when they live in different repos it joins the home-repo segment to the target-repo segment over a single `ContractLink` boundary (an HTTP consumer→provider link, joined on `Contract.symbolUid`), reported as a `CONTRACT_LINK` hop in `crossings[]`. The crossing is clamped to one boundary (`MAX_SUPPORTED_CROSS_DEPTH`, shared with cross-impact); deeper `crossDepth` is reported via `notes[]`. With `pdg: true` (experimental, opt-in), each boundary-adjacent segment is enriched with its intra-procedural REACHING_DEF data-flow when that repo was indexed with `--pdg` (reusing the same anchored `flows` query as `pdg_query`); data flow never crosses the repo boundary, and a missing PDG layer degrades to call-level hops with a note. Two stores meet only at the `symbolUid` grain — the per-repo PDG/call graph and the group bridge — so this is the documented join; full cross-program (SDG-like) data flow across the boundary remains deferred (see `docs/plans/2026-06-18-002-feat-unified-pdg-impact-evaluation-plan.md`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
|
||||
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
|
||||
|
||||
| Resource URI | Purpose |
|
||||
|--------------|---------|
|
||||
@@ -70,7 +65,7 @@ Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path th
|
||||
| Wiki generation | `src/core/wiki/` |
|
||||
| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` |
|
||||
| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` |
|
||||
| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) |
|
||||
| Call resolution/MRO | `src/core/ingestion/call-processor.ts` + `model/resolve.ts` |
|
||||
| Type extraction | `src/core/ingestion/type-extractors/` |
|
||||
| Worker pool | `src/core/ingestion/workers/` |
|
||||
| Web UI | `gitnexus-web/src/` |
|
||||
@@ -82,11 +77,11 @@ Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path th
|
||||
|
||||
## Pipeline Phase DAG
|
||||
|
||||
14 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
12 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
|
||||
```
|
||||
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
→ crossFile → scopeResolution → pruneLocalSymbols → mro → communities → processes
|
||||
→ crossFile → mro → communities → processes
|
||||
```
|
||||
|
||||
| Phase | File | Deps | Output |
|
||||
@@ -100,13 +95,11 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
|
||||
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
|
||||
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
|
||||
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
|
||||
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
| `mro` | `mro.ts` | `crossFile`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `communities` | `communities.ts` | `mro`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
|
||||
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `types.ts`, `runner.ts`, `index.ts`.
|
||||
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `orm-extraction.ts` (sequential ORM fallback), `types.ts`, `runner.ts`, `index.ts`.
|
||||
|
||||
### DAG runner
|
||||
|
||||
@@ -126,8 +119,7 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
|
||||
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
|
||||
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
|
||||
- **Local-symbol pruning** — `pruneLocalSymbols` removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with `PipelineOptions.keepLocalValueSymbols` or globally with the `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var.
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests). `skipWorkers` forces sequential parsing.
|
||||
|
||||
### How to add a new phase
|
||||
|
||||
@@ -155,18 +147,105 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
||||
|
||||
---
|
||||
|
||||
## Semantic model
|
||||
## Call-Resolution DAG
|
||||
|
||||
`SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). The scope-resolution pipeline reads from here: `findOwnedMember`, `pickOverload`, and `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`.
|
||||
Typed 6-stage pipeline in `call-processor.ts` (inside the `parse` phase) that resolves method/function calls and emits CALLS edges. Language behavior plugs in at two `LanguageProvider` hook points (stages 3–4); shared code names no languages. Scope: call resolution only — import resolution, type extraction, heritage, and symbol-table population live in other phases.
|
||||
|
||||
`ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact the scope-resolution pipeline consumes. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
|
||||
### Stages
|
||||
|
||||
```
|
||||
extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge
|
||||
(1) (2) (3) [hook] (4) [hook] (5) (6)
|
||||
```
|
||||
|
||||
| Stage | Produces | Location |
|
||||
|-------|----------|----------|
|
||||
| **extract-call** | `ExtractedCallSite` (name, form, receiver, argCount) | `call-extractors/` (per-language); runs in worker |
|
||||
| **classify-form** | callForm (`free`/`member`/`constructor`) + arity | `call-analysis.ts` → `inferCallForm`; shared, runs in worker |
|
||||
| **infer-receiver** | `ReceiverEnriched` (receiver type finalized) | `call-processor.ts`; shared default chain, then `inferImplicitReceiver` hook |
|
||||
| **select-dispatch** | `DispatchDecision` (primary, fallback, ancestryView) | `selectDispatch` hook, falls back to shared default |
|
||||
| **resolve-target** | `TieredCandidates` | `model/resolve.ts` → `lookupMethodByOwnerWithMRO` (MRO walk) |
|
||||
| **emit-edge** | CALLS edge in graph | `call-processor.ts`; writes edge with confidence tier |
|
||||
|
||||
### Provider hooks
|
||||
|
||||
Both hooks are optional on `LanguageProvider`. Ruby is the only current implementer.
|
||||
|
||||
**`inferImplicitReceiver`** — called after shared infer-receiver defaults. Returns `ImplicitReceiverOverride | null`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `callNode` (AST), `filePath` |
|
||||
| Non-null fields | `callForm`, `receiverName`, `receiverTypeName` (required); `receiverSource: 'implicit-self'` (fixed); `hint?` (opaque, passed to `selectDispatch`) |
|
||||
| Null | Keep existing `ReceiverEnriched` state |
|
||||
|
||||
**`selectDispatch`** — called after infer-receiver (including hook). Returns `DispatchDecision | null`; null uses shared default (constructor → `primary:'constructor'`; typed receiver → `primary:'owner-scoped'`; else → `primary:'free'`).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `receiverSource`, `hint` |
|
||||
| Non-null fields | `primary: 'owner-scoped' \| 'free' \| 'constructor'`; `fallback?: 'free-arity-narrowed'`; `ancestryView?: 'instance' \| 'singleton'`; `hint?` |
|
||||
|
||||
**`DispatchDecision` field semantics:**
|
||||
- `primary: 'owner-scoped'` — MRO walk from receiver's type; used when receiver type is known.
|
||||
- `fallback: 'free-arity-narrowed'` — after owner-scoped miss, search free-call candidates by arity only (Ruby uses this for implicit-self calls that miss their owner's MRO).
|
||||
- `ancestryView: 'singleton'` — walk singleton/class ancestry instead of instance ancestry (Ruby `def self.foo` bodies, so `extend`-ed methods are found).
|
||||
|
||||
### Adding language behavior
|
||||
|
||||
1. **Implicit receivers** — implement `inferImplicitReceiver`: return null if call already has a receiver; otherwise use `findEnclosingClassInfo` (`ast-helpers.ts`) to find the enclosing context, return `ImplicitReceiverOverride` with `receiverSource: 'implicit-self'`, and optionally set `hint` for `selectDispatch`.
|
||||
2. **Custom dispatch** — implement `selectDispatch`: inspect `receiverSource` and `hint`, return `DispatchDecision` with `primary`, optional `fallback`, optional `ancestryView`; return null to keep shared defaults.
|
||||
3. **MRO strategy** — confirm `mroStrategy` is `'first-wins'`, `'c3'`, `'ruby-mixin'`, or `'none'`; consumed by `lookupMethodByOwnerWithMRO`.
|
||||
|
||||
**Ruby example** (`languages/ruby.ts` + `utils/ruby-self-call.ts`): `inferImplicitReceiver` rewrites bare-identifier calls to `self.method` and sets `hint` to `'instance'`/`'singleton'`; `selectDispatch` uses hint for `ancestryView` and adds `fallback: 'free-arity-narrowed'` for implicit-self calls.
|
||||
|
||||
### Code references
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `core/ingestion/call-types.ts` | DAG types: `ReceiverEnriched`, `DispatchDecision`, `ImplicitReceiverOverride` |
|
||||
| `core/ingestion/language-provider.ts` | Hook signatures: `inferImplicitReceiver`, `selectDispatch` |
|
||||
| `core/ingestion/call-processor.ts` | `processCalls`: stages 3–6 |
|
||||
| `core/ingestion/model/resolve.ts` | `lookupMethodByOwnerWithMRO`: stage 5 MRO walk |
|
||||
| `core/ingestion/languages/ruby.ts` | Both hooks + `mroStrategy: 'ruby-mixin'` |
|
||||
| `core/ingestion/utils/ruby-self-call.ts` | Bare-call rewrite for `inferImplicitReceiver` |
|
||||
|
||||
### Coexistence with the scope-resolution pipeline
|
||||
|
||||
The Call-Resolution DAG is the **legacy path**. RFC #909 Ring 3 introduces a parallel **scope-resolution pipeline** (next section) that replaces stages 1–6 with a scope-indexed registry lookup. Both paths ship side-by-side and are gated per-language via `MIGRATED_LANGUAGES` + the `REGISTRY_PRIMARY_<LANG>` env var.
|
||||
|
||||
- **Unmigrated language** → Call-Resolution DAG runs; scope-resolution phase is a no-op.
|
||||
- **Migrated language** (currently: Python, C#) → scope-resolution owns CALLS/ACCESSES/USES emission; the legacy DAG gates off for that language via `isRegistryPrimary(lang)` checks in `call-processor.ts` and `import-processor.ts`.
|
||||
- `import-processor` still populates `importMap` for migrated languages — heritage's `ctx.resolve` reads it to disambiguate parent classes. Only edge emission is gated.
|
||||
- CI runs BOTH paths for every migrated language on every PR (`.github/workflows/ci-scope-parity.yml`); both must pass.
|
||||
|
||||
#### Same-graph guarantee
|
||||
|
||||
Edges emitted by the scope-resolution pipeline and edges emitted by the legacy DAG are indistinguishable to downstream consumers (MCP tools, HTTP API, embeddings, group bridge):
|
||||
|
||||
- **Node identity** — both paths use `generateId(...)` from `lib/utils.ts`, the same qualified-name keyspace, and the same node labels (`File`, `Folder`, `Class`, `Method`, `Function`, …). Overload disambiguation suffixes `parameterTypes` into the id consistently — see `scope-resolution/graph-bridge/ids.ts` and the legacy emitter in `call-processor.ts`.
|
||||
- **Edge vocabulary** — both paths emit the same reasons: `'import-resolved' | 'global' | 'local-call' | 'same-file' | 'interface-dispatch' | 'read' | 'write'`. Migrating a language must not change which reasons consumers see for previously-resolved edges.
|
||||
- **Confidence tier** — both paths attach a numeric `confidence` to each edge using the same scale.
|
||||
|
||||
The CI parity workflow (`.github/workflows/ci-scope-parity.yml`) runs both paths against every migrated language's fixture corpus and fails on any divergence.
|
||||
|
||||
#### Semantic-model source of truth
|
||||
|
||||
Two independent invariants.
|
||||
|
||||
**ParsedFile = the AST-level truth.** `ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact both resolution paths consume. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
|
||||
|
||||
**SemanticModel = the symbol-level truth.** `SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). Both paths read from here:
|
||||
|
||||
- Legacy Call-Resolution DAG → `call-processor` Tier 1/2/3 via `model.symbols.lookupExactAll`, `model.methods.lookupMethodByName`, `model.types.lookupClassByName`, `lookupMethodByOwnerWithMRO`.
|
||||
- Scope-resolution pipeline → `findOwnedMember`, `pickOverload`, `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`.
|
||||
|
||||
The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` for `Scope`-valued lookups (`classScopeByDefId`, `moduleScopeByFile`) that `SemanticModel` structurally cannot hold. No symbol-indexed duplicates exist outside `SemanticModel`.
|
||||
|
||||
**Write / read phase contract.** The model is mutable during three ordered phases and read-only afterward:
|
||||
|
||||
```
|
||||
Phase 1: parse ──► symbolTable.add fans into types/methods/fields
|
||||
Phase 1: legacy parse ──► symbolTable.add fans into types/methods/fields
|
||||
Phase 2: scope-resolution ──► reconcileOwnership() registers corrected ownerIds
|
||||
Phase 3: finalize ──► model.attachScopeIndexes(bundle) — one-shot freeze
|
||||
─────────────────────────── phase boundary ───────────────────────────
|
||||
@@ -176,7 +255,7 @@ The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` fo
|
||||
|
||||
`runScopeResolution` narrows `MutableSemanticModel` → `SemanticModel` at the phase boundary so downstream passes physically cannot mutate the model even accidentally.
|
||||
|
||||
**Reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose parse-time extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose extractor already carries `ownerId` (C#).
|
||||
**Transitional: reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose legacy extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose legacy extractor already carries `ownerId` (C#).
|
||||
|
||||
The architectural end state is for every language's parse-time extractor to emit the correct `ownerId` directly, making reconciliation a no-op (tracked as a follow-up refactor). The dev-mode validator `validateOwnershipParity` surfaces any drift via `onWarn` under `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`.
|
||||
|
||||
@@ -186,7 +265,7 @@ References: `semantic-model.ts` file-head (full write/read contract); `contract/
|
||||
|
||||
## Scope-Resolution Pipeline (RFC #909 Ring 3)
|
||||
|
||||
Language-agnostic scope-resolution resolver. This is the resolution path for every language — it owns CALLS/ACCESSES/USES emission and inheritance edges. Adding a language is one interface implementation (`ScopeResolver`) plus one registration in the `SCOPE_RESOLVERS` map — no changes to shared code, no new pipeline phase. (RING4-1 #942 removed the legacy call-resolution DAG and the per-language `MIGRATED_LANGUAGES` flag, so `SCOPE_RESOLVERS` registration is all that's needed.)
|
||||
Language-agnostic registry-primary resolver. Replaces the Call-Resolution DAG for migrated languages. Adding a language is one interface implementation (`ScopeResolver`) plus two registrations — no changes to shared code, no new pipeline phase.
|
||||
|
||||
### Pipeline stages
|
||||
|
||||
@@ -207,20 +286,7 @@ 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–#2086)
|
||||
|
||||
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.
|
||||
- **Cross-repo trace enrichment**: group-mode `trace` (`pdg: true`) reuses the same anchored REACHING_DEF `flows` query to annotate a boundary-adjacent segment with how a value reaches the cross-repo call — strictly intra-procedural (data flow never crosses the repo boundary). See the group-aware tools note above.
|
||||
|
||||
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`).
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates `SCOPE_RESOLVERS ∩ MIGRATED_LANGUAGES`, reads per-file Trees from the parse phase's `scopeTreeCache`, disposes the cache at the end.
|
||||
|
||||
### `ScopeResolver` contract
|
||||
|
||||
@@ -246,6 +312,7 @@ Single interface a language implements to plug into the pipeline. Contract fully
|
||||
|
||||
1. Implement `ScopeResolver` in `languages/<lang>/scope-resolver.ts`.
|
||||
2. Add entry to `SCOPE_RESOLVERS` in `scope-resolution/pipeline/registry.ts`.
|
||||
3. Add the language to `MIGRATED_LANGUAGES` in `registry-primary-flag.ts` when the shadow-harness corpus parity ≥ 99% fixtures / ≥ 98% corpus.
|
||||
|
||||
CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
|
||||
@@ -261,6 +328,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges |
|
||||
| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets |
|
||||
| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index |
|
||||
| `registry-primary-flag.ts` | `MIGRATED_LANGUAGES` set + `isRegistryPrimary(lang)` |
|
||||
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
|
||||
@@ -269,7 +337,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
|
||||
### Performance notes
|
||||
|
||||
- **Cross-phase Tree cache**: the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) lets a scope-resolution per-language hook (`emitScopeCaptures`) reuse a tree instead of re-parsing. Workers leave it empty — Trees can't cross MessageChannels — so in normal (worker-pool) runs scope-resolution does NOT rely on it: workers serialize each file's `ParsedFile` (+ capture side-channel) and stream them in, so scope-resolution consumes the pre-extracted artifact rather than re-parsing on the main thread (§ Chunked parse-and-resolve). `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
|
||||
- **Cross-phase Tree cache**: parse phase writes Trees into `scopeTreeCache` (separate from the chunk-local `astCache`) ONLY for languages with `emitScopeCaptures`. Scope-resolution reads from it to skip the second parse. Cleared at end of the phase. Workers leave the cache empty — Trees can't cross MessageChannels; cache miss = fresh parse. `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
|
||||
- **Typed relationship iteration**: heritage + MRO walk only the EXTENDS / IMPLEMENTS / HAS_METHOD edges via `iterRelationshipsByType`, not the full relationship map.
|
||||
- **Workspace-resolution-index**: O(1) `findOwnedMember` / `findExportedDef` / `classScopeByDefId` built once per run.
|
||||
- **SCC-ordered cross-file return-type propagation** (PR #1050): `propagateImportedReturnTypes` walks `indexes.sccs` in reverse-topological order (leaves first), so multi-hop alias chains like `models.User → service.user → app.user` collapse to the terminal class in a single linear pass. Within each importer, the source module's `typeBindings` is chain-followed BEFORE mirroring (so we mirror terminal types, not intermediate refs), and the importer's own `typeBindings` is chain-followed AFTER mirroring (so local `const x = importedFn()` resolves before downstream importers run). Cyclic SCCs reach a partial fixpoint within a single pass without iterating to convergence — see the `ts-circular` cross-file-binding fixture which only asserts pipeline-no-throw. PROF output (`PROF_SCOPE_RESOLUTION=1`) splits `finalize` from `propagate` so quadratic regressions in the chain-follow surface independently.
|
||||
@@ -283,7 +351,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
```
|
||||
Unified Graph Schema (44 node types, 21 relationship types)
|
||||
↑
|
||||
Scope-Resolution Pipeline (registry lookup + 3-tier import resolution + MRO)
|
||||
Unified Resolution (3-tier name lookup + MRO walk)
|
||||
↑
|
||||
Language Providers (import semantics, type config, export checker, MRO strategy)
|
||||
↑
|
||||
@@ -303,13 +371,12 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
||||
| `exportChecker` | Public/exported symbol detection |
|
||||
| `typeConfig` | Type annotation extraction rules |
|
||||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
|
||||
|
||||
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
|
||||
### Unified capture tags
|
||||
|
||||
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@reference.inherits`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
|
||||
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@heritage.extends`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
|
||||
|
||||
### Import resolution
|
||||
|
||||
@@ -333,26 +400,23 @@ Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSe
|
||||
### Chunked parse-and-resolve
|
||||
|
||||
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
|
||||
1. Worker pool dispatches files (the sole parse path — there is no sequential fallback; `skipWorkers`, `--workers 0`, and `GITNEXUS_WORKER_POOL_SIZE=0` are rejected with an actionable error)
|
||||
1. Worker pool dispatches files (or sequential fallback via `skipWorkers`)
|
||||
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
|
||||
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
|
||||
4. Resolve imports
|
||||
4. Resolve imports and heritage
|
||||
5. Collect `BindingAccumulator` entries for cross-file propagation
|
||||
|
||||
Inheritance edges are emitted later, by the scope-resolution phase (`preEmitInheritanceEdges` + `emitHeritageEdges`), not during `parse`.
|
||||
|
||||
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
|
||||
|
||||
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the *sole* parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
|
||||
### Heritage and MRO
|
||||
|
||||
### Inheritance and MRO
|
||||
|
||||
Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy:
|
||||
All languages emit unified `ExtractedHeritage` (child, parent, `EXTENDS`/`IMPLEMENTS`). MRO phase walks the heritage graph using per-language strategy:
|
||||
- **`first-wins`** — Java, C#, C++, TS, Ruby, Go
|
||||
- **`c3`** — Python (C3 linearization)
|
||||
- **`ruby-mixin`** — Ruby (mixin-aware linearization)
|
||||
- **`none`** — single-inheritance languages
|
||||
|
||||
Unified walk: `lookupMethodByOwnerWithMRO()` in `model/resolve.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Full analysis flow
|
||||
@@ -398,8 +462,6 @@ 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.
|
||||
|
||||
@@ -4,6 +4,12 @@ All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
|
||||
@@ -35,7 +35,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
||||
## Reference Documentation
|
||||
|
||||
- **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md).
|
||||
- **Call & inheritance resolution:** See ARCHITECTURE.md § Scope-Resolution Pipeline. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` / `ScopeResolver` hooks instead (see AGENTS.md). (The legacy call-resolution DAG was removed in #942.)
|
||||
- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks instead (see AGENTS.md).
|
||||
- **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
|
||||
|
||||
## Changelog
|
||||
@@ -62,18 +62,18 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `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 run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- 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"})`.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
+1
-17
@@ -13,8 +13,6 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
|
||||
|
||||
## Development setup
|
||||
|
||||
**Prerequisites:** Node.js — `gitnexus/` requires `>=22.0.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
|
||||
|
||||
1. Clone the repository.
|
||||
2. **CLI / MCP package:** `cd gitnexus && npm install && npm run build`
|
||||
3. **Web UI (if needed):** `cd gitnexus-web && npm install`
|
||||
@@ -144,15 +142,6 @@ 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.
|
||||
@@ -166,12 +155,7 @@ routes between two modes based on the triggering event:
|
||||
suffix; RC tags are excluded at trigger via a negative glob). Publishes to
|
||||
the `latest` dist-tag with a changelog-backed GitHub release. Maintainers
|
||||
are expected to tag from `main` as a convention; the workflow itself does
|
||||
not enforce branch reachability. No Docker build (RC-only). Before cutting a
|
||||
stable release, keep `gitnexus/package.json`,
|
||||
`gitnexus-claude-plugin/.claude-plugin/plugin.json`,
|
||||
`.claude-plugin/marketplace.json`, and the matching `CHANGELOG.md` entry in
|
||||
lockstep — the always-on `gitnexus` unit suite now fails if those manifest
|
||||
versions drift.
|
||||
not enforce branch reachability. No Docker build (RC-only).
|
||||
- **Release-candidate mode** — runs on every push to `main` (typically a
|
||||
merged PR) plus manual `workflow_dispatch`. Docs-only changes are skipped
|
||||
via `paths-ignore`. Publishes to the `rc` dist-tag with version
|
||||
|
||||
@@ -36,17 +36,6 @@ RUN npm ci --prefix gitnexus
|
||||
# Drop dev dependencies for a smaller runtime layer.
|
||||
RUN npm prune --omit=dev --prefix gitnexus
|
||||
|
||||
# `npm prune` removes anything not in package.json's dependency tree — which
|
||||
# includes the VENDORED tree-sitter grammars (materialized into node_modules/ by
|
||||
# postinstall, but not declared as deps) and their freshly-built native bindings.
|
||||
# The `serve` image analyzes/parses uploaded repos at runtime, so those grammars
|
||||
# must survive into the runtime layer. Re-run the grammar postinstall here in the
|
||||
# builder (which still has python3/make/g++ and the hoisted node-addon-api /
|
||||
# node-gyp-build) to re-materialize + rebuild them after the prune. This is
|
||||
# load-bearing for tree-sitter-c (a core, REQUIRED grammar now vendored, #2116):
|
||||
# as a former `dependency` it used to survive prune; vendored, it would not.
|
||||
RUN npm run postinstall --prefix gitnexus
|
||||
|
||||
# -- Runtime -----------------------------------------------------------
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
@@ -78,44 +67,6 @@ COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
|
||||
# unreachable from $PATH.
|
||||
RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus
|
||||
|
||||
# Bake the LadybugDB FTS extension into the image so BM25 keyword search works
|
||||
# at runtime. The server runs the default `load-only` extension policy (the read
|
||||
# pool pins `{ policy: 'load-only' }`), so a runtime `LOAD EXTENSION fts` never
|
||||
# INSTALLs — the extension must already exist in the runtime user's HOME
|
||||
# extension dir, or every keyword search silently degrades (no FTS indexes are
|
||||
# written and ranking falls back to vector-only with only a `warning` field).
|
||||
# Run the installer as the `node` user with the SAME HOME the server runs under,
|
||||
# so `INSTALL fts` materializes the extension under `$HOME/.lbdb/extension` where
|
||||
# the runtime `LOAD` resolves it offline. `ENV HOME` is pinned because Docker
|
||||
# does not derive HOME from `USER`, so without it build-install and runtime-load
|
||||
# would resolve different paths. Requires network egress for the one-time
|
||||
# INSTALL; the build fails loudly if it cannot fetch the extension. The DB-size
|
||||
# default comes from GITNEXUS_LBUG_MAX_DB_SIZE (single source of truth, matches
|
||||
# the runtime) — it only sizes the throwaway scratch DB used to run INSTALL.
|
||||
# The second `--verify-only` step re-LOADs the extension in a FRESH process
|
||||
# under the same HOME, so a HOME/extension-dir mismatch fails the build here
|
||||
# rather than silently degrading keyword search to vector-only at runtime.
|
||||
ENV HOME=/home/node \
|
||||
GITNEXUS_LBUG_MAX_DB_SIZE=17179869184
|
||||
RUN su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts" \
|
||||
&& su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts --verify-only"
|
||||
|
||||
# Published runtime assets (in package.json `files`). Placed AFTER the DuckDB
|
||||
# FTS-extension RUN above so editing hook/skill content does not invalidate that
|
||||
# network-fetching cache layer; they have no input dependency on it.
|
||||
# `hooks/`: dist/cli/resolve-invocation.js does
|
||||
# `require('../../hooks/claude/resolve-analyze-cmd.cjs')` at module load — the
|
||||
# single source of truth for the npm-11 npx-crash invocation decision (#1939).
|
||||
# Without it, `gitnexus analyze` inside the image crashes with MODULE_NOT_FOUND
|
||||
# before it does any work (#2130). `skills/`: the CLI reads the bundled SKILL.md
|
||||
# templates from `<pkg>/skills/` for `gitnexus analyze --skills` and `gitnexus
|
||||
# setup`/`uninstall`; absent, those degrade silently (placeholder content / zero
|
||||
# skills installed). (The web UI bundle `web/`, also in `files`, is deliberately
|
||||
# NOT shipped: this builder never builds gitnexus-web, so the image is API-only;
|
||||
# the UI is the separate Dockerfile.web image / hosted app.)
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/hooks ./gitnexus/hooks
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/skills ./gitnexus/skills
|
||||
|
||||
USER node
|
||||
|
||||
# The web UI defaults to http://localhost:4747 - keep that contract.
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
<a href="https://securityscorecards.dev/viewer/?uri=github.com/abhigyanpatwari/GitNexus">
|
||||
<img src="https://api.securityscorecards.dev/projects/github.com/abhigyanpatwari/GitNexus/badge" alt="OpenSSF Scorecard"/>
|
||||
</a>
|
||||
<a href="https://github.com/abhigyanpatwari/GitNexus/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/abhigyanpatwari/GitNexus/actions/workflows/ci.yml/badge.svg" alt="CI Workflows"/>
|
||||
</a>
|
||||
|
||||
<p><strong>Enterprise (SaaS & Self-hosted)</strong> - <a href="https://akonlabs.com">akonlabs.com</a></p>
|
||||
|
||||
@@ -117,13 +114,11 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
|
||||
|
||||
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
|
||||
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` — those four won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild. See the `tree-sitter-kotlin` note below.
|
||||
>
|
||||
> **About `tree-sitter-kotlin`:** like Dart/Proto/Swift, Kotlin is a **vendored** grammar (under `gitnexus/vendor/tree-sitter-kotlin`). Upstream `tree-sitter-kotlin` ships **source only** (no prebuilt binaries), so GitNexus builds the Kotlin platform prebuilds itself (via the `build-tree-sitter-prebuilds` GitHub Actions workflow) and vendors them — the same uniform pipeline now used for Dart, Proto, and Swift (Swift's prebuilds were originally copied from upstream; they're now GitNexus-cross-built too). `node-gyp-build` selects the right `.node` at require time, so **no C/C++ toolchain is needed**. If no prebuild matches your platform-arch, only Kotlin (`.kt`/`.kts`) parsing is unavailable; the rest of `gitnexus` is unaffected.
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
|
||||
|
||||
### MCP Setup
|
||||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`.
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
|
||||
|
||||
### Editor Support
|
||||
|
||||
@@ -224,22 +219,19 @@ args = ["-y", "gitnexus@latest", "mcp"]
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
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 setup # Configure MCP for your editors (one-time)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --skip-skills # Skip installing .claude/skills/gitnexus/ skill files
|
||||
gitnexus analyze --default-branch develop # Branch used in the generated regression-compare example (base_ref)
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16, auto-sized to the repo). 0 is rejected — there is no sequential mode.
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (default: cores-1, capped at 16; 0 = sequential)
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
gitnexus list # List all indexed repositories
|
||||
@@ -262,8 +254,6 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
|
||||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
#### Embeddings node limit
|
||||
@@ -283,51 +273,19 @@ gitnexus analyze --embeddings 100000
|
||||
|
||||
If embeddings are skipped on a large repository, the indexed graph likely exceeds the default safety cap. Re-run with `gitnexus analyze --embeddings 0` to remove the cap, or `gitnexus analyze --embeddings <n>` to choose a higher limit while still keeping memory bounded.
|
||||
|
||||
#### Project config (`.gitnexusrc`)
|
||||
|
||||
Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `analyze` options per project, instead of re-passing the same flags every run. It is read from the resolved repo root (not `.gitnexus/`, which is gitignored index storage). **CLI flags always override `.gitnexusrc`.**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Default branch used in the generated regression-compare example (base_ref).
|
||||
// Use this so a project on `develop`/`master` doesn't get "main" rewritten
|
||||
// over its fix on every analyze. (Alias: "branch".)
|
||||
"defaultBranch": "develop",
|
||||
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
|
||||
"skipSkills": true, // don't install .claude/skills/gitnexus/
|
||||
"embeddings": true, // generate embeddings by default
|
||||
"workerTimeout": 60
|
||||
}
|
||||
```
|
||||
|
||||
A nested `analyze` block is also accepted (and overrides flat keys for the same option):
|
||||
|
||||
```json
|
||||
{ "analyze": { "defaultBranch": "develop", "skipSkills": true } }
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`.
|
||||
- `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection.
|
||||
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
|
||||
- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
|
||||
|
||||
#### Environment variables
|
||||
|
||||
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.
|
||||
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size. `0` disables the pool (sequential fallback). Equivalent to `--workers <n>`. | Constrained containers (cgroup CPU limits), CI runners with explicit quotas, or debugging a worker-only crash via `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
@@ -335,7 +293,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD`| `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` at install time. | Installing on a host without a C++ toolchain or where Swift prebuilds don't match; you're willing to skip Dart/Proto/Swift parsing. |
|
||||
|
||||
#### Publishing to understand-quickly (opt-in)
|
||||
|
||||
@@ -349,7 +307,7 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
|
||||
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ----------------- | ---------------------------------------------------------------- | ------------ |
|
||||
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
|
||||
| `list_repos` | Discover all indexed repositories | — |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
|
||||
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
|
||||
@@ -362,7 +320,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({search_query: "auth", repo: "my-app"})`.
|
||||
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
|
||||
|
||||
**Resources** for instant context:
|
||||
|
||||
@@ -705,8 +663,6 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
||||
|
||||
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
|
||||
|
||||
**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default.
|
||||
|
||||
---
|
||||
|
||||
## Tool Examples
|
||||
@@ -740,7 +696,7 @@ gitnexus impact get_embeddings --uid "Function:src/embed.py:get_embeddings" # e
|
||||
### Process-Grouped Search
|
||||
|
||||
```
|
||||
query({search_query: "authentication middleware"})
|
||||
query({query: "authentication middleware"})
|
||||
|
||||
processes:
|
||||
- summary: "LoginFlow"
|
||||
|
||||
+42
-32
@@ -4,11 +4,11 @@ How we structure tests and which commands to run locally and in CI.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Path | Runner | Notes |
|
||||
| -------------- | --------------- | ---------- | -------------------------- |
|
||||
| CLI + MCP core | `gitnexus/` | Vitest | Primary test surface in CI |
|
||||
| Web UI | `gitnexus-web/` | Vitest | Unit/component tests |
|
||||
| Web UI E2E | `gitnexus-web/` | Playwright | Run when changing UI flows |
|
||||
| Package | Path | Runner | Notes |
|
||||
| -------------- | -------------- | -------- | ------------------------------ |
|
||||
| CLI + MCP core | `gitnexus/` | Vitest | Primary test surface in CI |
|
||||
| Web UI | `gitnexus-web/`| Vitest | Unit/component tests |
|
||||
| Web UI E2E | `gitnexus-web/`| Playwright | Run when changing UI flows |
|
||||
|
||||
## Test lanes
|
||||
|
||||
@@ -16,25 +16,25 @@ How we structure tests and which commands to run locally and in CI.
|
||||
|
||||
From `gitnexus/`:
|
||||
|
||||
| Command | What it runs | When to use |
|
||||
| ----------------------------- | -------------------------------------------------- | ------------------------------------- |
|
||||
| `npm test` | Full suite (all 3 vitest projects) | Before opening a PR |
|
||||
| `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop |
|
||||
| `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers |
|
||||
| `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact |
|
||||
| `npm run test:parity` | Scope-resolution parity for all migrated languages | After changing resolver or scope code |
|
||||
| `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue |
|
||||
| `npm run test:watch` | Vitest in watch mode | Active development |
|
||||
| Command | What it runs | When to use |
|
||||
| ------------------------ | ---------------------------------------------------- | ------------------------------- |
|
||||
| `npm test` | Full suite (all 3 vitest projects) | Before opening a PR |
|
||||
| `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop |
|
||||
| `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers |
|
||||
| `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact |
|
||||
| `npm run test:parity` | Scope-resolution parity for all migrated languages | After changing resolver or scope code |
|
||||
| `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue |
|
||||
| `npm run test:watch` | Vitest in watch mode | Active development |
|
||||
|
||||
### `gitnexus-web/` commands
|
||||
|
||||
From `gitnexus-web/`:
|
||||
|
||||
| Command | What it runs | When to use |
|
||||
| ----------------------- | ----------------------------- | ------------------------------------------------------------------- |
|
||||
| `npm test` | Unit/component tests (vitest) | After changing web code |
|
||||
| `npm run test:coverage` | Unit tests + coverage | Checking coverage impact |
|
||||
| `npm run test:e2e` | Playwright browser tests | After changing UI flows (requires `gitnexus serve` + `npm run dev`) |
|
||||
| Command | What it runs | When to use |
|
||||
| ---------------------- | --------------------------------- | ------------------------------ |
|
||||
| `npm test` | Unit/component tests (vitest) | After changing web code |
|
||||
| `npm run test:coverage`| Unit tests + coverage | Checking coverage impact |
|
||||
| `npm run test:e2e` | Playwright browser tests | After changing UI flows (requires `gitnexus serve` + `npm run dev`) |
|
||||
|
||||
### Before opening a PR
|
||||
|
||||
@@ -59,11 +59,11 @@ Skip with `git commit --no-verify` (use sparingly).
|
||||
|
||||
`gitnexus/vitest.config.ts` defines three projects for safety isolation:
|
||||
|
||||
| Project | Files | Parallelism | Purpose |
|
||||
| --------- | -------------------------------------------------- | ----------- | --------------------------------------------------- |
|
||||
| `lbug-db` | Native LadybugDB integration tests (explicit list) | Sequential | Prevents file-lock conflicts from native mmap addon |
|
||||
| `cli-e2e` | `skills-e2e.test.ts` | Sequential | CLI process spawning requires serial execution |
|
||||
| `default` | Everything else | Parallel | Fast execution for pure logic and parser tests |
|
||||
| Project | Files | Parallelism | Purpose |
|
||||
| ---------- | ----------------------------- | ----------- | ---------------------------------------------- |
|
||||
| `lbug-db` | Native LadybugDB integration tests (explicit list) | Sequential | Prevents file-lock conflicts from native mmap addon |
|
||||
| `cli-e2e` | `skills-e2e.test.ts` | Sequential | CLI process spawning requires serial execution |
|
||||
| `default` | Everything else | Parallel | Fast execution for pure logic and parser tests |
|
||||
|
||||
When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to the `lbug-db` project's explicit include list and the `default` project's exclude list.
|
||||
|
||||
@@ -74,11 +74,21 @@ When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to
|
||||
- **Resolver / parity** — Language-specific call-resolution tests in `test/integration/resolvers/`.
|
||||
- **E2E (web)** — Critical user paths only; prefer `data-testid` attributes for stable selectors. Tests run against real backend (`gitnexus serve`) and Vite dev server.
|
||||
|
||||
## Scope-resolution tests
|
||||
## Scope-resolution parity
|
||||
|
||||
Every language resolves calls and inheritance through the scope-resolution pipeline — the legacy call-resolution DAG and the per-language `REGISTRY_PRIMARY_<LANG>` flag were removed in RING4-1 (#942). Each language's resolver test lives at `test/integration/resolvers/<slug>.test.ts` and runs once, on the single scope-resolution path, as part of the normal `tests` job (`vitest test/**/*.test.ts`).
|
||||
Migrated languages (listed in `MIGRATED_LANGUAGES` in `src/core/ingestion/registry-primary-flag.ts`) are tested in both legacy and registry-primary modes on every PR.
|
||||
|
||||
Adding a language: register its `ScopeResolver` in `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`) and add the resolver test file — no workflow or config edit needed.
|
||||
For each migrated language, CI runs the resolver test file twice:
|
||||
1. `REGISTRY_PRIMARY_<LANG>=0` — legacy DAG path
|
||||
2. `REGISTRY_PRIMARY_<LANG>=1` — registry-primary path
|
||||
|
||||
Both must pass. Known legacy gaps are listed in `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES` in `test/integration/resolvers/helpers.ts` and are automatically skipped in legacy mode.
|
||||
|
||||
Adding a language to `MIGRATED_LANGUAGES` automatically enrolls it in parity — no workflow or config edit needed. The test file must exist at `test/integration/resolvers/<slug>.test.ts`.
|
||||
|
||||
Run parity locally: `cd gitnexus && npm run test:parity`
|
||||
|
||||
Run for a single language: `cd gitnexus && npx tsx scripts/run-parity.ts --language python`
|
||||
|
||||
## Cross-platform testing
|
||||
|
||||
@@ -110,12 +120,12 @@ To check the cross-platform list is up to date, run `npm run test:cross-platform
|
||||
|
||||
GitHub Actions (`.github/workflows/ci.yml`) orchestrate:
|
||||
|
||||
| Workflow | Jobs | Purpose |
|
||||
| --------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `ci-quality.yml` | format, lint, typecheck, typecheck-web, workflow-convention | Code quality gates |
|
||||
| Workflow | Jobs | Purpose |
|
||||
| --------------------- | ------------------------------ | ------------------------------------------------ |
|
||||
| `ci-quality.yml` | format, lint, typecheck, typecheck-web, workflow-convention | Code quality gates |
|
||||
| `ci-tests.yml` | ubuntu/coverage, cross-platform (Win/Mac), packaged-install-smoke | Full suite + coverage on Ubuntu; platform-sensitive subset on Win/Mac |
|
||||
| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages |
|
||||
| `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes |
|
||||
| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages |
|
||||
| `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes |
|
||||
|
||||
The `CI Gate` job in `ci.yml` is the single required check for branch protection. It requires quality, tests, e2e, and scope-parity to all pass.
|
||||
|
||||
|
||||
Generated
+90
-106
@@ -21,7 +21,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.14.1"
|
||||
version = "3.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -30,111 +30,95 @@ dependencies = [
|
||||
{ name = "frozenlist" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -942,7 +926,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.87.0"
|
||||
version = "1.83.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -958,9 +942,9 @@ dependencies = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/0d/ccdf682ccfd7f18bf0e179c39d85616b8f8ef05a798588285310412db13d/litellm-1.87.0.tar.gz", hash = "sha256:cafc1882cb0cbab8374c41180af86e4a067796e4524e15f59e99f6e689cd1bd8", size = 15453755, upload-time = "2026-06-02T03:53:29.076Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/20/88a372fa7e50fc2c33458c6eef94a79afcf7bdfa43610079531b82b484a3/litellm-1.87.0-py3-none-any.whl", hash = "sha256:fbbba7e47ae29b55f878fe1acc80effb92761bc168f6236bd81a0cb6e147d855", size = 17103948, upload-time = "2026-06-02T03:53:25.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
|
||||
"version": "1.6.8",
|
||||
"version": "1.3.6",
|
||||
"author": {
|
||||
"name": "GitNexus"
|
||||
},
|
||||
|
||||
@@ -15,10 +15,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.js');
|
||||
const {
|
||||
hasGitNexusDbLockedByGitNexusServer,
|
||||
resolveUnixGuardTimeout,
|
||||
} = require('./hook-db-lock-probe.cjs');
|
||||
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
|
||||
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
|
||||
|
||||
/**
|
||||
@@ -113,20 +110,10 @@ function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
|
||||
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
|
||||
* non-error skip paths must stay silent unless the operator explicitly asks
|
||||
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
|
||||
*/
|
||||
function isDebugEnabled() {
|
||||
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = isDebugEnabled();
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
|
||||
@@ -199,90 +186,18 @@ 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))) {
|
||||
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, {
|
||||
return spawnSync(process.execPath, [String(hookCli), ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
@@ -306,12 +221,7 @@ function runGitNexusCli(args, cwd, timeout) {
|
||||
}
|
||||
|
||||
if (useDirectBinary) {
|
||||
// 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, {
|
||||
return spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
@@ -319,27 +229,8 @@ function runGitNexusCli(args, cwd, timeout) {
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
// 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, {
|
||||
// npx fallback needs shell on Windows since npx is a .cmd script
|
||||
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
@@ -375,34 +266,16 @@ function handlePreToolUse(input) {
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
||||
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
||||
// as the augment, or concurrent sessions fan out unbounded probe
|
||||
// subprocesses. Keep the acquire right after the cheap guards above —
|
||||
// moving it earlier would churn slot files on tool calls that never probe.
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
||||
// the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
||||
}
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
||||
let result = '';
|
||||
try {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
// Normal skip path: the MCP server owns the DB, so the CLI augment would
|
||||
// contend on the lock. Stay silent for strict hook runners (issue #1913);
|
||||
// surface the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = extractAugmentContext(child.stderr || '');
|
||||
@@ -493,7 +366,7 @@ function main() {
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (isDebugEnabled()) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,57 +3,14 @@
|
||||
* with a command line that looks like a GitNexus MCP/serve server?
|
||||
*
|
||||
* Backends (no user-installed Sysinternals):
|
||||
* - 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.
|
||||
* - 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.
|
||||
* - 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 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
|
||||
* wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
|
||||
* is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
|
||||
* survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
|
||||
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
|
||||
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
|
||||
* wrapper off deterministically; any other value is adopted only when it
|
||||
* exists AND passes a one-shot `-k` exit-propagation self-test — otherwise
|
||||
* resolution FALLS THROUGH to the built-in candidate list (first self-test
|
||||
* pass wins), so no malformed value of any shape can silently disable
|
||||
* orphan containment.
|
||||
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
|
||||
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
|
||||
* A probe before that first query is therefore always false — a known,
|
||||
* pre-existing race, not a bug in this probe.
|
||||
* - resolveUnixGuardTimeout is exported so the hook adapters can wrap the
|
||||
* `gitnexus augment` CLI child — the longest-lived hook subprocess (7s
|
||||
* local / 12s npx inner budgets) — in the same guard; see runGitNexusCli
|
||||
* in the adapters (#2163 follow-up).
|
||||
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
|
||||
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -68,16 +25,6 @@ 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];
|
||||
@@ -99,106 +46,6 @@ function resolveHookBinary(tool) {
|
||||
return tool;
|
||||
}
|
||||
|
||||
function hasMissingHookBinaryOverride(tool) {
|
||||
const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
|
||||
const fromEnv = process.env[envKey];
|
||||
if (!fromEnv || !String(fromEnv).trim()) return false;
|
||||
try {
|
||||
return !fs.existsSync(String(fromEnv).trim());
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Sentinel:
|
||||
// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
|
||||
// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
|
||||
// null = no usable wrapper (disabled, none found, or self-test failed)
|
||||
let unixGuardTimeoutCache;
|
||||
|
||||
/**
|
||||
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
|
||||
* (#2163). Unix-only by contract: the probe's win32 dispatch returns before
|
||||
* reaching it, and the exported callers (the adapters' runGitNexusCli,
|
||||
* #2163 follow-up) must check the platform first — the self-test below
|
||||
* spawns /bin/sh. The memoized result is module-wide, so probe and adapter
|
||||
* share one lazy self-test per hook process.
|
||||
*
|
||||
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
|
||||
* wrapper off; any other value is only a CANDIDATE — an existing file path
|
||||
* is tried first, but it must pass the `-k` exit-propagation self-test to
|
||||
* be adopted. On any failure (non-existent path, directory, non-executable
|
||||
* file, wrapper without `-k` support, always-exit-0 stub, …) resolution
|
||||
* falls through to the built-in candidates below, tried in order, first
|
||||
* self-test pass wins. This is strictly stronger than the sibling
|
||||
* GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only
|
||||
* check existence): no bad env value of ANY shape can silently disable
|
||||
* orphan containment.
|
||||
*
|
||||
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
|
||||
* first reached, and the result is memoized. A candidate is adopted only
|
||||
* when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 — i.e. it must RUN
|
||||
* the wrapped command AND PROPAGATE its exit status. This rejects two
|
||||
* failure shapes: wrappers without the coreutils `-k` flag — busybox <1.34,
|
||||
* toybox, broken symlinks — which would exit with a usage error without
|
||||
* ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed
|
||||
* contract into fail-open (#1492 regression); and always-exit-0 stubs
|
||||
* (/bin/true shapes), which would otherwise be adopted and "succeed" every
|
||||
* wrapped spawn instantly without running it — a constant no-owner probe
|
||||
* answer and, worse, a silently dead augment (status 0, empty stderr passes
|
||||
* the adapters' success check with no context; #2163 follow-up review).
|
||||
* Only when EVERY candidate fails does the probe fall back to the unwrapped
|
||||
* status quo (memoized null). busybox ≥1.34 passes the test and is fully
|
||||
* usable for everything THIS file spawns (lsof/ps are the guard's direct
|
||||
* children) and for the adapters' direct-exec arm. The adapters' npx arm
|
||||
* additionally relies on coreutils' process-GROUP signalling for its
|
||||
* `-s KILL` grandchild reaping; busybox signals only its direct child, and
|
||||
* this self-test deliberately does not probe that capability — see the
|
||||
* adapter docblocks for the residual-gap statement.
|
||||
*/
|
||||
function passesGuardSelfTest(guard) {
|
||||
try {
|
||||
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return !selfTest.error && selfTest.status === 42;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUnixGuardTimeout() {
|
||||
if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
|
||||
unixGuardTimeoutCache = null;
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
|
||||
const trimmed = fromEnv ? String(fromEnv).trim() : '';
|
||||
if (trimmed === 'disabled') return unixGuardTimeoutCache;
|
||||
const candidates = [];
|
||||
if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
|
||||
for (const builtin of [
|
||||
'/usr/bin/timeout',
|
||||
'/bin/timeout',
|
||||
'/opt/homebrew/bin/gtimeout',
|
||||
'/usr/local/bin/gtimeout',
|
||||
]) {
|
||||
try {
|
||||
if (fs.existsSync(builtin)) candidates.push(builtin);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (passesGuardSelfTest(candidate)) {
|
||||
unixGuardTimeoutCache = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return unixGuardTimeoutCache;
|
||||
}
|
||||
|
||||
function resolveWindowsPowerShellPath() {
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
|
||||
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
|
||||
@@ -285,374 +132,76 @@ function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
function readLinuxCmdline(pidStr) {
|
||||
try {
|
||||
return fs
|
||||
.readFileSync(path.join(procRoot, pidStr, 'comm'), 'utf8')
|
||||
.replace(/\0+/g, '')
|
||||
.trim();
|
||||
return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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 raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
|
||||
const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
|
||||
const start = Date.now();
|
||||
const outOfBudget = () => Date.now() - start > budget;
|
||||
|
||||
let targetStat;
|
||||
try {
|
||||
targetStat = fs.statSync(dbPathAbs);
|
||||
} catch {
|
||||
// Caller already existsSync'd the path; a stat failure here is a transient
|
||||
// race, treat as no owner (historical semantics).
|
||||
return 'not-owned';
|
||||
return false;
|
||||
}
|
||||
|
||||
let procEntries;
|
||||
try {
|
||||
procEntries = fs.readdirSync(procRoot, { withFileTypes: true });
|
||||
procEntries = fs.readdirSync('/proc', { withFileTypes: true });
|
||||
} catch {
|
||||
return 'not-owned';
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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 (outOfBudget()) return 'timeout';
|
||||
if (Date.now() - start > budget) return false;
|
||||
if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
|
||||
const pid = Number.parseInt(ent.name, 10);
|
||||
if (!Number.isFinite(pid) || pid === myPid) continue;
|
||||
|
||||
// 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');
|
||||
const fdDir = path.join('/proc', ent.name, 'fd');
|
||||
let fds;
|
||||
try {
|
||||
fds = fs.readdirSync(fdDir);
|
||||
} 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`,
|
||||
);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let holds = false;
|
||||
for (const fd of fds) {
|
||||
if (outOfBudget()) return 'timeout';
|
||||
if (Date.now() - start > budget) return false;
|
||||
try {
|
||||
const st = fs.statSync(path.join(fdDir, fd));
|
||||
if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
|
||||
return 'owned';
|
||||
holds = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* fd raced closed; ignore */
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (!holds) continue;
|
||||
if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
|
||||
}
|
||||
|
||||
return 'not-owned';
|
||||
return false;
|
||||
}
|
||||
|
||||
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
const guard = resolveUnixGuardTimeout();
|
||||
// An explicit missing override models ENOENT and must fail open instead of
|
||||
// falling through to a host binary with different process-table visibility.
|
||||
if (hasMissingHookBinaryOverride('lsof')) return false;
|
||||
const lsofPath = resolveHookBinary('lsof');
|
||||
// The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
|
||||
// SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
|
||||
// SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
|
||||
// fail-closed contract are untouched. The wrapper only matters once this
|
||||
// hook process has been SIGKILLed and can no longer deliver that SIGTERM.
|
||||
const [lsofCmd, lsofArgs] = guard
|
||||
? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
|
||||
: [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
|
||||
const lsof = spawnSync(lsofCmd, lsofArgs, {
|
||||
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
|
||||
// Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
|
||||
// result shapes, verified against coreutils 9.1:
|
||||
// - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
|
||||
// SELF-RAISES the signal, so spawnSync reports {status: null, signal}
|
||||
// with no .error (spawnSync's own ETIMEDOUT was handled above). The
|
||||
// same shape appears when this hook is frozen >2s (SIGSTOP, laptop
|
||||
// suspend) and the guard expires while it sleeps. By construction, a
|
||||
// guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
|
||||
// is a budget/kill outcome.
|
||||
// - 124: budget expired and the child exited after the plain SIGTERM.
|
||||
// - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
|
||||
// or a child SIGKILLed externally (e.g. the OOM killer).
|
||||
if (guard && lsof.status === null && lsof.signal) return true;
|
||||
if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
|
||||
|
||||
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
|
||||
const psMissing = hasMissingHookBinaryOverride('ps');
|
||||
const psPath = resolveHookBinary('ps');
|
||||
for (const pid of pids) {
|
||||
if (Number(pid) === myPid) continue;
|
||||
// Missing ps means we cannot verify that this pid is a GitNexus server.
|
||||
if (psMissing) continue;
|
||||
const [psCmd, psArgs] = guard
|
||||
? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
|
||||
: [psPath, ['-p', pid, '-o', 'command=']];
|
||||
const ps = spawnSync(psCmd, psArgs, {
|
||||
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 500,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
@@ -662,11 +211,6 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
if (ps.error.code === 'ETIMEDOUT') return true;
|
||||
continue;
|
||||
}
|
||||
// Same guard-mediated-death mapping as the lsof call above (signal-death
|
||||
// from the -k escalation or a frozen hook; 124 budget expiry; 137 only
|
||||
// for exit-code-propagating wrappers / external SIGKILL).
|
||||
if (guard && ps.status === null && ps.signal) return true;
|
||||
if (guard && (ps.status === 124 || ps.status === 137)) return true;
|
||||
if (isGitNexusServerCommand(ps.stdout || '')) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -685,13 +229,8 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
// #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';
|
||||
if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
|
||||
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
|
||||
}
|
||||
|
||||
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
|
||||
@@ -699,30 +238,4 @@ 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,
|
||||
};
|
||||
|
||||
@@ -27,8 +27,6 @@
|
||||
*/
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const NPX_REF = 'gitnexus@latest';
|
||||
|
||||
@@ -36,72 +34,46 @@ const NPX_REF = 'gitnexus@latest';
|
||||
const PNPM_ALLOW_BUILD_BASE = ['@ladybugdb/core', 'gitnexus', 'tree-sitter'];
|
||||
const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
|
||||
|
||||
// Version-probe timeout, kept under Claude Code's 10s hook budget. PATH presence
|
||||
// detection is now spawn-free (resolveOnPath scans PATH directly), so the only
|
||||
// subprocesses left are the version probes: in a linked worktree the stale-index
|
||||
// hook first runs `git rev-parse --git-common-dir` (~2s) and `git rev-parse HEAD`
|
||||
// (~3s); the pnpm path then adds up to two 1s `--version` probes (npm, pnpm), so
|
||||
// the worst case is ~7s — within budget. A healthy `--version` returns in well
|
||||
// under a second, so the realistic cost is far lower.
|
||||
// Probe timeout, kept under Claude Code's 10s hook budget. In a linked worktree
|
||||
// the stale-index hook first runs `git rev-parse --git-common-dir` (~2s) and
|
||||
// `git rev-parse HEAD` (~3s); the pnpm path then adds up to four 1s probes
|
||||
// (which gitnexus, npm --version, which pnpm, pnpm --version), so the worst case
|
||||
// is ~9s — within budget but tight. A healthy `which`/`where`/`--version`
|
||||
// returns in well under a second, so the realistic cost is far lower.
|
||||
const PROBE_TIMEOUT_MS = 1000;
|
||||
|
||||
/**
|
||||
* Absolute path to `command` on PATH, or null — a pure-Node, spawn-free lookup
|
||||
* that mirrors how a shell resolves a bare command name: each PATH dir × the
|
||||
* platform's executable extensions (PATHEXT on Windows; the bare name + X_OK on
|
||||
* POSIX). This replaces the former `where`/`which` subprocess (#1938 "Option A"):
|
||||
* it is byte-for-byte identical on every OS, with no dependency on the probe
|
||||
* binary being reachable (a sanitized PATH that drops System32 / `/usr/bin` no
|
||||
* longer defeats detection), no shell-spawn surface (CVE-2024-27980), and no
|
||||
* spawn timeout to tune. On Windows it matches PATHEXT extensions ONLY — exactly
|
||||
* what `where`/cmd.exe resolve — so neither an un-spawnable `.ps1`-only shim (not
|
||||
* in default PATHEXT) nor a bare extensionless file (which the shell cannot launch
|
||||
* as `command`) is a false positive. `preferExecExt` returns a recognized
|
||||
* `.cmd`/`.bat`/`.exe` shim ahead of an exotic PATHEXT hit (e.g. `.COM`) when both
|
||||
* match, matching what a user would actually launch. Pure (platform/env injectable)
|
||||
* so it is unit-testable without touching the host PATH.
|
||||
* Pick the best match from `where`/`which` output. A global `gitnexus` may be a
|
||||
* `.cmd`/`.bat` (npm), a `.exe`, or an extensionless shim (Volta, scoop), so on
|
||||
* Windows we prefer a recognized executable extension but accept any hit — the
|
||||
* emitted hint is `gitnexus analyze` regardless of which shim resolves it. Pure
|
||||
* and exported so the shim-matching can be unit-tested without spawning.
|
||||
*/
|
||||
function resolveOnPath(
|
||||
command,
|
||||
preferExecExt = false,
|
||||
{ platform = process.platform, env = process.env } = {},
|
||||
) {
|
||||
const pathValue = env.PATH || env.Path || env.path || '';
|
||||
if (!pathValue) return null;
|
||||
const isWin = platform === 'win32';
|
||||
const exts = isWin
|
||||
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
|
||||
.split(';')
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean)
|
||||
.map((e) => (e.startsWith('.') ? e : `.${e}`))
|
||||
: [''];
|
||||
let weakHit = null;
|
||||
// Split on the host's PATH delimiter. `platform` is injected only to choose the
|
||||
// extension/exec-bit rules; the PATH string is always host-format, so it must
|
||||
// split on the host delimiter (`path.delimiter`) — in production `platform` IS
|
||||
// the host, so they coincide. (Deriving the delimiter from an injected platform
|
||||
// would split a Windows drive-letter path `C:\…` at its colon under a POSIX
|
||||
// injection.)
|
||||
for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
|
||||
for (const ext of exts) {
|
||||
const candidate = path.join(dir, `${command}${ext}`);
|
||||
try {
|
||||
if (!fs.statSync(candidate).isFile()) continue;
|
||||
if (!isWin) fs.accessSync(candidate, fs.constants.X_OK);
|
||||
// Prefer a runnable .cmd/.bat/.exe shim; remember an exotic PATHEXT hit
|
||||
// (e.g. .COM) only as a last resort if nothing better turns up.
|
||||
if (isWin && preferExecExt && !/\.(cmd|bat|exe)$/i.test(ext)) {
|
||||
weakHit = weakHit || candidate;
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
} catch {
|
||||
/* not a runnable file here — try the next candidate */
|
||||
}
|
||||
}
|
||||
function pickPathMatch(output, { isWin, gitnexusWrapper } = {}) {
|
||||
const lines = output
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (isWin && gitnexusWrapper) {
|
||||
return lines.find((l) => /\.(cmd|bat|exe)$/i.test(l)) || lines[0] || null;
|
||||
}
|
||||
return lines[0] || null;
|
||||
}
|
||||
|
||||
/** Absolute path to `command` on PATH, or null. `gitnexusWrapper` enables the Windows shim match. */
|
||||
function resolveOnPath(command, gitnexusWrapper = false) {
|
||||
const isWin = process.platform === 'win32';
|
||||
try {
|
||||
const output = execFileSync(isWin ? 'where' : 'which', [command], {
|
||||
encoding: 'utf-8',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return pickPathMatch(output, { isWin, gitnexusWrapper });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return weakHit;
|
||||
}
|
||||
|
||||
// One spawn of `<command> --version` → { major, minor } (each null when
|
||||
@@ -221,12 +193,12 @@ function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
|
||||
function formatAnalyzeCommand(options = {}, deps = {}) {
|
||||
const suffix = options.embeddings ? ' --embeddings' : '';
|
||||
// Keep the stale-index hook budget tight by querying each tool at most once.
|
||||
// The memoized `probe` is a spawn-free PATH scan (resolveOnPath) shared with
|
||||
// resolveInvocationMode, so `gitnexus` is scanned only once and no subprocess
|
||||
// is spawned for presence. pnpm's *version* is still captured by a single
|
||||
// `pnpm --version` (the allow-build gate needs the number), which also proves
|
||||
// presence; the memoized scan only re-checks pnpm when that version is
|
||||
// unreadable. Injected deps (tests) and forced/global modes skip the pnpm probe.
|
||||
// A memoized PATH probe is shared with resolveInvocationMode (so `gitnexus`
|
||||
// isn't probed twice), and pnpm's version is captured by a single
|
||||
// `pnpm --version` that proves both presence (for mode resolution) and
|
||||
// version (for the allow-build gate) — replacing the former `which pnpm` +
|
||||
// `pnpm --version` double spawn. Injected deps (tests) and forced/global
|
||||
// modes skip the pnpm probe.
|
||||
const cache = new Map();
|
||||
const probe = (command, gitnexusWrapper) => {
|
||||
const key = `${command}:${gitnexusWrapper ? 1 : 0}`;
|
||||
@@ -284,7 +256,7 @@ module.exports = {
|
||||
formatPnpmDlxCommand,
|
||||
resolveInvocationMode,
|
||||
buildRunnerArgv,
|
||||
resolveOnPath,
|
||||
pickPathMatch,
|
||||
getNpmMajorVersion,
|
||||
NPX_REF,
|
||||
PNPM_ALLOW_BUILD_BASE,
|
||||
|
||||
@@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. query({search_query: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
@@ -28,11 +28,11 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] query for error text or related code
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] context to see callers and callees
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] cypher for custom call chain traces if needed
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
@@ -40,58 +40,46 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `query` for error text → `context` on throw sites |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call |
|
||||
|
||||
## Tools
|
||||
|
||||
**query** — find code related to error:
|
||||
**gitnexus_query** — find code related to error:
|
||||
|
||||
```
|
||||
query({search_query: "payment validation error"})
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**context** — full context for a suspect:
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**cypher** — custom call chain traces:
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
```
|
||||
|
||||
**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops:
|
||||
|
||||
```
|
||||
trace({ from: "processCheckout", to: "fetchRates" })
|
||||
→ status: ok, hopCount: 3
|
||||
→ hops: processCheckout → validatePayment → verifyCard → fetchRates
|
||||
→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0)
|
||||
```
|
||||
|
||||
When no path exists, `trace` reports the furthest reachable node — exactly where the chain breaks (dynamic dispatch, reflection, or an external boundary).
|
||||
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. query({search_query: "payment error handling"})
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. context({name: "validatePayment"})
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
|
||||
@@ -18,8 +18,8 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
@@ -29,9 +29,9 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
@@ -47,18 +47,18 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
|
||||
## Tools
|
||||
|
||||
**query** — find execution flows related to a concept:
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
query({search_query: "payment processing"})
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**context** — 360-degree view of a symbol:
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
context({name: "validateUser"})
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
@@ -68,10 +68,10 @@ context({name: "validateUser"})
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. query({search_query: "payment processing"})
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. context({name: "processPayment"})
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
|
||||
@@ -35,74 +35,10 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
||||
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `trace` | Shortest path between two symbols — "how does A reach B?" in one call |
|
||||
| `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`
|
||||
|
||||
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"repositories": [
|
||||
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
|
||||
],
|
||||
"pagination": {
|
||||
"total": 437,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"returned": 50,
|
||||
"hasMore": true,
|
||||
"nextOffset": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
|
||||
|
||||
```text
|
||||
list_repos {} → repos 1–50, nextOffset 50, hasMore true
|
||||
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
|
||||
…
|
||||
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
|
||||
```
|
||||
|
||||
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
|
||||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
|
||||
|
||||
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
|
||||
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
|
||||
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
|
||||
|
||||
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
|
||||
|
||||
### 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.
|
||||
|
||||
### Shortest path between two symbols (`trace`)
|
||||
|
||||
`trace` answers "how does A reach B?" in one call — the shortest directed path over `CALLS` (plus `HAS_METHOD`, so a class-rooted trace descends into its methods) instead of chaining 3–8 `context`/`impact` hops by hand.
|
||||
|
||||
- `trace { from: "validateUser", to: "executeQuery" }` — shortest path between two symbols.
|
||||
- Disambiguate common names with `from_uid`/`to_uid` (zero-ambiguity) or `from_file`/`to_file`; an ambiguous name returns ranked candidates.
|
||||
- `maxDepth` (default 10, max 30) bounds the search; `includeTests` (default false) lets the traversal pass through test-file symbols.
|
||||
|
||||
Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`.
|
||||
| `list_repos` | Discover indexed repos |
|
||||
|
||||
## Resources Reference
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ description: "Use when the user wants to know what will break if they change som
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. detect_changes() → Map current git changes to affected flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
@@ -28,11 +28,11 @@ description: "Use when the user wants to know what will break if they change som
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] detect_changes() for pre-commit check
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
@@ -55,10 +55,10 @@ description: "Use when the user wants to know what will break if they change som
|
||||
|
||||
## Tools
|
||||
|
||||
**impact** — the primary tool for symbol blast radius:
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
impact({
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
@@ -73,10 +73,10 @@ impact({
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**detect_changes** — git-diff based impact analysis:
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
detect_changes({scope: "staged"})
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
@@ -86,7 +86,7 @@ detect_changes({scope: "staged"})
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. impact({target: "validateUser", direction: "upstream"})
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -18,10 +18,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. context({name: "<key symbol>"}) → Understand callers/callees
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
@@ -32,10 +32,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] detect_changes to map changes to affected execution flows
|
||||
- [ ] impact on each non-trivial changed symbol
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] context on key changed symbols to understand full picture
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
@@ -63,20 +63,20 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
## Tools
|
||||
|
||||
**detect_changes** — map PR diff to affected execution flows:
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
detect_changes({scope: "compare", base_ref: "main"})
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**impact** — blast radius per changed symbol:
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream"})
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
@@ -86,20 +86,20 @@ impact({target: "validatePayment", direction: "upstream"})
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**impact with tests** — check test coverage:
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**context** — understand a changed symbol's role:
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
@@ -112,20 +112,20 @@ context({name: "validatePayment"})
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. detect_changes({scope: "compare", base_ref: "main"})
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. impact({target: "validatePayment", direction: "upstream"})
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. impact({target: "PaymentInput", direction: "upstream"})
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. context({name: "formatAmount"})
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
|
||||
@@ -16,9 +16,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. query({search_query: "X"}) → Find execution flows involving X
|
||||
3. context({name: "X"}) → See all incoming/outgoing refs
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
@@ -29,65 +29,65 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] context({name: target}) — understand all callees
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**rename** — automated multi-file rename:
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**impact** — map all dependents first:
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
|
||||
```
|
||||
impact({target: "validateUser", direction: "upstream"})
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**detect_changes** — verify your changes after refactoring:
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
detect_changes({scope: "all"})
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**cypher** — custom reference queries:
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
@@ -98,24 +98,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use rename for automated updates |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | query to find them |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. detect_changes({scope: "all"})
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
---
|
||||
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,18 +241,7 @@ function main() {
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
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;
|
||||
}
|
||||
if (!release) return;
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
|
||||
@@ -15,10 +15,10 @@ description: Trace bugs through call chains using knowledge graph
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. query({search_query: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
@@ -27,11 +27,11 @@ description: Trace bugs through call chains using knowledge graph
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] query for error text or related code
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] context to see callers and callees
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] cypher for custom call chain traces if needed
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
@@ -39,54 +39,43 @@ description: Trace bugs through call chains using knowledge graph
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|---------|-------------------|
|
||||
| Error message | `query` for error text → `context` on throw sites |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call |
|
||||
|
||||
## Tools
|
||||
|
||||
**query** — find code related to error:
|
||||
**gitnexus_query** — find code related to error:
|
||||
```
|
||||
query({search_query: "payment validation error"})
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**context** — full context for a suspect:
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**cypher** — custom call chain traces:
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
```
|
||||
|
||||
**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops:
|
||||
```
|
||||
trace({ from: "processCheckout", to: "fetchRates" })
|
||||
→ status: ok, hopCount: 3
|
||||
→ hops: processCheckout → validatePayment → verifyCard → fetchRates
|
||||
→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0)
|
||||
```
|
||||
|
||||
When no path exists, `trace` reports the furthest reachable node — exactly where the chain breaks (dynamic dispatch, reflection, or an external boundary).
|
||||
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. query({search_query: "payment error handling"})
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. context({name: "validatePayment"})
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
|
||||
@@ -17,8 +17,8 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
@@ -28,9 +28,9 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
@@ -46,16 +46,16 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
|
||||
## Tools
|
||||
|
||||
**query** — find execution flows related to a concept:
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
```
|
||||
query({search_query: "payment processing"})
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**context** — 360-degree view of a symbol:
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
```
|
||||
context({name: "validateUser"})
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
@@ -65,10 +65,10 @@ context({name: "validateUser"})
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. query({search_query: "payment processing"})
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. context({name: "processPayment"})
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
|
||||
@@ -16,9 +16,9 @@ description: Analyze blast radius before making code changes
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. detect_changes() → Map current git changes to affected flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
@@ -27,11 +27,11 @@ description: Analyze blast radius before making code changes
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] detect_changes() for pre-commit check
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
@@ -54,9 +54,9 @@ description: Analyze blast radius before making code changes
|
||||
|
||||
## Tools
|
||||
|
||||
**impact** — the primary tool for symbol blast radius:
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
```
|
||||
impact({
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
@@ -71,9 +71,9 @@ impact({
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**detect_changes** — git-diff based impact analysis:
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
```
|
||||
detect_changes({scope: "staged"})
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
@@ -83,7 +83,7 @@ detect_changes({scope: "staged"})
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. impact({target: "validateUser", direction: "upstream"})
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. context({name: "<key symbol>"}) → Understand callers/callees
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
@@ -32,10 +32,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] detect_changes to map changes to affected execution flows
|
||||
- [ ] impact on each non-trivial changed symbol
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] context on key changed symbols to understand full picture
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
@@ -63,20 +63,20 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
## Tools
|
||||
|
||||
**detect_changes** — map PR diff to affected execution flows:
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
detect_changes({scope: "compare", base_ref: "main"})
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**impact** — blast radius per changed symbol:
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream"})
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
@@ -86,20 +86,20 @@ impact({target: "validatePayment", direction: "upstream"})
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**impact with tests** — check test coverage:
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**context** — understand a changed symbol's role:
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
@@ -112,20 +112,20 @@ context({name: "validatePayment"})
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. detect_changes({scope: "compare", base_ref: "main"})
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. impact({target: "validatePayment", direction: "upstream"})
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. impact({target: "PaymentInput", direction: "upstream"})
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. context({name: "formatAmount"})
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
|
||||
@@ -15,9 +15,9 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. query({search_query: "X"}) → Find execution flows involving X
|
||||
3. context({name: "X"}) → See all incoming/outgoing refs
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
@@ -27,60 +27,60 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
|
||||
### Rename Symbol
|
||||
```
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
```
|
||||
- [ ] context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
```
|
||||
- [ ] context({name: target}) — understand all callees
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**rename** — automated multi-file rename:
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
```
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**impact** — map all dependents first:
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
```
|
||||
impact({target: "validateUser", direction: "upstream"})
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**detect_changes** — verify your changes after refactoring:
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
```
|
||||
detect_changes({scope: "all"})
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**cypher** — custom reference queries:
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
@@ -90,24 +90,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
|-------------|------------|
|
||||
| Many callers (>5) | Use rename for automated updates |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | query to find them |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. detect_changes({scope: "all"})
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
|
||||
@@ -44,10 +44,7 @@ export type NodeLabel =
|
||||
| 'Template'
|
||||
| 'Section'
|
||||
| 'Route'
|
||||
| 'Tool'
|
||||
// Taint/PDG substrate (issue #2080). Intra-procedural control-flow node.
|
||||
// Emitted by no phase yet — M1 (#2081) populates these behind an opt-in.
|
||||
| 'BasicBlock';
|
||||
| 'Tool';
|
||||
|
||||
export type NodeProperties = {
|
||||
name: string;
|
||||
@@ -92,11 +89,6 @@ export type NodeProperties = {
|
||||
responseKeys?: string[];
|
||||
errorKeys?: string[];
|
||||
middleware?: string[];
|
||||
// BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
|
||||
text?: string;
|
||||
/** BasicBlock: space-joined leaf callee names invoked in the block — the
|
||||
* statement-precise inter-procedural reach substrate for impact mode. */
|
||||
callees?: string;
|
||||
// Extensible
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -123,71 +115,7 @@ export type RelationshipType =
|
||||
| 'HANDLES_TOOL'
|
||||
| 'ENTRY_POINT_OF'
|
||||
| 'WRAPS'
|
||||
| 'QUERIES'
|
||||
/** Vue component event system: a handler function in a parent component is
|
||||
* bound to an event emitted by a child component (`@event="handlerFn"`).
|
||||
* Source = handler Function/Method node in the parent.
|
||||
* Target = the child component's File node.
|
||||
* `reason` encodes the event name: `vue-event: @<eventName>`.
|
||||
* Complements `EMITS_EVENT`; together they enable Cypher queries that
|
||||
* trace which handlers receive which component's emitted events. */
|
||||
| 'BINDS_EVENT_HANDLER'
|
||||
/** Vue component event system: a component calls `emit('eventName', ...)`
|
||||
* or `this.$emit('eventName', ...)`, advertising that it can emit that event.
|
||||
* Source = the component's own File node (self-referential annotation).
|
||||
* Target = the same File node.
|
||||
* `reason` encodes the event name: `vue-emit: <eventName>`.
|
||||
* Complements `BINDS_EVENT_HANDLER`; a Cypher query joining on the
|
||||
* component File node reveals all (emitter, handler) pairs. */
|
||||
| 'EMITS_EVENT'
|
||||
// ── Taint/PDG substrate (issue #2080) ────────────────────────────────────
|
||||
// Reserved edge types for the taint-first PDG substrate. No phase emits any
|
||||
// of these yet; they are populated behind an opt-in by later milestones
|
||||
// (CFG → M1 #2081, REACHING_DEF → M2 #2082, TAINTED/SANITIZES/TAINT_PATH →
|
||||
// M3/M4 #2083/#2084). Adding them here keeps the shared schema stable so
|
||||
// downstream work does not re-ripple the exhaustiveness sites.
|
||||
/** Control-flow edge between two BasicBlock nodes (intra-procedural CFG). */
|
||||
| 'CFG'
|
||||
/** Data-dependence edge: a definition of `variable` reaches a use of it.
|
||||
* The `variable` name is stored in the relation's existing `reason` column
|
||||
* (M0/S1 verdict: LadybugDB has no secondary index on relationship
|
||||
* properties, so a dedicated indexed column would not speed the
|
||||
* variable-filtered path query). */
|
||||
| 'REACHING_DEF'
|
||||
/** A tainted value flows from source toward sink. */
|
||||
| 'TAINTED'
|
||||
/** A sanitizer clears taint along a flow. */
|
||||
| 'SANITIZES'
|
||||
/** Materialized source→sink taint path. Working name — final name/representation
|
||||
* is confirmed when M3/M4 emits it; no persisted edge exists before then. */
|
||||
| 'TAINT_PATH'
|
||||
/** 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'
|
||||
/** Per-callee dependence SUMMARY edge (PDG FU-C): a self-loop on a
|
||||
* Function/Method/Constructor node carrying that callee's RETURN-VALUE
|
||||
* ASCENT — which formal-parameter indices flow to the function's return
|
||||
* value, encoded as a versioned bitset in the relation's existing `reason`
|
||||
* column (the same single-channel pattern `CFG`/`REACHING_DEF`/`CDG` use,
|
||||
* since the lone `CodeRelation` table has no dedicated label column). A
|
||||
* later consumer phase lets an interprocedural slice ascend a callee's
|
||||
* return effect into the caller continuation. Like the taint substrate
|
||||
* edges it is an internal PDG-engine edge: deliberately EXCLUDED from
|
||||
* `VALID_RELATION_TYPES` and the web schema so it never leaks into
|
||||
* callgraph-style impact/relationship surfaces. Emitted only under `--pdg`;
|
||||
* a default analyze emits zero. */
|
||||
| 'CALL_SUMMARY';
|
||||
| 'QUERIES';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
|
||||
@@ -116,8 +116,6 @@ export type {
|
||||
FieldRegistry,
|
||||
FieldLookupOptions,
|
||||
} from './scope-resolution/registries/field-registry.js';
|
||||
export { buildMacroRegistry } from './scope-resolution/registries/macro-registry.js';
|
||||
export type { MacroRegistry } from './scope-resolution/registries/macro-registry.js';
|
||||
export { lookupCore } from './scope-resolution/registries/lookup-core.js';
|
||||
export type { CoreLookupParams } from './scope-resolution/registries/lookup-core.js';
|
||||
export { lookupQualified } from './scope-resolution/registries/lookup-qualified.js';
|
||||
@@ -129,12 +127,7 @@ export {
|
||||
CONFIDENCE_EPSILON,
|
||||
} from './scope-resolution/registries/tie-breaks.js';
|
||||
export type { TieBreakKey } from './scope-resolution/registries/tie-breaks.js';
|
||||
export {
|
||||
CLASS_KINDS,
|
||||
METHOD_KINDS,
|
||||
FIELD_KINDS,
|
||||
MACRO_KINDS,
|
||||
} from './scope-resolution/registries/context.js';
|
||||
export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/registries/context.js';
|
||||
export type {
|
||||
RegistryContext,
|
||||
RegistryProviders,
|
||||
@@ -183,3 +176,13 @@ export {
|
||||
stripGitSuffix,
|
||||
} from './integrations/understand-quickly.js';
|
||||
export type { UqDispatchPayload } from './integrations/understand-quickly.js';
|
||||
|
||||
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
|
||||
export { diffResolutions } from './scope-resolution/shadow/diff.js';
|
||||
export type {
|
||||
ShadowAgreement,
|
||||
ShadowCallsite,
|
||||
ShadowDiff,
|
||||
} from './scope-resolution/shadow/diff.js';
|
||||
export { aggregateDiffs } from './scope-resolution/shadow/aggregate.js';
|
||||
export type { LanguageParityRow, ShadowParityReport } from './scope-resolution/shadow/aggregate.js';
|
||||
|
||||
@@ -32,17 +32,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
|
||||
[SupportedLanguages.Python]: ['.py'],
|
||||
[SupportedLanguages.Java]: ['.java'],
|
||||
[SupportedLanguages.C]: ['.c'],
|
||||
[SupportedLanguages.CPlusPlus]: [
|
||||
'.cpp',
|
||||
'.cc',
|
||||
'.cxx',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.hxx',
|
||||
'.hh',
|
||||
'.cu',
|
||||
'.cuh',
|
||||
],
|
||||
[SupportedLanguages.CPlusPlus]: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
|
||||
[SupportedLanguages.CSharp]: ['.cs'],
|
||||
[SupportedLanguages.Go]: ['.go'],
|
||||
[SupportedLanguages.Ruby]: ['.rb', '.rake', '.gemspec'],
|
||||
|
||||
@@ -40,8 +40,6 @@ export const NODE_TABLES = [
|
||||
'Module',
|
||||
'Route',
|
||||
'Tool',
|
||||
// Taint/PDG substrate (issue #2080) — inert until M1 (#2081) emits blocks.
|
||||
'BasicBlock',
|
||||
] as const;
|
||||
|
||||
export type NodeTableName = (typeof NODE_TABLES)[number];
|
||||
@@ -69,20 +67,6 @@ export const REL_TYPES = [
|
||||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
'QUERIES',
|
||||
// Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no
|
||||
// phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH →
|
||||
// M3/M4). REACHING_DEF's variable name rides the relation's `reason` column.
|
||||
'CFG',
|
||||
'REACHING_DEF',
|
||||
'TAINTED',
|
||||
'SANITIZES',
|
||||
'TAINT_PATH',
|
||||
// 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];
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #913.
|
||||
*
|
||||
* Consumed by: #915 (SCC finalize link pass).
|
||||
* Consumed by: #915 (SCC finalize link pass), #923 (shadow harness when
|
||||
* resolving callsite file → enclosing module).
|
||||
*/
|
||||
|
||||
import type { ScopeId } from './types.js';
|
||||
|
||||
@@ -74,50 +74,4 @@ export interface ParsedFile {
|
||||
*/
|
||||
readonly localDefs: readonly SymbolDefinition[];
|
||||
readonly referenceSites: readonly ReferenceSite[];
|
||||
/**
|
||||
* Opaque, language-private serialization of capture-time side-channel
|
||||
* state that a provider's `emitScopeCaptures` populates into module-level
|
||||
* maps as a SIDE EFFECT (not onto the scopes/defs of this `ParsedFile`).
|
||||
*
|
||||
* Such state is computed inside the parse worker (where `emitScopeCaptures`
|
||||
* runs) and would otherwise be lost across the worker→main MessageChannel
|
||||
* and the disk store, because scope-resolution reuses the serialized
|
||||
* `ParsedFile` and SKIPS re-extraction on the main thread (#1983 — the
|
||||
* whole point is to avoid a main-thread tree-sitter re-parse). Carrying the
|
||||
* data here lets the main thread repopulate those maps WITHOUT re-parsing.
|
||||
*
|
||||
* Shared / ingestion code treats this as opaque (`unknown`) per AGENTS.md
|
||||
* (no language names in shared code). The producing language fills it via
|
||||
* the `LanguageProvider.collectCaptureSideChannel` hook (worker side) and
|
||||
* consumes it via the `ScopeResolver.applyCaptureSideChannel` hook
|
||||
* (main-thread resolution side). It MUST be plain JSON-serializable data
|
||||
* (objects / arrays / primitives) so it round-trips through the disk-backed
|
||||
* `parsedfile-store` (JSON.stringify + interning reviver).
|
||||
*
|
||||
* Optional: providers whose `emitScopeCaptures` is pure (no module-level
|
||||
* side effects — the contract default) leave this undefined.
|
||||
*/
|
||||
readonly captureSideChannel?: unknown;
|
||||
|
||||
/**
|
||||
* Per-function control-flow graphs for this file (#2081 M1, PDG/taint
|
||||
* substrate). A DISTINCT field from {@link captureSideChannel} — different
|
||||
* producer, consumer, and lifecycle: the worker builds it from the
|
||||
* tree-sitter AST via `LanguageProvider.cfgVisitor` (only on a `--pdg` run),
|
||||
* and scope-resolution emits BasicBlock nodes + CFG edges from it while the
|
||||
* disk-backed ParsedFile store is still live (it is NOT a capture-time
|
||||
* marker the resolver restores into module maps). Kept separate so a future
|
||||
* change to either channel's shape invalidates independently.
|
||||
*
|
||||
* Shared / ingestion code treats this as opaque (`unknown`) per AGENTS.md.
|
||||
* Concretely it is a `readonly FunctionCfg[]` (see
|
||||
* `core/ingestion/cfg/types.ts`) — plain JSON-serializable data (no AST
|
||||
* refs, no class instances) so it round-trips through the parse cache and
|
||||
* the `parsedfile-store` (whose interning reviver keys on `nodeId`, which
|
||||
* these blocks/edges deliberately lack).
|
||||
*
|
||||
* Optional: `undefined` on non-`--pdg` runs and for languages with no
|
||||
* `cfgVisitor` — the default for every run today.
|
||||
*/
|
||||
readonly cfgSideChannel?: unknown;
|
||||
}
|
||||
|
||||
@@ -37,12 +37,7 @@ export type ReferenceKind =
|
||||
| 'write'
|
||||
| 'type-reference'
|
||||
| 'inherits'
|
||||
| 'import-use'
|
||||
// A macro invocation (`log!(...)` / `vec![...]`). Resolved against
|
||||
// `Macro`-labeled definitions ONLY (see `MacroRegistry`) so a macro
|
||||
// never aliases a same-named free function — macros and functions are
|
||||
// disjoint namespaces. Emitted as a `USES` edge, not `CALLS`.
|
||||
| 'macro';
|
||||
| 'import-use';
|
||||
|
||||
/**
|
||||
* How a call site binds its target. Informs `Registry.lookup` Step 2
|
||||
|
||||
@@ -162,10 +162,3 @@ export const FIELD_KINDS: readonly NodeLabel[] = Object.freeze([
|
||||
'Const',
|
||||
'Static',
|
||||
]);
|
||||
|
||||
// Macros occupy a namespace disjoint from functions/methods: a `log!`
|
||||
// invocation must resolve ONLY to a `macro_rules! log` definition, never
|
||||
// to a same-named `fn log`. `MACRO_KINDS` is therefore a singleton
|
||||
// (`['Macro']`) and is NOT merged into METHOD_KINDS — keeping the two
|
||||
// keyspaces separate is what prevents the cross-namespace false-edge.
|
||||
export const MACRO_KINDS: readonly NodeLabel[] = Object.freeze(['Macro']);
|
||||
|
||||
@@ -57,7 +57,8 @@ export interface RawSignals {
|
||||
*
|
||||
* Emission order mirrors the `EvidenceWeights` layout: where-found →
|
||||
* type-binding → corroborators → arity → degraded. Stable order makes
|
||||
* the per-signal contributions easy to reason about in tests.
|
||||
* the per-signal contributions easy to reason about in tests and in the
|
||||
* shadow-mode parity dashboard.
|
||||
*/
|
||||
export function composeEvidence(signals: RawSignals): readonly ResolutionEvidence[] {
|
||||
const out: ResolutionEvidence[] = [];
|
||||
@@ -140,7 +141,7 @@ export function composeEvidence(signals: RawSignals): readonly ResolutionEvidenc
|
||||
|
||||
/**
|
||||
* Sum evidence weights and clamp to `[0, 1]`. Separate from `composeEvidence`
|
||||
* so tests can inspect the raw evidence list.
|
||||
* so tests and the parity dashboard can inspect the raw evidence list.
|
||||
*/
|
||||
export function confidenceFromEvidence(evidence: readonly ResolutionEvidence[]): number {
|
||||
let sum = 0;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* `MacroRegistry` — scope-aware lookup for macro definitions
|
||||
* (`macro_rules!` in Rust; `#define` in C/C++) referenced from a macro
|
||||
* invocation site.
|
||||
*
|
||||
* Thin wrapper over `lookupCore`, specialized for the macro namespace:
|
||||
*
|
||||
* - `acceptedKinds` = `MACRO_KINDS` (`['Macro']` only). Crucially this
|
||||
* does NOT include `Function`/`Method`, so a `log!(…)` invocation can
|
||||
* never resolve to a same-named free function `fn log` — macros and
|
||||
* functions are disjoint namespaces (the false-`CALLS`-edge class the
|
||||
* #1934 review flagged).
|
||||
* - `useReceiverTypeBinding` is **false** — a macro invocation has no
|
||||
* receiver; resolution is name-through-the-lexical-chain + the global
|
||||
* qualified fallback, exactly like `ClassRegistry`.
|
||||
* - Arity is not applied — macros are variadic by nature.
|
||||
*/
|
||||
|
||||
import type { Resolution, ScopeId } from '../types.js';
|
||||
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
|
||||
import { MACRO_KINDS, type RegistryContext } from './context.js';
|
||||
|
||||
export interface MacroRegistry {
|
||||
/**
|
||||
* Look up a macro definition by simple or scoped name anchored at
|
||||
* `scope`. Returns a confidence-ranked `Resolution[]`; consume `[0]`
|
||||
* for the best answer.
|
||||
*/
|
||||
lookup(name: string, scope: ScopeId): readonly Resolution[];
|
||||
}
|
||||
|
||||
export function buildMacroRegistry(ctx: RegistryContext): MacroRegistry {
|
||||
const params: CoreLookupParams = {
|
||||
acceptedKinds: MACRO_KINDS,
|
||||
useReceiverTypeBinding: false,
|
||||
ownerScopedContributor: null,
|
||||
};
|
||||
return {
|
||||
lookup(name: string, scope: ScopeId) {
|
||||
return lookupCore(name, scope, params, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Shadow-mode aggregation — per-language parity %, per-evidence-kind
|
||||
* breakdown of divergences. Consumed by the parity dashboard (RING2-PKG-5).
|
||||
*
|
||||
* Pure functions; no I/O. The harness persists per-run JSON; the dashboard
|
||||
* reads `.gitnexus/shadow-parity/latest.json` and renders.
|
||||
*
|
||||
* Related types — `ShadowAgreement`, `ShadowCallsite`, `ShadowDiff` — are
|
||||
* defined alongside `diffResolutions` in `./diff.ts` and re-exported
|
||||
* through the top-level `gitnexus-shared` barrel. Consumers import all
|
||||
* three from `gitnexus-shared`, not from this module.
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #918.
|
||||
*/
|
||||
|
||||
import type { SupportedLanguages } from '../../languages.js';
|
||||
import type { ResolutionEvidence } from '../types.js';
|
||||
import type { ShadowAgreement, ShadowDiff } from './diff.js';
|
||||
|
||||
// ─── Aggregated report shape ────────────────────────────────────────────────
|
||||
|
||||
export interface LanguageParityRow {
|
||||
readonly language: SupportedLanguages;
|
||||
readonly totalCalls: number;
|
||||
readonly bothAgree: number;
|
||||
readonly onlyLegacy: number;
|
||||
readonly onlyNew: number;
|
||||
readonly bothDisagree: number;
|
||||
readonly bothEmpty: number;
|
||||
/**
|
||||
* Fraction in [0, 1]. Numerator = `bothAgree`; denominator = "calls where
|
||||
* at least one side resolved" = `totalCalls - bothEmpty`.
|
||||
*
|
||||
* When the denominator is 0 (all calls for this language were
|
||||
* `both-empty`), returns 0. Callers rendering the dashboard should treat
|
||||
* a 0 parity alongside `totalCalls === bothEmpty` as "no signal" rather
|
||||
* than "total disagreement".
|
||||
*/
|
||||
readonly parity: number;
|
||||
/**
|
||||
* Divergence signals broken down by `ResolutionEvidence.kind`. Sourced
|
||||
* from `ShadowDiff.evidenceDelta` on non-agreeing rows only — `both-agree`
|
||||
* and `both-empty` do not contribute.
|
||||
*/
|
||||
readonly evidenceBreakdown: ReadonlyMap<ResolutionEvidence['kind'], number>;
|
||||
}
|
||||
|
||||
export interface ShadowParityReport {
|
||||
readonly generatedAt: string; // ISO 8601
|
||||
readonly perLanguage: readonly LanguageParityRow[];
|
||||
readonly overall: Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'>;
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aggregate a stream of `ShadowDiff` records into a `ShadowParityReport`,
|
||||
* bucketed by language. Pure function.
|
||||
*
|
||||
* - `perLanguage` rows are sorted alphabetically by `SupportedLanguages`
|
||||
* value for stable JSON output (the dashboard reads
|
||||
* `.gitnexus/shadow-parity/latest.json` and diffing snapshots is useful).
|
||||
* - `overall` is the column-wise sum across languages.
|
||||
* - `generatedAt` is injected via the `now` parameter so tests stay
|
||||
* deterministic; production callers let it default to `new Date()`.
|
||||
*/
|
||||
export function aggregateDiffs(
|
||||
diffs: readonly { readonly language: SupportedLanguages; readonly diff: ShadowDiff }[],
|
||||
now: Date = new Date(),
|
||||
): ShadowParityReport {
|
||||
const perLanguageMap = new Map<SupportedLanguages, MutableCounts>();
|
||||
|
||||
for (const { language, diff } of diffs) {
|
||||
let counts = perLanguageMap.get(language);
|
||||
if (!counts) {
|
||||
counts = makeEmptyCounts();
|
||||
perLanguageMap.set(language, counts);
|
||||
}
|
||||
tallyDiff(counts, diff);
|
||||
}
|
||||
|
||||
const perLanguage: LanguageParityRow[] = Array.from(perLanguageMap.entries())
|
||||
.map(([language, counts]) => buildRow(language, counts))
|
||||
.sort((a, b) => a.language.localeCompare(b.language));
|
||||
|
||||
const overall = buildOverallRow(perLanguage);
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
perLanguage,
|
||||
overall,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
interface MutableCounts {
|
||||
totalCalls: number;
|
||||
bothAgree: number;
|
||||
onlyLegacy: number;
|
||||
onlyNew: number;
|
||||
bothDisagree: number;
|
||||
bothEmpty: number;
|
||||
evidenceBreakdown: Map<ResolutionEvidence['kind'], number>;
|
||||
}
|
||||
|
||||
function makeEmptyCounts(): MutableCounts {
|
||||
return {
|
||||
totalCalls: 0,
|
||||
bothAgree: 0,
|
||||
onlyLegacy: 0,
|
||||
onlyNew: 0,
|
||||
bothDisagree: 0,
|
||||
bothEmpty: 0,
|
||||
evidenceBreakdown: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function tallyDiff(counts: MutableCounts, diff: ShadowDiff): void {
|
||||
counts.totalCalls += 1;
|
||||
incrementAgreement(counts, diff.agreement);
|
||||
if (diff.agreement === 'both-agree' || diff.agreement === 'both-empty') return;
|
||||
for (const ev of diff.evidenceDelta) {
|
||||
counts.evidenceBreakdown.set(ev.kind, (counts.evidenceBreakdown.get(ev.kind) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function incrementAgreement(counts: MutableCounts, agreement: ShadowAgreement): void {
|
||||
switch (agreement) {
|
||||
case 'both-agree':
|
||||
counts.bothAgree += 1;
|
||||
return;
|
||||
case 'only-legacy':
|
||||
counts.onlyLegacy += 1;
|
||||
return;
|
||||
case 'only-new':
|
||||
counts.onlyNew += 1;
|
||||
return;
|
||||
case 'both-disagree':
|
||||
counts.bothDisagree += 1;
|
||||
return;
|
||||
case 'both-empty':
|
||||
counts.bothEmpty += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRow(language: SupportedLanguages, counts: MutableCounts): LanguageParityRow {
|
||||
const resolved = counts.totalCalls - counts.bothEmpty;
|
||||
const parity = resolved > 0 ? counts.bothAgree / resolved : 0;
|
||||
return {
|
||||
language,
|
||||
totalCalls: counts.totalCalls,
|
||||
bothAgree: counts.bothAgree,
|
||||
onlyLegacy: counts.onlyLegacy,
|
||||
onlyNew: counts.onlyNew,
|
||||
bothDisagree: counts.bothDisagree,
|
||||
bothEmpty: counts.bothEmpty,
|
||||
parity,
|
||||
// Freeze via `new Map` on a sorted-kind copy so downstream consumers
|
||||
// can't mutate the aggregator's internal state.
|
||||
evidenceBreakdown: new Map(
|
||||
Array.from(counts.evidenceBreakdown.entries()).sort(([a], [b]) => a.localeCompare(b)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOverallRow(
|
||||
perLanguage: readonly LanguageParityRow[],
|
||||
): Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'> {
|
||||
let totalCalls = 0;
|
||||
let bothAgree = 0;
|
||||
let onlyLegacy = 0;
|
||||
let onlyNew = 0;
|
||||
let bothDisagree = 0;
|
||||
let bothEmpty = 0;
|
||||
for (const row of perLanguage) {
|
||||
totalCalls += row.totalCalls;
|
||||
bothAgree += row.bothAgree;
|
||||
onlyLegacy += row.onlyLegacy;
|
||||
onlyNew += row.onlyNew;
|
||||
bothDisagree += row.bothDisagree;
|
||||
bothEmpty += row.bothEmpty;
|
||||
}
|
||||
const resolved = totalCalls - bothEmpty;
|
||||
const parity = resolved > 0 ? bothAgree / resolved : 0;
|
||||
return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Shadow-mode diff logic — RFC §6.3.
|
||||
*
|
||||
* Pure comparison logic for shadow mode. Takes two `Resolution[]` (legacy
|
||||
* DAG result + new scope-based registry result) and produces a structured
|
||||
* diff record for the parity dashboard.
|
||||
*
|
||||
* Consumed by the Ring 2 PKG shadow harness (#923), which dual-runs each
|
||||
* call through legacy + new paths, diffs results, and persists per-run JSON
|
||||
* for the parity dashboard.
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #918.
|
||||
*/
|
||||
|
||||
import type { Resolution, ResolutionEvidence } from '../types.js';
|
||||
|
||||
// ─── Diff record shape ──────────────────────────────────────────────────────
|
||||
|
||||
export type ShadowAgreement =
|
||||
| 'both-agree' // top match identical (same DefId)
|
||||
| 'only-legacy' // legacy resolved; new did not
|
||||
| 'only-new' // new resolved; legacy did not
|
||||
| 'both-disagree' // both resolved, but to different targets
|
||||
| 'both-empty'; // both returned empty
|
||||
|
||||
export interface ShadowDiff {
|
||||
readonly callsite: ShadowCallsite;
|
||||
readonly legacy: Resolution | null;
|
||||
readonly newResult: Resolution | null;
|
||||
readonly agreement: ShadowAgreement;
|
||||
/**
|
||||
* Symmetric difference of the two top resolutions' `evidence` arrays,
|
||||
* keyed on `ResolutionEvidence.kind`.
|
||||
*
|
||||
* - For `'both-agree'` and `'both-empty'` agreements, always empty.
|
||||
* - For `'both-disagree'`, contains evidence kinds present on exactly one
|
||||
* side (not in both).
|
||||
* - For `'only-legacy'`, contains all of legacy's top evidence.
|
||||
* - For `'only-new'`, contains all of new's top evidence.
|
||||
*/
|
||||
readonly evidenceDelta: readonly ResolutionEvidence[];
|
||||
}
|
||||
|
||||
export interface ShadowCallsite {
|
||||
readonly filePath: string;
|
||||
readonly line: number;
|
||||
readonly col: number;
|
||||
readonly calledName: string;
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compare two `Resolution[]` arrays (top matches at `[0]`) and produce a
|
||||
* `ShadowDiff`. Pure function.
|
||||
*
|
||||
* Agreement rules:
|
||||
* - both arrays empty → `'both-empty'`, `evidenceDelta: []`
|
||||
* - legacy empty, new non-empty → `'only-new'`, `evidenceDelta` = new's top evidence
|
||||
* - legacy non-empty, new empty → `'only-legacy'`, `evidenceDelta` = legacy's top evidence
|
||||
* - both non-empty, same top `def.nodeId` → `'both-agree'`, `evidenceDelta: []`
|
||||
* - both non-empty, different top `def.nodeId` → `'both-disagree'`,
|
||||
* `evidenceDelta` = symmetric difference by `ResolutionEvidence.kind`
|
||||
* (first occurrence of a kind-only-on-legacy then kind-only-on-new; order
|
||||
* preserved from input arrays)
|
||||
*
|
||||
* Evidence-delta rationale: callers aggregating divergences want to know
|
||||
* which signal kinds explain a disagreement. Keying on `kind` (not full
|
||||
* equality over `weight`/`note`) avoids spurious deltas when the same
|
||||
* signal fires with slightly different calibration weights on each side.
|
||||
*/
|
||||
export function diffResolutions(
|
||||
callsite: ShadowCallsite,
|
||||
legacy: readonly Resolution[],
|
||||
newResult: readonly Resolution[],
|
||||
): ShadowDiff {
|
||||
const legacyTop: Resolution | null = legacy.length > 0 ? legacy[0] : null;
|
||||
const newTop: Resolution | null = newResult.length > 0 ? newResult[0] : null;
|
||||
|
||||
const agreement: ShadowAgreement = (() => {
|
||||
if (legacyTop === null && newTop === null) return 'both-empty';
|
||||
if (legacyTop === null) return 'only-new';
|
||||
if (newTop === null) return 'only-legacy';
|
||||
return legacyTop.def.nodeId === newTop.def.nodeId ? 'both-agree' : 'both-disagree';
|
||||
})();
|
||||
|
||||
const evidenceDelta = computeEvidenceDelta(legacyTop, newTop, agreement);
|
||||
|
||||
return {
|
||||
callsite,
|
||||
legacy: legacyTop,
|
||||
newResult: newTop,
|
||||
agreement,
|
||||
evidenceDelta,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Symmetric difference of two evidence arrays, keyed on
|
||||
* `ResolutionEvidence.kind`. Preserves input order: legacy-only signals
|
||||
* first (in legacy's original order), then new-only signals (in new's order).
|
||||
*
|
||||
* For `'both-agree'` / `'both-empty'` the delta is empty by contract. For
|
||||
* `'only-legacy'` / `'only-new'` one side's evidence is the delta (nothing to
|
||||
* subtract against).
|
||||
*/
|
||||
function computeEvidenceDelta(
|
||||
legacy: Resolution | null,
|
||||
newResult: Resolution | null,
|
||||
agreement: ShadowAgreement,
|
||||
): readonly ResolutionEvidence[] {
|
||||
if (agreement === 'both-agree' || agreement === 'both-empty') return [];
|
||||
if (agreement === 'only-legacy') return legacy!.evidence;
|
||||
if (agreement === 'only-new') return newResult!.evidence;
|
||||
|
||||
// both-disagree: symmetric difference keyed on `kind`
|
||||
const legacyKinds = new Set(legacy!.evidence.map((e) => e.kind));
|
||||
const newKinds = new Set(newResult!.evidence.map((e) => e.kind));
|
||||
|
||||
const onlyInLegacy = legacy!.evidence.filter((e) => !newKinds.has(e.kind));
|
||||
const onlyInNew = newResult!.evidence.filter((e) => !legacyKinds.has(e.kind));
|
||||
|
||||
return [...onlyInLegacy, ...onlyInNew];
|
||||
}
|
||||
@@ -20,8 +20,6 @@ export interface ParameterTypeClass {
|
||||
indirection: 'value' | 'lvalue-ref' | 'rvalue-ref' | 'pointer' | 'unknown';
|
||||
/** Number of pointer markers when indirection is `pointer`; otherwise 0. */
|
||||
pointerDepth: number;
|
||||
/** Normalized top-level template arguments, when a language preserves them. */
|
||||
templateArguments?: string[];
|
||||
}
|
||||
|
||||
export interface SymbolDefinition {
|
||||
@@ -59,19 +57,6 @@ export interface SymbolDefinition {
|
||||
* Currently used by C++ overload ranking to exclude explicit constructors
|
||||
* from implicit user-defined conversion candidates. */
|
||||
isExplicit?: boolean;
|
||||
/** True when the callable is declared unavailable (for example C++ `= delete`).
|
||||
* Unavailable callables still participate in overload selection, but a
|
||||
* selected unavailable target must suppress edge emission. */
|
||||
isDeleted?: boolean;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
|
||||
* tagged during the C++ resolution phase. Lets the graph bridge retry a
|
||||
* namespace-prefixed node-lookup key and lets the qualified-base resolver
|
||||
* break same-tail cross-namespace inheritance ties. A deliberate sidecar,
|
||||
* separate from `qualifiedName`: it does NOT participate in graph node
|
||||
* identity (node keys derive from filePath/type/qualifiedName) and leaves the
|
||||
* qualifiedName-keyed resolution index untouched. Absent for the common case
|
||||
* (non-namespace-nested defs and all non-C++ languages). */
|
||||
namespacePrefix?: string;
|
||||
}
|
||||
|
||||
@@ -105,13 +105,6 @@ export type ParsedImport =
|
||||
readonly localName: string;
|
||||
readonly importedName: string;
|
||||
readonly targetRaw: string;
|
||||
/**
|
||||
* Set by providers when `targetRaw` already names the imported symbol
|
||||
* rather than only its containing module. Consumers that compose
|
||||
* `<local>.<member>` paths can then use `targetRaw.<member>` instead of
|
||||
* duplicating `importedName`.
|
||||
*/
|
||||
readonly targetIncludesImportedName?: boolean;
|
||||
}
|
||||
/**
|
||||
* Per-name import with rename.
|
||||
@@ -126,8 +119,6 @@ export type ParsedImport =
|
||||
readonly importedName: string;
|
||||
readonly alias: string;
|
||||
readonly targetRaw: string;
|
||||
/** See the same field on the `named` variant. */
|
||||
readonly targetIncludesImportedName?: boolean;
|
||||
}
|
||||
/**
|
||||
* Qualified module handle, with or without rename. `importedName` is the
|
||||
@@ -448,7 +439,7 @@ export interface Reference {
|
||||
readonly toDef: DefId;
|
||||
/** Location of the reference in source. */
|
||||
readonly atRange: Range;
|
||||
readonly kind: 'call' | 'read' | 'write' | 'type-reference' | 'inherits' | 'import-use' | 'macro';
|
||||
readonly kind: 'call' | 'read' | 'write' | 'type-reference' | 'inherits' | 'import-use';
|
||||
readonly confidence: number;
|
||||
readonly evidence: readonly ResolutionEvidence[];
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* E2E for the browser folder-upload flow (replaces the removed server-side
|
||||
* directory picker). Mocks the backend so no live gitnexus server is needed.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = 'http://localhost:4747';
|
||||
|
||||
let fixtureDir: string;
|
||||
|
||||
test.beforeAll(() => {
|
||||
// A tiny "repo" folder; Playwright sets webkitRelativePath = <folder>/<file>.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-upload-e2e-'));
|
||||
fixtureDir = path.join(root, 'myrepo');
|
||||
fs.mkdirSync(path.join(fixtureDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixtureDir, 'README.md'), '# hi\n');
|
||||
fs.writeFileSync(path.join(fixtureDir, 'src', 'index.ts'), 'export const x = 1;\n');
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] }));
|
||||
await page.route(`${BACKEND_URL}/api/info`, (route) =>
|
||||
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
|
||||
);
|
||||
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body: ':ok\n\n',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('uploading a folder posts a multipart upload and starts analysis', async ({ page }) => {
|
||||
let uploadContentType = '';
|
||||
await page.route(`${BACKEND_URL}/api/analyze/upload`, async (route) => {
|
||||
uploadContentType = route.request().headers()['content-type'] ?? '';
|
||||
await route.fulfill({ json: { jobId: 'job-e2e', status: 'analyzing' } });
|
||||
});
|
||||
// SSE progress → immediately complete.
|
||||
await page.route(`${BACKEND_URL}/api/analyze/job-e2e/progress`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
body: 'event: complete\ndata: {"repoName":"myrepo"}\n\n',
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
|
||||
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
|
||||
|
||||
// Select the fixture folder via the hidden webkitdirectory input.
|
||||
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
|
||||
|
||||
// The upload endpoint should be hit with a multipart body, and the UI should
|
||||
// leave the input phase (upload button no longer shown).
|
||||
await expect.poll(() => uploadContentType).toContain('multipart/form-data');
|
||||
await expect(page.locator('[data-testid="upload-folder"]')).toBeHidden({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('switching modes mid-upload aborts it and never shows progress', async ({ page }) => {
|
||||
// Hold the upload response until the test releases it, so the mode switch
|
||||
// happens while the POST is in flight (the review 4470339833 repro).
|
||||
let releaseUpload!: () => void;
|
||||
const uploadGate = new Promise<void>((res) => (releaseUpload = res));
|
||||
let uploadAborted = false;
|
||||
let progressOpened = false;
|
||||
|
||||
// The client-side AbortController kills the POST at mode-switch time; that
|
||||
// surfaces as a failed request (net::ERR_ABORTED), not as a response.
|
||||
page.on('requestfailed', (req) => {
|
||||
if (req.url().includes('/api/analyze/upload') && /ABORTED/.test(req.failure()?.errorText ?? ''))
|
||||
uploadAborted = true;
|
||||
});
|
||||
await page.route(`${BACKEND_URL}/api/analyze/upload`, async (route) => {
|
||||
await uploadGate;
|
||||
await route.fulfill({ json: { jobId: 'job-stale', status: 'analyzing' } }).catch(() => {}); // the request may already be gone — that's the point
|
||||
});
|
||||
await page.route(`${BACKEND_URL}/api/analyze/job-stale/progress`, (route) => {
|
||||
progressOpened = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
body: 'event: complete\ndata: {"repoName":"myrepo"}\n\n',
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
|
||||
await expect(page.locator('[data-testid="upload-progress"]')).toBeVisible();
|
||||
|
||||
// Switch back to GitHub while the upload POST is still pending, then let
|
||||
// the (now-stale) route handler finish.
|
||||
await page.getByRole('tab', { name: 'GitHub URL' }).click();
|
||||
await expect.poll(() => uploadAborted, { timeout: 10_000 }).toBe(true);
|
||||
releaseUpload();
|
||||
|
||||
// The GitHub form stays clean (no error, immediately usable), and no SSE
|
||||
// progress stream is ever opened by the stale upload.
|
||||
await expect(page.getByPlaceholder('https://github.com/owner/repo')).toBeEditable();
|
||||
await expect(page.locator('[data-testid="upload-progress"]')).toBeHidden();
|
||||
expect(progressOpened).toBe(false);
|
||||
});
|
||||
@@ -218,8 +218,8 @@ test.describe('Flow 3: Analyze form', () => {
|
||||
// Switch to Local Folder tab
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
|
||||
// Upload-a-folder button should be visible (browser folder upload)
|
||||
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
|
||||
// Browse button should be visible
|
||||
await expect(page.getByText('Browse for folder')).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('local-folder-tab.png') });
|
||||
});
|
||||
|
||||
|
||||
Generated
+463
-401
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -19,16 +19,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.49",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.30",
|
||||
"@langchain/langgraph": "^1.4.1",
|
||||
"@langchain/ollama": "^1.2.7",
|
||||
"@langchain/openai": "^1.5.0",
|
||||
"@langchain/langgraph": "^1.3.2",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"dompurify": "^3.4.3",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
@@ -36,22 +36,22 @@
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"langchain": "^1.3.5",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.40.4",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.3",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^4.4.3"
|
||||
@@ -67,13 +67,13 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.12",
|
||||
"@vercel/node": "^5.8.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.0.16",
|
||||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5",
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
|
||||
+14
-29
@@ -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 { buildGraphFromConnectResult } from './lib/apply-connect-result';
|
||||
import { createKnowledgeGraph } from './core/graph/graph';
|
||||
import {
|
||||
connectToServer,
|
||||
fetchRepos,
|
||||
@@ -21,7 +21,6 @@ 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';
|
||||
|
||||
@@ -31,8 +30,6 @@ const AppContent = () => {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
setGraph,
|
||||
setGraphMode,
|
||||
setChatOnlyNodeCount,
|
||||
setProgress,
|
||||
setProjectName,
|
||||
progress,
|
||||
@@ -69,14 +66,15 @@ const AppContent = () => {
|
||||
setProjectName(projectName);
|
||||
setCurrentRepo(projectName);
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
// Persist the active project in the URL for bookmarkability and F5 refresh resilience
|
||||
const urlObj = new URL(window.location.href);
|
||||
@@ -86,11 +84,10 @@ const AppContent = () => {
|
||||
// Transition directly to exploring view
|
||||
setViewMode('exploring');
|
||||
|
||||
// Initialize agent with backend queries, then start embeddings. Pass the
|
||||
// chat-only flag so the agent's prompt matches the loaded/skipped graph (#2178).
|
||||
// Initialize agent with backend queries, then start embeddings
|
||||
try {
|
||||
if (getActiveProviderConfig()) {
|
||||
await initializeAgent(projectName, { chatOnly: result.graphSkipped });
|
||||
await initializeAgent(projectName);
|
||||
}
|
||||
startEmbeddingsWithFallback();
|
||||
} catch (err) {
|
||||
@@ -100,8 +97,6 @@ const AppContent = () => {
|
||||
[
|
||||
setViewMode,
|
||||
setGraph,
|
||||
setGraphMode,
|
||||
setChatOnlyNodeCount,
|
||||
setProjectName,
|
||||
setCurrentRepo,
|
||||
initializeAgent,
|
||||
@@ -121,9 +116,6 @@ 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;
|
||||
@@ -170,19 +162,15 @@ const AppContent = () => {
|
||||
},
|
||||
undefined,
|
||||
projectParam || undefined,
|
||||
{ awaitAnalysis: true, skipGraph: skipGraphParam }, // hold-queue + chat-only control (#2178)
|
||||
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
|
||||
);
|
||||
};
|
||||
|
||||
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));
|
||||
@@ -273,9 +261,6 @@ 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));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* The "empty state" card rendered inside DropZone's Crossfade when the server
|
||||
* is connected but zero repos are indexed. Replaces the generic error message
|
||||
* with a first-class repository URL input flow.
|
||||
* with a first-class GitHub URL input flow.
|
||||
*
|
||||
* Rendering context:
|
||||
* DropZone (Crossfade, phase="analyze")
|
||||
@@ -15,7 +15,7 @@
|
||||
* the app to the graph explorer.
|
||||
*/
|
||||
|
||||
import { Sparkles, GitBranch } from '@/lib/lucide-icons';
|
||||
import { Sparkles, Github } from '@/lib/lucide-icons';
|
||||
import { RepoAnalyzer } from './RepoAnalyzer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -46,7 +46,7 @@ export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
|
||||
|
||||
{/* Icon */}
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/20 to-accent-dim/10 shadow-glow-soft">
|
||||
<GitBranch className="h-7 w-7 text-accent" />
|
||||
<Github className="h-7 w-7 text-accent" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
|
||||
@@ -206,10 +206,6 @@ 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,8 +27,6 @@ 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;
|
||||
@@ -57,9 +55,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
animatedNodes,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
graphMode,
|
||||
chatOnlyNodeCount,
|
||||
loadGraphAnyway,
|
||||
} = useAppState();
|
||||
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
|
||||
|
||||
@@ -198,10 +193,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
|
||||
// Update Sigma graph when KnowledgeGraph changes
|
||||
useEffect(() => {
|
||||
// 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;
|
||||
if (!graph) return;
|
||||
|
||||
let sigmaGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>;
|
||||
|
||||
@@ -226,7 +218,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
}
|
||||
|
||||
setSigmaGraph(sigmaGraph);
|
||||
}, [graph, graphMode, nodeById, setSigmaGraph, graphViewMode]);
|
||||
}, [graph, nodeById, setSigmaGraph, graphViewMode]);
|
||||
|
||||
// Update node visibility when filters change
|
||||
useEffect(() => {
|
||||
@@ -264,37 +256,6 @@ 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 */}
|
||||
@@ -363,32 +324,6 @@ 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,7 +27,6 @@ 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> = {
|
||||
@@ -59,11 +58,10 @@ export const Header = ({
|
||||
onAnalyzeComplete,
|
||||
onReposChanged,
|
||||
}: HeaderProps) => {
|
||||
const { t } = useTranslation(['common', 'header', 'errors']);
|
||||
const { t } = useTranslation(['common', 'header']);
|
||||
const {
|
||||
projectName,
|
||||
graph,
|
||||
graphMode,
|
||||
openChatPanel,
|
||||
isRightPanelOpen,
|
||||
rightPanelTab,
|
||||
@@ -74,7 +72,6 @@ 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);
|
||||
@@ -308,7 +305,6 @@ export const Header = ({
|
||||
setReanalyzeProgress(null);
|
||||
reanalyzeSseRef.current = null;
|
||||
}
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteRepo(repo.name);
|
||||
const updated = await fetchRepos();
|
||||
@@ -321,11 +317,7 @@ 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"
|
||||
@@ -338,13 +330,6 @@ 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">
|
||||
@@ -468,9 +453,8 @@ export const Header = ({
|
||||
<span className="hidden sm:inline">✨</span>
|
||||
</a>
|
||||
|
||||
{/* 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' && (
|
||||
{/* Stats */}
|
||||
{graph && (
|
||||
<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>
|
||||
|
||||
@@ -10,36 +10,28 @@ import { useState, useRef, useEffect, useId } from 'react';
|
||||
import {
|
||||
Github,
|
||||
Gitlab,
|
||||
AzureDevops,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Check,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
Key,
|
||||
} from '@/lib/lucide-icons';
|
||||
import {
|
||||
startAnalyze,
|
||||
cancelAnalyze,
|
||||
streamAnalyzeProgress,
|
||||
uploadFolder,
|
||||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { AnalyzeProgress } from './AnalyzeProgress';
|
||||
import { filterRepoFiles } from '@/lib/upload-filter';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type InputMode = 'github' | 'gitlab' | 'azure' | 'local';
|
||||
type InputMode = 'github' | 'gitlab' | 'local';
|
||||
|
||||
const GITHUB_RE = /^https?:\/\/(www\.)?github\.com\/[^/\s]+\/[^/\s]+/i;
|
||||
const GITLAB_RE = /^https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+(\/.*)?$/i;
|
||||
// One-or-more path segments before `/_git/`, so the legacy single-project
|
||||
// cloud form (myorg.visualstudio.com/project/_git/repo) is accepted too —
|
||||
// the backend already supports it (isAzureDevOpsUrl / extractRepoName).
|
||||
const AZURE_RE = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+_git\/[^/\s]+/i;
|
||||
const IS_WINDOWS = navigator.userAgent.toLowerCase().includes('win');
|
||||
|
||||
function isValidGithubUrl(value: string): boolean {
|
||||
@@ -50,10 +42,6 @@ function isValidGitlabUrl(value: string): boolean {
|
||||
return GITLAB_RE.test(value.trim());
|
||||
}
|
||||
|
||||
function isValidAzureUrl(value: string): boolean {
|
||||
return AZURE_RE.test(value.trim());
|
||||
}
|
||||
|
||||
// ── Mode tabs ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
|
||||
@@ -91,19 +79,6 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
|
||||
<Gitlab className="h-3 w-3" />
|
||||
{t('repoAnalyzer.gitlabUrl')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'azure'}
|
||||
onClick={() => onChange('azure')}
|
||||
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
mode === 'azure'
|
||||
? 'bg-accent text-white shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
} `}
|
||||
>
|
||||
<AzureDevops className="h-3 w-3" />
|
||||
{t('repoAnalyzer.azureDevOpsUrl')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'local'}
|
||||
@@ -190,15 +165,10 @@ export interface RepoAnalyzerProps {
|
||||
export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProps) => {
|
||||
const { t } = useTranslation(['common', 'errors', 'onboarding']);
|
||||
const inputId = useId();
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const [mode, setMode] = useState<InputMode>('github');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadSummary, setUploadSummary] = useState<{ count: number; dropped: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [gitlabUrl, setGitlabUrl] = useState('');
|
||||
const [azureUrl, setAzureUrl] = useState('');
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
const [phase, setPhase] = useState<InternalPhase>('input');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
@@ -211,84 +181,35 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
|
||||
const jobIdRef = useRef<string | null>(null);
|
||||
const sseControllerRef = useRef<AbortController | null>(null);
|
||||
// Owns the in-flight analyze/upload request. The controller doubles as the
|
||||
// staleness token: each request captures its own controller in a closure and
|
||||
// bails after the await when that controller was aborted, so a resolution
|
||||
// arriving after a mode switch / cancel / unmount can never drive state.
|
||||
const requestControllerRef = useRef<AbortController | null>(null);
|
||||
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
sseControllerRef.current?.abort();
|
||||
requestControllerRef.current?.abort();
|
||||
if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Abort any in-flight analyze/upload request so its settlement can't drive
|
||||
// state. Aborting is load-bearing: once a mode switch resets `uploading`,
|
||||
// the `uploading || isLoading` re-entry guard no longer covers the stale
|
||||
// request — only its aborted signal does.
|
||||
const invalidateRequest = (): void => {
|
||||
requestControllerRef.current?.abort();
|
||||
requestControllerRef.current = null;
|
||||
};
|
||||
|
||||
// Invalidate the previous request and hand the caller a fresh controller.
|
||||
const renewRequestController = (): AbortController => {
|
||||
invalidateRequest();
|
||||
const controller = new AbortController();
|
||||
requestControllerRef.current = controller;
|
||||
return controller;
|
||||
};
|
||||
|
||||
// An upload that resolved after invalidation has still created a server-side
|
||||
// job; cancel it so the single analyze slot isn't held for the job's full
|
||||
// duration. Upload-path only: every upload owns a fresh job (the server
|
||||
// stages each upload into a unique dir, never dedup-aliasing), whereas URL
|
||||
// analyzes dedup-alias by repo — the returned jobId may belong to a job
|
||||
// another session (or this user's own resubmit) is actively watching, so
|
||||
// cancelling on that path could kill a live analysis. A stale URL job is
|
||||
// left to finish: a same-URL resubmit re-attaches to it via dedup, and the
|
||||
// server's job timeout/TTL sweep bounds the slot occupancy.
|
||||
const cancelStaleUploadJob = (jobId: string): void => {
|
||||
void cancelAnalyze(jobId).catch(() => {});
|
||||
};
|
||||
|
||||
const handleModeChange = (m: InputMode) => {
|
||||
// ModeTabs fires onChange on every click, including the already-active
|
||||
// tab — never abort the user's own in-flight request for a no-op click.
|
||||
if (m === mode) return;
|
||||
invalidateRequest();
|
||||
setMode(m);
|
||||
setGithubUrl('');
|
||||
setGithubToken('');
|
||||
setGitlabUrl('');
|
||||
setAzureUrl('');
|
||||
setLocalPath('');
|
||||
setValidationError(null);
|
||||
setUploadSummary(null);
|
||||
setUploading(false);
|
||||
// An aborted request no longer resolves to move `phase` off 'starting';
|
||||
// reset so the new mode's form is immediately usable (also clears a stale
|
||||
// 'error' phase). Only reachable while showInput is true.
|
||||
setPhase('input');
|
||||
};
|
||||
|
||||
// Local-folder mode uploads the selected folder's files (the browser never
|
||||
// exposes an absolute path, so the old typed-path/browse approach couldn't
|
||||
// work — see handleFolderUpload). A typed server path is also still accepted.
|
||||
// Use the browser's native directory picker (webkitdirectory doesn't give paths,
|
||||
// so we use a text input + a "Browse" button that opens a standard file input
|
||||
// to let users pick files from the folder — the path is typed manually since
|
||||
// browsers don't expose absolute paths for security reasons).
|
||||
// For local paths, the user types or pastes the absolute path.
|
||||
|
||||
const canSubmit =
|
||||
mode === 'github'
|
||||
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
|
||||
: mode === 'gitlab'
|
||||
? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error')
|
||||
: mode === 'azure'
|
||||
? isValidAzureUrl(azureUrl) && (phase === 'input' || phase === 'error')
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
|
||||
@@ -299,10 +220,6 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
setValidationError('Please enter a valid GitLab repository URL.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'azure' && !isValidAzureUrl(azureUrl)) {
|
||||
setValidationError(t('errors:invalidAzureDevOpsUrl'));
|
||||
return;
|
||||
}
|
||||
if (mode === 'local' && localPath.trim().length < 2) {
|
||||
setValidationError(t('errors:missingFolderPath'));
|
||||
return;
|
||||
@@ -311,114 +228,46 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
setValidationError(null);
|
||||
setPhase('starting');
|
||||
|
||||
// Staleness guard only (no wire abort): the POST is short-lived and
|
||||
// self-terminates, but its resolution must not drive state after a mode
|
||||
// switch / cancel / unmount invalidated this request.
|
||||
const controller = renewRequestController();
|
||||
try {
|
||||
const request =
|
||||
mode === 'github'
|
||||
? {
|
||||
url: githubUrl.trim(),
|
||||
...(githubToken.trim() ? { token: githubToken.trim() } : {}),
|
||||
}
|
||||
? { url: githubUrl.trim() }
|
||||
: mode === 'gitlab'
|
||||
? { url: gitlabUrl.trim() }
|
||||
: mode === 'azure'
|
||||
? { url: azureUrl.trim() }
|
||||
: { path: localPath.trim() };
|
||||
: { path: localPath.trim() };
|
||||
const { jobId } = await startAnalyze(request);
|
||||
// Stale resolution: return without cancelling — URL jobIds may be
|
||||
// dedup-aliased to a job another session owns (see cancelStaleUploadJob).
|
||||
if (controller.signal.aborted) return;
|
||||
jobIdRef.current = jobId;
|
||||
setPhase('analyzing');
|
||||
|
||||
const nameSource =
|
||||
mode === 'github'
|
||||
? githubUrl.trim()
|
||||
: mode === 'gitlab'
|
||||
? gitlabUrl.trim()
|
||||
: mode === 'azure'
|
||||
? azureUrl.trim()
|
||||
: localPath.trim();
|
||||
trackJob(jobId, nameSource);
|
||||
: localPath.trim();
|
||||
const controller = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setProgress(p),
|
||||
(data) => {
|
||||
const name =
|
||||
data.repoName ??
|
||||
nameSource.split(/[/\\]/).filter(Boolean).at(-1) ??
|
||||
t('onboarding:repoAnalyzer.defaultRepoName');
|
||||
setCompletedRepoName(name);
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
completeTimerRef.current = setTimeout(() => {
|
||||
completeTimerRef.current = null;
|
||||
onComplete(name);
|
||||
}, 1200);
|
||||
},
|
||||
(errMsg) => {
|
||||
setValidationError(errMsg || t('errors:analysisFailed'));
|
||||
setPhase('error');
|
||||
},
|
||||
);
|
||||
sseControllerRef.current = controller;
|
||||
} catch (err) {
|
||||
// Unmount aborts the controller, so this also covers the unmounted case.
|
||||
if (controller.signal.aborted) return;
|
||||
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Drive an already-created analysis job through the SSE progress stream to
|
||||
// completion. Shared by the path/URL analyze flow and the folder-upload flow.
|
||||
const trackJob = (jobId: string, fallbackNameSource: string | null) => {
|
||||
// Callers reach here only with a live (non-aborted) request controller, so
|
||||
// the component is mounted — unmount aborts the controller.
|
||||
jobIdRef.current = jobId;
|
||||
setPhase('analyzing');
|
||||
const controller = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setProgress(p),
|
||||
(data) => {
|
||||
const name =
|
||||
data.repoName ??
|
||||
(fallbackNameSource
|
||||
? fallbackNameSource.split(/[/\\]/).filter(Boolean).at(-1)
|
||||
: undefined) ??
|
||||
t('onboarding:repoAnalyzer.defaultRepoName');
|
||||
setCompletedRepoName(name);
|
||||
setGithubToken('');
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
completeTimerRef.current = setTimeout(() => {
|
||||
completeTimerRef.current = null;
|
||||
onComplete(name);
|
||||
}, 1200);
|
||||
},
|
||||
(errMsg) => {
|
||||
setValidationError(errMsg || t('errors:analysisFailed'));
|
||||
setPhase('error');
|
||||
},
|
||||
);
|
||||
sseControllerRef.current = controller;
|
||||
};
|
||||
|
||||
// Upload a browser-selected folder (webkitdirectory) and start analysis. The
|
||||
// upload endpoint returns a jobId, which then joins the normal SSE flow.
|
||||
const handleFolderUpload = async (fileList: FileList) => {
|
||||
if (uploading || isLoading) return; // guard against a concurrent upload
|
||||
const { files, manifest, droppedCount } = filterRepoFiles(fileList);
|
||||
if (files.length === 0) {
|
||||
setValidationError(t('onboarding:repoAnalyzer.upload.empty'));
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
setUploadSummary({ count: files.length, dropped: droppedCount });
|
||||
setUploading(true);
|
||||
setPhase('starting');
|
||||
// The selected folder's name (manifest entries are `<folder>/<rest>`) is a
|
||||
// sensible fallback if the server's complete event omits repoName.
|
||||
const folderName = manifest[0]?.split('/')[0] ?? null;
|
||||
const controller = renewRequestController();
|
||||
try {
|
||||
const { jobId } = await uploadFolder(files, manifest, controller.signal);
|
||||
if (controller.signal.aborted) {
|
||||
// The abort raced the response: the server already created the job.
|
||||
// (Unmount aborts the controller, so this also covers unmounted.)
|
||||
cancelStaleUploadJob(jobId);
|
||||
return;
|
||||
}
|
||||
setUploading(false);
|
||||
trackJob(jobId, folderName);
|
||||
} catch (err) {
|
||||
// An abort surfaces in two shapes — BackendError('Request aborted')
|
||||
// when it lands during fetch, raw AbortError when it lands during the
|
||||
// response-body read — so branch on the closure controller's signal,
|
||||
// never on the error identity. In the second shape the server may have
|
||||
// already launched a job whose id we never learn; that orphan is bounded
|
||||
// by the server's job timeout and terminal-job TTL sweep.
|
||||
if (controller.signal.aborted) return;
|
||||
setUploading(false);
|
||||
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
|
||||
setPhase('error');
|
||||
}
|
||||
@@ -427,21 +276,14 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
const handleCancel = async () => {
|
||||
sseControllerRef.current?.abort();
|
||||
sseControllerRef.current = null;
|
||||
// Defensive: no UI path can reach handleCancel while a request is in
|
||||
// flight (the cancel affordance renders only at phase === 'analyzing'),
|
||||
// but invalidate it anyway so the guard topology has no holes.
|
||||
invalidateRequest();
|
||||
if (jobIdRef.current) {
|
||||
try {
|
||||
await cancelAnalyze(jobIdRef.current);
|
||||
} catch {}
|
||||
jobIdRef.current = null;
|
||||
}
|
||||
setGithubToken('');
|
||||
setPhase('input');
|
||||
setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') });
|
||||
setUploading(false);
|
||||
setUploadSummary(null);
|
||||
};
|
||||
|
||||
const isLoading = phase === 'starting';
|
||||
@@ -502,39 +344,6 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional GitHub Personal Access Token for private repos */}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<label
|
||||
htmlFor={`${inputId}-token`}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
{t('onboarding:repoAnalyzer.githubTokenLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border-default bg-void px-4 py-3 transition-all duration-200 focus-within:border-accent/40">
|
||||
<Key className="h-4 w-4 shrink-0 text-text-muted" />
|
||||
<input
|
||||
id={`${inputId}-token`}
|
||||
type="password"
|
||||
value={githubToken}
|
||||
onChange={(e) => setGithubToken(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSubmit && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleAnalyze();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder={t('onboarding:repoAnalyzer.githubTokenPlaceholder')}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('onboarding:repoAnalyzer.githubTokenHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -591,61 +400,6 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Azure DevOps URL input */}
|
||||
{showInput && mode === 'azure' && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
{t('onboarding:repoAnalyzer.azureDevOpsRepositoryUrl')}
|
||||
</label>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
|
||||
validationError && phase === 'error'
|
||||
? 'border-red-500/50'
|
||||
: isValidAzureUrl(azureUrl)
|
||||
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
|
||||
: 'border-border-default focus-within:border-accent/40'
|
||||
} `}
|
||||
>
|
||||
<AzureDevops className="h-4 w-4 shrink-0 text-text-muted" />
|
||||
<input
|
||||
id={inputId}
|
||||
type="url"
|
||||
value={azureUrl}
|
||||
onChange={(e) => {
|
||||
setAzureUrl(e.target.value);
|
||||
if (validationError) setValidationError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSubmit && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleAnalyze();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder="http://azuredevops.example.com/Collection/Project/_git/Repo"
|
||||
autoComplete="url"
|
||||
spellCheck={false}
|
||||
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
|
||||
/>
|
||||
{azureUrl.length > 10 && (
|
||||
<div className="shrink-0">
|
||||
{isValidAzureUrl(azureUrl) ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('onboarding:repoAnalyzer.azureDevOpsSupported')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local folder input */}
|
||||
{showInput && mode === 'local' && (
|
||||
<div className="space-y-2">
|
||||
@@ -689,51 +443,35 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* Upload a folder from your computer — no server path or mount needed.
|
||||
The browser can't expose an absolute path, so we upload the files. */}
|
||||
{/* Native folder picker + Browse button — below the input */}
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
// @ts-expect-error -- webkitdirectory is non-standard but widely supported
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
className="hidden"
|
||||
data-testid="folder-upload-input"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFolderUpload(e.target.files);
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const rel = files[0].webkitRelativePath;
|
||||
const folderName = rel.split('/')[0];
|
||||
if (folderName) {
|
||||
setLocalPath(folderName);
|
||||
setValidationError(null);
|
||||
}
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="upload-folder"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{t('onboarding:repoAnalyzer.upload.button')}
|
||||
{t('onboarding:repoAnalyzer.browseForFolder')}
|
||||
</button>
|
||||
{uploading && (
|
||||
<div role="status" aria-busy="true" data-testid="upload-progress" className="space-y-1">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
|
||||
<div className="h-full w-1/3 animate-pulse rounded-full bg-accent" />
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('onboarding:repoAnalyzer.upload.uploading')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{uploadSummary && !uploading && phase !== 'error' && (
|
||||
<p className="text-xs text-text-muted" data-testid="upload-summary">
|
||||
{t('onboarding:repoAnalyzer.upload.selected', {
|
||||
fileCount: uploadSummary.count,
|
||||
dropped: uploadSummary.dropped,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ export const RightPanel = () => {
|
||||
isRightPanelOpen,
|
||||
setRightPanelOpen,
|
||||
graph,
|
||||
graphMode,
|
||||
addCodeReference,
|
||||
// LLM / chat state
|
||||
chatMessages,
|
||||
@@ -284,14 +283,6 @@ 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">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { translateProgressMessage } from '../i18n/progress';
|
||||
|
||||
export const StatusBar = () => {
|
||||
const { graph, graphMode, progress } = useAppState();
|
||||
const { graph, progress } = useAppState();
|
||||
const { t } = useTranslation(['common', 'graph']);
|
||||
|
||||
const nodeCount = graph?.nodes.length ?? 0;
|
||||
@@ -68,9 +68,7 @@ export const StatusBar = () => {
|
||||
|
||||
{/* Right - Stats */}
|
||||
<div className="flex items-center gap-3" data-testid="graph-stats">
|
||||
{/* 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' && (
|
||||
{graph && (
|
||||
<>
|
||||
<span>{t('common:counts.nodes', { count: nodeCount })}</span>
|
||||
<span className="text-border-default">•</span>
|
||||
|
||||
@@ -8,40 +8,6 @@ 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__;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user