Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a1ed9b53e | ||
|
|
cab63b508e | ||
|
|
60752de3e9 | ||
|
|
50cda61a43 | ||
|
|
6bd2d119fc | ||
|
|
129bc84c0d | ||
|
|
0054496323 | ||
|
|
14397dd4aa | ||
|
|
28f3b99822 | ||
|
|
bdb824cfe4 | ||
|
|
10d1e47df3 | ||
|
|
bde340a5b4 | ||
|
|
1150eea98f | ||
|
|
ae1ec82f5f | ||
|
|
5a590f052f | ||
|
|
df56d34f33 | ||
|
|
04aeb1f4bf | ||
|
|
792cd96d37 | ||
|
|
351edca0ca | ||
|
|
15e141f21b | ||
|
|
0b2c88518e | ||
|
|
8bb2fe9128 | ||
|
|
6424d8b09c | ||
|
|
5bf8a17cd5 | ||
|
|
e26002c37a | ||
|
|
31a2b19416 | ||
|
|
2870aa6248 | ||
|
|
3d30b94c46 | ||
|
|
7f0ab87782 | ||
|
|
ae5ec94fd9 | ||
|
|
7eaeb0a0c4 | ||
|
|
36ca096e75 | ||
|
|
292f26ece3 | ||
|
|
4f9d595c73 | ||
|
|
e46651e42c | ||
|
|
43e6a3f688 | ||
|
|
581943bbe5 | ||
|
|
1cf65b339c | ||
|
|
d0452d26a1 | ||
|
|
247ea431de | ||
|
|
4682a477d8 | ||
|
|
bd90d5bf90 | ||
|
|
6ab3f64443 | ||
|
|
cef63dd044 | ||
|
|
1716bf7c1e | ||
|
|
f1151660b9 | ||
|
|
288b96f3e5 | ||
|
|
3a4247ec36 | ||
|
|
774cd4d568 | ||
|
|
4de4d205dd | ||
|
|
f2c9e69792 | ||
|
|
689e6ef1f8 | ||
|
|
2cf7e7fa88 | ||
|
|
1ca4f15267 | ||
|
|
df5ce1f49b | ||
|
|
3963c497dd | ||
|
|
4fc2ffa5d0 | ||
|
|
f0c292f9e7 | ||
|
|
2dc0cc6398 | ||
|
|
2b4d6e742b | ||
|
|
baca749e0b | ||
|
|
9a40af3d79 | ||
|
|
95f87fc12a | ||
|
|
3b43eb8b47 | ||
|
|
bb3642ad5f | ||
|
|
782f70cc07 | ||
|
|
22304cd4a4 | ||
|
|
89b02286ad | ||
|
|
281ce2600c | ||
|
|
93e04b46d6 | ||
|
|
54ea21a0bc | ||
|
|
1812fcd165 | ||
|
|
4d010ff3f0 | ||
|
|
3b195ec100 | ||
|
|
bd59fa95ce | ||
|
|
c4ee911463 | ||
|
|
7cbc544299 | ||
|
|
938111ad45 | ||
|
|
2aa78a60a7 | ||
|
|
560291ad6e | ||
|
|
083aedbc41 | ||
|
|
9f3bcee7fc | ||
|
|
e316222cd5 | ||
|
|
16014657c8 | ||
|
|
c11f50a06e | ||
|
|
ff5cb0c0ff | ||
|
|
9ed9aa3489 | ||
|
|
2877cffb27 | ||
|
|
64d6a13439 | ||
|
|
3fef36572a | ||
|
|
ad36a86ab0 | ||
|
|
7982eaea12 | ||
|
|
42ac44b838 | ||
|
|
617ca0d05e | ||
|
|
81b46518b2 | ||
|
|
1bb5b31745 | ||
|
|
76684b8d4b | ||
|
|
8a9b13fc3b | ||
|
|
5cb00119e8 | ||
|
|
a987c2c0e6 |
@@ -11,7 +11,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.3.3",
|
||||
"version": "1.6.7",
|
||||
"source": "./gitnexus-claude-plugin",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
1. query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
4. cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
@@ -28,11 +28,11 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
@@ -40,7 +40,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Error message | `query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
@@ -48,24 +48,24 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
**query** — find code related to error:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
**context** — full context for a suspect:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
**cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
@@ -75,11 +75,11 @@ RETURN [n IN nodes(path) | n.name] AS chain
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
1. query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
2. context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
|
||||
@@ -18,8 +18,8 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
3. query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
@@ -29,9 +29,9 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
@@ -47,18 +47,18 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
**query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
**context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
@@ -68,10 +68,10 @@ gitnexus_context({name: "validateUser"})
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
2. query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
3. context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
|
||||
@@ -36,9 +36,52 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `check` | Check graph invariants such as circular imports |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
|
||||
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
|
||||
|
||||
### Paginating `list_repos`
|
||||
|
||||
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"repositories": [
|
||||
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
|
||||
],
|
||||
"pagination": {
|
||||
"total": 437,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"returned": 50,
|
||||
"hasMore": true,
|
||||
"nextOffset": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
|
||||
|
||||
```text
|
||||
list_repos {} → repos 1–50, nextOffset 50, hasMore true
|
||||
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
|
||||
…
|
||||
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
|
||||
```
|
||||
|
||||
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
|
||||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
|
||||
|
||||
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
|
||||
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
|
||||
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
|
||||
|
||||
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
|
||||
|
||||
## Resources Reference
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ description: "Use when the user wants to know what will break if they change som
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
1. impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
3. detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
@@ -28,11 +28,11 @@ description: "Use when the user wants to know what will break if they change som
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
@@ -55,10 +55,10 @@ description: "Use when the user wants to know what will break if they change som
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
**impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
gitnexus_impact({
|
||||
impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
@@ -73,10 +73,10 @@ gitnexus_impact({
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
**detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
@@ -86,7 +86,7 @@ gitnexus_detect_changes({scope: "staged"})
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
1. impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
@@ -32,10 +32,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] detect_changes to map changes to affected execution flows
|
||||
- [ ] impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
@@ -63,20 +63,20 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
**detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
**impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
@@ -86,20 +86,20 @@ gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
**impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
**context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
@@ -112,20 +112,20 @@ gitnexus_context({name: "validatePayment"})
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
2. detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
3. impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
4. impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
5. context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
|
||||
@@ -16,9 +16,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
1. impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. query({query: "X"}) → Find execution flows involving X
|
||||
3. context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
@@ -29,65 +29,65 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
**rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
**impact** — map all dependents first:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
**detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
**cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
@@ -98,24 +98,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Many callers (>5) | Use rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| String/dynamic refs | query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
4. detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: gitnexus-taint-analysis
|
||||
description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
|
||||
---
|
||||
|
||||
# CFG & Taint Analysis with GitNexus
|
||||
|
||||
Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
|
||||
graphs, reaching definitions, and intra- + inter-procedural taint. Read this
|
||||
before touching `gitnexus/src/core/ingestion/cfg/**` or
|
||||
`gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
|
||||
|
||||
## When to Use
|
||||
|
||||
- "How does the taint engine work / why is this flow (not) reported?"
|
||||
- Adding a source, sink, or sanitizer to the model.
|
||||
- Extending or reviewing the CFG / reaching-defs / taint / summary code.
|
||||
- Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
|
||||
- Debugging a false positive or false negative in `--pdg` output.
|
||||
|
||||
## The layered substrate (build order)
|
||||
|
||||
Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
|
||||
and a default `analyze` run is **byte-identical** (the golden parity gate is the
|
||||
hard floor for every change here).
|
||||
|
||||
```
|
||||
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
|
||||
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
|
||||
L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
|
||||
L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
|
||||
```
|
||||
|
||||
- **Worker-built, main-thread-solved.** The parse worker builds each function's
|
||||
CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
|
||||
(plain, structured-clone-safe data — never AST nodes). The main thread runs
|
||||
the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
|
||||
OOM).
|
||||
- **In-phase emit (KTD1).** L1–L4-harvest all run INSIDE the scope-resolution
|
||||
pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
|
||||
because the disk-backed ParsedFile store is cleared when that phase ends — a
|
||||
standalone post-`mro` phase would read empty data. The cross-function fixpoint
|
||||
(L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
|
||||
AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
|
||||
small plain summary data threaded out via `ScopeResolutionOutput`.
|
||||
- **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
|
||||
`harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
|
||||
(no graph, no I/O, no logger; sorted outputs). Snapshot tests and
|
||||
content-derived edge ids depend on it.
|
||||
|
||||
## Intra-procedural taint (L3)
|
||||
|
||||
Forward reachability over RD facts from matched **sources** to matched **sinks**,
|
||||
killed by **sanitizers**. Key design points worth internalizing:
|
||||
|
||||
- **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
|
||||
`exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
|
||||
call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
|
||||
precise.
|
||||
- **Kind-set sanitizer model.** A taint carries a set of *neutralized*
|
||||
`SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
|
||||
suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
|
||||
kill would be a suppressed live injection (the forbidden FN direction).
|
||||
`path.basename(t)` neutralizes path-traversal only, not command-injection.
|
||||
- **Statement-level finding identity.** NOT block-pair (block conflation drops
|
||||
distinct findings; `exec(req.body, req.query)` is two findings).
|
||||
- Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
|
||||
`reason` column via the shared versioned codec (`taint/path-codec.ts`).
|
||||
|
||||
## Interprocedural taint (L4) — the functional/summary method
|
||||
|
||||
The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
|
||||
Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
|
||||
reduced to a compact **summary**, and summaries are composed over the already-
|
||||
resolved `CALLS` graph.
|
||||
|
||||
**Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
|
||||
|
||||
| Edge | Meaning | Analogue |
|
||||
|------|---------|----------|
|
||||
| `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
|
||||
| `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
|
||||
| `param→sink` | a param reaches a modelled sink | partial/triggered sink |
|
||||
| `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
|
||||
| `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
|
||||
| `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
|
||||
|
||||
**The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
|
||||
parameter, source)`. Seed from `source→callee-arg`, propagate via
|
||||
`param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
|
||||
|
||||
- **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
|
||||
lattice (`fn × param × source`), so the worklist converges — a recursive call
|
||||
just re-proposes an already-visited entry. SCC condensation would only refine
|
||||
processing order; correctness/termination don't require it.
|
||||
- **Source-discriminated state (load-bearing).** Key the state by the SOURCE
|
||||
too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
|
||||
tainted by source A is marked visited and a later flow from source B is dropped
|
||||
before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
|
||||
- **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
|
||||
CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
|
||||
site) is fragile; the callee identity is exact and context-insensitivity
|
||||
taints the callee's param identically at every call site.
|
||||
- Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
|
||||
in `reason` via the same codec; confidence < the intra-procedural 1.0.
|
||||
|
||||
**Context-insensitivity** is the accepted trade-off at this tier: one summary
|
||||
per function, return/call-site merging accepted (security-conservative). Expect
|
||||
some FP from merging; the bigger FN sources are unmodeled features (below).
|
||||
|
||||
## Known false-negative classes (documented, deferred)
|
||||
|
||||
The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
|
||||
into a callback is dropped without per-library models (true of CodeQL's JS libs
|
||||
too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
|
||||
field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
|
||||
flows, promise/async-await threading, and **destructured/rest params before a
|
||||
tainted simple param** (the summary port index is the binding ordinal, not the
|
||||
formal arg position — needs a formal-param index threaded from the worker
|
||||
`BindingEntry`). The interprocedural join is also context-insensitive: when one
|
||||
caller invokes two distinct **same-named callees**, a flow into one
|
||||
over-attributes to both (sound — over-report, never a missed flow). Absence of a
|
||||
finding is NOT proof of safety.
|
||||
|
||||
## GitNexus-specific gotchas
|
||||
|
||||
- **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
|
||||
`Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
|
||||
have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
|
||||
ambiguous → drop (the summary driver counts `unresolved`) rather than
|
||||
cross-wire.
|
||||
- **No rel-property index (S1).** Kuzu has no secondary index on relationship
|
||||
properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
|
||||
TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
|
||||
traversed live; `explain` reads it source-anchored + LIMIT-guarded.
|
||||
- **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
|
||||
deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
|
||||
schema (pinned in `security.test.ts`). `explain` enumerates both layers
|
||||
(cross-function findings carry `interprocedural: true`).
|
||||
- **One shared codec.** Both the emit path and `explain` import
|
||||
`taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
|
||||
fork it. New metadata extends the format WITHIN the version when writer +
|
||||
reader ship together.
|
||||
- **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
|
||||
NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
|
||||
Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
|
||||
full writeback. Model content rides `taintModelVersion`.
|
||||
|
||||
## Adding a source / sink / sanitizer
|
||||
|
||||
Edit the language model in `taint/typescript-model.ts` (registered via the
|
||||
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
|
||||
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
|
||||
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
|
||||
finding (or its absence) in `test/unit/taint/` (real-source harness:
|
||||
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
|
||||
`test/integration/cfg/`.
|
||||
|
||||
## Validation checklist for any `--pdg` change
|
||||
|
||||
```
|
||||
1. tsc clean (schema additions are exhaustiveness-checked; watch the
|
||||
api.ts getNodeQuery runtime read-path if a node label is added).
|
||||
2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
|
||||
test/integration/cfg) — verify by ISOLATION, not full-suite exit
|
||||
(known load-flakes). `node scripts/build.js` before worker/integration runs.
|
||||
3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
|
||||
4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
|
||||
5. detect_changes() before commit; impact({direction:'upstream'}) before
|
||||
editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
|
||||
```
|
||||
|
||||
## Prior art (for deeper design questions)
|
||||
|
||||
Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
|
||||
FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
|
||||
propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
|
||||
notation, incl. callback ports); Infer (content-keyed incremental summaries).
|
||||
+16
-67
@@ -22,26 +22,10 @@
|
||||
# --format '{{json .Manifest.Digest}}'
|
||||
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:7c2e711a4f7b02f32d2da16192d5e05aa7c95279be4ce889cff5df316f251c1d
|
||||
|
||||
# Build args. We deliberately set no version defaults here. devcontainer.json
|
||||
# `build.args` is the single source of truth for versions. A standalone
|
||||
# `docker build .devcontainer/` (for example, a CI smoke test) must pass each
|
||||
# version with --build-arg. Without a default, the build fails loudly instead of
|
||||
# silently drifting from the version pinned in devcontainer.json.
|
||||
ARG CLAUDE_CODE_VERSION
|
||||
ARG CODEX_VERSION
|
||||
# Cursor is pinned by version plus a per-arch tarball sha256 hash. The install
|
||||
# step below verifies that hash. All three values live in devcontainer.json
|
||||
# build.args. They follow the same rule as the others: one source of truth, and
|
||||
# no default so the build fails loudly if a value is missing.
|
||||
ARG CURSOR_VERSION
|
||||
ARG CURSOR_SHA256_X64
|
||||
ARG CURSOR_SHA256_ARM64
|
||||
# Bun is installed via the official remote script (bun.sh/install), pinned by
|
||||
# version. UNLIKE Cursor and the npm packages, this install path runs an
|
||||
# UNVERIFIED remote script — there is no tarball-hash check. Chosen explicitly
|
||||
# at request time over the pin-by-sha256 alternative for install-script
|
||||
# simplicity. To harden later, switch to a pinned tarball + per-arch sha256 in
|
||||
# the Cursor style (release artifacts at github.com/oven-sh/bun/releases).
|
||||
# version. Claude Code and Cursor also use official install scripts (no version
|
||||
# to pin). To harden Bun: switch to a pinned tarball + per-arch sha256
|
||||
# (release artifacts at github.com/oven-sh/bun/releases).
|
||||
ARG BUN_VERSION
|
||||
ARG TZ=UTC
|
||||
ARG USERNAME=node
|
||||
@@ -50,10 +34,7 @@ ARG USERNAME=node
|
||||
# read them. We deliberately do not set CLAUDE_CONFIG_DIR here. Its one true
|
||||
# value lives in devcontainer.json `containerEnv`, and the runtime value wins
|
||||
# anyway.
|
||||
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
|
||||
CODEX_VERSION=${CODEX_VERSION} \
|
||||
CURSOR_VERSION=${CURSOR_VERSION} \
|
||||
BUN_VERSION=${BUN_VERSION} \
|
||||
ENV BUN_VERSION=${BUN_VERSION} \
|
||||
BUN_INSTALL=/home/${USERNAME}/.bun \
|
||||
TZ=${TZ} \
|
||||
DEVCONTAINER=true \
|
||||
@@ -86,51 +67,19 @@ RUN mkdir -p \
|
||||
|
||||
USER ${USERNAME}
|
||||
|
||||
# Install Claude Code and the Codex CLI globally, as the `node` user. The base
|
||||
# image sets /usr/local/share/npm-global as the npm-global prefix and makes the
|
||||
# `npm` group writable by `node`. So `npm install -g` works without sudo. Both
|
||||
# versions come from build args. To upgrade, bump them in devcontainer.json and
|
||||
# rebuild.
|
||||
RUN npm install -g \
|
||||
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
|
||||
@openai/codex@${CODEX_VERSION}
|
||||
# Install Claude Code via the official native installer. Downloads the latest
|
||||
# self-contained binary for the running platform and places it at
|
||||
# ~/.local/bin/claude — no Node.js runtime dependency, no version to pin.
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
# Install the Cursor CLI. It is pinned and hash-verified, and we run no remote
|
||||
# script. The cursor.com/install script just detects os/arch, downloads a
|
||||
# versioned tarball from
|
||||
# downloads.cursor.com/lab/<version>/<os>/<arch>/agent-cli-package.tar.gz,
|
||||
# extracts it, and symlinks `agent`/`cursor-agent` into ~/.local/bin. We do that
|
||||
# ourselves against a PINNED version plus a per-arch sha256 hash. So the build
|
||||
# runs no unverified remote code. This matches how we pin the base image and npm
|
||||
# packages by digest (issue #1451). The download is fail-closed: if the hash
|
||||
# does not match, the build aborts.
|
||||
#
|
||||
# To bump: set CURSOR_VERSION and both CURSOR_SHA256_* in devcontainer.json
|
||||
# build.args. Get each arch's hash with:
|
||||
# curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
|
||||
#
|
||||
# TARGETARCH is the per-platform build arg that BuildKit sets automatically. It
|
||||
# must be (re)declared in this stage to be visible. When the build is a
|
||||
# non-BuildKit `docker build`, TARGETARCH is unset, so we fall back to `dpkg
|
||||
# --print-architecture`.
|
||||
ARG TARGETARCH
|
||||
RUN set -eux; \
|
||||
arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \
|
||||
case "$arch" in \
|
||||
amd64) cursor_arch=x64; cursor_sha="${CURSOR_SHA256_X64}";; \
|
||||
arm64) cursor_arch=arm64; cursor_sha="${CURSOR_SHA256_ARM64}";; \
|
||||
*) echo "unsupported architecture for Cursor: $arch" >&2; exit 1;; \
|
||||
esac; \
|
||||
url="https://downloads.cursor.com/lab/${CURSOR_VERSION}/linux/${cursor_arch}/agent-cli-package.tar.gz"; \
|
||||
curl -fSL --retry 3 --max-time 120 -o /tmp/cursor.tgz "$url"; \
|
||||
echo "${cursor_sha} /tmp/cursor.tgz" | sha256sum -c -; \
|
||||
dir="/home/${USERNAME}/.local/share/cursor-agent/versions/${CURSOR_VERSION}"; \
|
||||
install -d "$dir" "/home/${USERNAME}/.local/bin"; \
|
||||
tar --strip-components=1 -xzf /tmp/cursor.tgz -C "$dir"; \
|
||||
test -x "$dir/cursor-agent"; \
|
||||
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/agent"; \
|
||||
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/cursor-agent"; \
|
||||
rm -f /tmp/cursor.tgz
|
||||
# Install the Codex CLI globally via npm. No version pinned — @latest at build
|
||||
# time. (Codex has no native binary installer; npm is the canonical method.)
|
||||
RUN npm install -g @openai/codex
|
||||
|
||||
# Install the Cursor agent CLI via the official install script. Downloads the
|
||||
# latest agent-cli-package for the running platform and places `cursor-agent`
|
||||
# and `agent` into ~/.local/bin — no version or hash to pin.
|
||||
RUN curl -fsSL https://cursor.com/install | bash
|
||||
|
||||
# Install Bun via the official remote installer, pinned by version. The first
|
||||
# positional arg to `bash` is the release tag (`bun-vX.Y.Z`), so a specific
|
||||
|
||||
@@ -310,8 +310,8 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
|
||||
|
||||
- **LadybugDB integration tests may fail in containers** (file-locking, `AGENTS.md` § Testing). Default to `npm run test:unit` inside the container; run integration tests on the host. Tracking issue: documented as a known limitation.
|
||||
- **Single-writer LadybugDB constraint** (`GUARDRAILS.md` § LadybugDB lock). Don't run `gitnexus analyze` on the host and inside the container against the same `.gitnexus/` directory simultaneously — the second writer will get `database busy`.
|
||||
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift grammars build during `gitnexus`'s `postinstall`. To skip them (loses parsing for those three languages), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` in your shell or add it to `remoteEnv` and rebuild.
|
||||
- **`tree-sitter-kotlin` warnings on install** are expected (per `AGENTS.md`). Ignore them.
|
||||
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift/Kotlin are all vendored uniformly: `node-gyp-build` picks a committed GitNexus-built prebuilt `.node` at install time (no compile), and only falls back to compiling from the vendored source during `postinstall` if no prebuild matches the host (then a toolchain is needed). Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` (in your shell or `remoteEnv`, then rebuild) to skip all four; each loses parsing for the affected language(s), and the install still succeeds.
|
||||
- **`tree-sitter-kotlin`/`tree-sitter-swift` warnings on install** only appear when no prebuild matches the platform-arch (per `AGENTS.md`); they are non-fatal — parsing for that language is simply unavailable.
|
||||
- **`.mcp.json` works inside the container**: `npx -y gitnexus@latest mcp` resolves cleanly because npm registry is reachable and the workspace bind mount exposes the same `.mcp.json` the host sees.
|
||||
- **Husky pre-commit fires inside the container** without extra setup. The root `npm install` (run automatically in `postCreateCommand`) installs the hook via `package.json` `prepare`.
|
||||
|
||||
|
||||
@@ -14,16 +14,6 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".",
|
||||
"args": {
|
||||
"CLAUDE_CODE_VERSION": "2.1.156",
|
||||
"CODEX_VERSION": "0.134.0",
|
||||
// Cursor: a pinned version plus one sha256 hash per CPU arch. The
|
||||
// Dockerfile checks the tarball against the hash at build time, so it
|
||||
// never runs a remote install script. Bump all three values together.
|
||||
// Re-hash each arch with:
|
||||
// curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
|
||||
"CURSOR_VERSION": "2026.05.28-a70ca7c",
|
||||
"CURSOR_SHA256_X64": "7f8b6a09393e0b84b288cc6952b292fc98d15775f644cc01b0b9aa4f04b268df",
|
||||
"CURSOR_SHA256_ARM64": "05a0ab361e038729aba25fe7f407531b3e8432912e499d0bffdf1dda0e7833e9",
|
||||
// Bun: pinned by version. Installed by the official bun.sh/install
|
||||
// script, which accepts the release tag as its first positional arg
|
||||
// (`bash -s bun-vX.Y.Z`). UNLIKE Cursor, the install path runs an
|
||||
@@ -324,17 +314,6 @@
|
||||
// dependency explicit instead of silently following the default.
|
||||
"containerEnv": {
|
||||
"CODEX_HOME": "/home/node/.codex",
|
||||
"DISABLE_AUTOUPDATER": "1",
|
||||
// post-create.sh removes `installMethod` from the seeded ~/.claude.json so
|
||||
// the npm-global binary detects its own install method. This is a backup
|
||||
// safeguard for Claude Code issue #17289. The install-checks routine probes
|
||||
// ~/.local/bin/claude just because that directory EXISTS. It does exist
|
||||
// here, because Cursor drops agent and cursor-agent symlinks there. So even
|
||||
// when installMethod is non-native, the routine reports a false "claude
|
||||
// command not found at ~/.local/bin/claude". DISABLE_AUTOUPDATER does NOT
|
||||
// turn that routine off. DISABLE_INSTALLATION_CHECKS is its dedicated kill
|
||||
// switch.
|
||||
"DISABLE_INSTALLATION_CHECKS": "1",
|
||||
"HISTFILE": "/commandhistory/.zsh_history"
|
||||
},
|
||||
|
||||
|
||||
@@ -141,15 +141,12 @@ sync_from_host /host/.codex/config.toml /home/node/.codex/config.toml 644
|
||||
# Seed $HOME/.claude.json from the host, but NOT as a straight copy. That file
|
||||
# mixes two kinds of state. Some is portable account and onboarding state we
|
||||
# want to keep: hasCompletedOnboarding, oauthAccount, userID, projects,
|
||||
# tipsHistory. The rest describes how Claude is installed on the host, and that
|
||||
# part is never valid here. This image installs Claude with `npm install -g`,
|
||||
# but the host's `installMethod` (for example "native") makes Claude look for
|
||||
# ~/.local/bin/claude and fail with
|
||||
# "claude command not found at /home/node/.local/bin/claude". The fix strips the
|
||||
# machine-specific fields and forces hasCompletedOnboarding, while handling a
|
||||
# host file that isn't a JSON object. That logic lives in seed-claude-config.cjs
|
||||
# so it can be unit-tested and prettier-checked
|
||||
# (translate-plugin-registries.test.cjs).
|
||||
# tipsHistory. The rest describes how Claude is installed on the HOST, and that
|
||||
# part is never valid here — for example the host's `installMethod` value only
|
||||
# makes sense for the host's binary. The fix strips the machine-specific fields
|
||||
# and forces hasCompletedOnboarding, while handling a host file that isn't a
|
||||
# JSON object. That logic lives in seed-claude-config.cjs so it can be
|
||||
# unit-tested and prettier-checked (translate-plugin-registries.test.cjs).
|
||||
node "$SCRIPT_DIR/seed-claude-config.cjs"
|
||||
|
||||
# Codex auth. Some hosts store credentials in the OS keyring instead of on disk
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Code owners
|
||||
|
||||
* @Arvuno
|
||||
* @magyargergo
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Vendored tree-sitter grammar update monitor.
|
||||
*
|
||||
* Checks each vendored grammar against its upstream source-of-origin and, for an
|
||||
* available AND ABI-compatible update, re-vendors the grammar source in place so
|
||||
* a PR can be opened. The version bump in vendor/<name>/package.json then triggers
|
||||
* .github/workflows/build-tree-sitter-prebuilds.yml, which cross-builds + ABI-
|
||||
* validates the prebuilds — so even an imperfect re-vendor can never silently
|
||||
* ship: its PR's CI goes red.
|
||||
*
|
||||
* ABI awareness is load-bearing. Every grammar is pinned to tree-sitter@0.21.1
|
||||
* (LANGUAGE_VERSION 13–14, the #1922 gate). Most upstream grammar releases target
|
||||
* a newer tree-sitter, so a blind "bump to latest" would pull an ABI-incompatible
|
||||
* parser and open doomed PRs. This monitor fetches the candidate source, reads its
|
||||
* parser.c `#define LANGUAGE_VERSION`, and only re-vendors when it is 13 or 14;
|
||||
* incompatible updates are reported (and surfaced as a workflow notice), not
|
||||
* applied.
|
||||
*
|
||||
* Usage:
|
||||
* node update-vendored-grammars.mjs # detect only → JSON report on stdout
|
||||
* node update-vendored-grammars.mjs --apply X # re-vendor grammar X in place
|
||||
*
|
||||
* tree-sitter-c is MONITORED but report-only (`hold`): it is ABI-pinned at 0.21.4
|
||||
* (#1242/#858) and must not auto-bump without a tree-sitter runtime upgrade, so an
|
||||
* available c update is detected + reported but never auto-applied — even if it is
|
||||
* ABI-13/14. A maintainer re-vendors it deliberately.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
||||
const VENDOR = path.join(REPO_ROOT, 'gitnexus', 'vendor');
|
||||
|
||||
const COMPATIBLE_ABI = new Set([13, 14]); // tree-sitter@0.21.1 LANGUAGE_VERSION range
|
||||
|
||||
// Source-of-origin per grammar. npm grammars resolve `latest` via the registry;
|
||||
// github grammars (no usable npm release) track the default branch HEAD. A `hold`
|
||||
// reason makes a grammar report-only: updates are detected + surfaced but never
|
||||
// auto-applied (c is ABI-pinned and must not move without a runtime upgrade).
|
||||
const GRAMMARS = {
|
||||
c: {
|
||||
name: 'tree-sitter-c',
|
||||
npm: 'tree-sitter-c',
|
||||
hold: 'ABI-pinned at 0.21.4 (#1242/#858) — needs a tree-sitter runtime upgrade before bumping',
|
||||
},
|
||||
swift: { name: 'tree-sitter-swift', npm: 'tree-sitter-swift' },
|
||||
kotlin: { name: 'tree-sitter-kotlin', npm: 'tree-sitter-kotlin' },
|
||||
dart: { name: 'tree-sitter-dart', github: 'UserNobody14/tree-sitter-dart' },
|
||||
proto: { name: 'tree-sitter-proto', github: 'coder3101/tree-sitter-proto' },
|
||||
};
|
||||
|
||||
const sh = (cmd, args, opts = {}) =>
|
||||
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
|
||||
|
||||
const clean = (v) =>
|
||||
String(v || '')
|
||||
.replace(/^[v^~]/, '')
|
||||
.trim();
|
||||
|
||||
function vendoredVersion(g) {
|
||||
const p = path.join(VENDOR, g.name, 'package.json');
|
||||
return clean(JSON.parse(fs.readFileSync(p, 'utf8')).version);
|
||||
}
|
||||
|
||||
/** Resolve the upstream candidate: { version, ref, kind }. */
|
||||
function resolveUpstream(g) {
|
||||
if (g.npm) {
|
||||
const version = clean(sh('npm', ['view', g.npm, 'version']));
|
||||
return { version, ref: version, kind: 'npm' };
|
||||
}
|
||||
// github: no reliable release tags here, so track the default branch HEAD sha.
|
||||
const meta = JSON.parse(sh('gh', ['api', `repos/${g.github}`]));
|
||||
const branch = meta.default_branch;
|
||||
const sha = JSON.parse(sh('gh', ['api', `repos/${g.github}/commits/${branch}`])).sha;
|
||||
// Version key: "<upstreamPkgVersion>-g<sha7>" — safeRef-compatible (no `+`,
|
||||
// which the build workflow's ref validator rejects) and changes on every commit.
|
||||
let base = '0.0.0';
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
Buffer.from(
|
||||
JSON.parse(sh('gh', ['api', `repos/${g.github}/contents/package.json?ref=${sha}`])).content,
|
||||
'base64',
|
||||
).toString('utf8'),
|
||||
);
|
||||
if (pkg.version) base = clean(pkg.version);
|
||||
} catch {
|
||||
/* no upstream package.json — base stays 0.0.0 */
|
||||
}
|
||||
return { version: `${base}-g${sha.slice(0, 7)}`, ref: sha, kind: 'github' };
|
||||
}
|
||||
|
||||
/** Fetch the candidate source into a temp dir; return the package root. */
|
||||
function fetchSource(g, ref) {
|
||||
const work = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `revendor-${Object.keys(GRAMMARS).find((k) => GRAMMARS[k] === g)}-`),
|
||||
);
|
||||
if (g.npm) {
|
||||
sh('npm', ['pack', `${g.npm}@${ref}`, '--silent'], { cwd: work });
|
||||
const tgz = fs.readdirSync(work).find((f) => f.endsWith('.tgz'));
|
||||
sh('tar', ['xzf', tgz], { cwd: work });
|
||||
return path.join(work, 'package');
|
||||
}
|
||||
// github tarball at the resolved sha. Download + extract WITHOUT a shell
|
||||
// (no `bash -c`/redirect): `gh api` writes the binary tarball to stdout, which
|
||||
// we capture as a Buffer and write to a fixed path, then extract with execFile.
|
||||
// Avoids the shell-command-injection surface CodeQL flags when an API-derived
|
||||
// ref is interpolated into a `bash -c` string.
|
||||
const tgz = path.join(work, 'src.tgz');
|
||||
fs.writeFileSync(
|
||||
tgz,
|
||||
execFileSync('gh', ['api', `repos/${g.github}/tarball/${ref}`], {
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
sh('tar', ['xzf', tgz], { cwd: work });
|
||||
const dir = fs.readdirSync(work).find((f) => fs.statSync(path.join(work, f)).isDirectory());
|
||||
return path.join(work, dir);
|
||||
}
|
||||
|
||||
/** Read parser.c's LANGUAGE_VERSION (ABI). Prefer the ABI-14 default parser.c. */
|
||||
function readAbi(srcRoot) {
|
||||
const candidates = ['src/parser.c', 'parser.c'];
|
||||
for (const rel of candidates) {
|
||||
const p = path.join(srcRoot, rel);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
// Read only the head — the #define is near the top.
|
||||
const head = fs.readFileSync(p, 'utf8').slice(0, 4000);
|
||||
const m = head.match(/#define\s+LANGUAGE_VERSION\s+(\d+)/);
|
||||
if (m) return Number(m[1]);
|
||||
}
|
||||
return null; // unknown (e.g. parser.c only generated at build time)
|
||||
}
|
||||
|
||||
function detect() {
|
||||
const report = [];
|
||||
for (const [key, g] of Object.entries(GRAMMARS)) {
|
||||
const have = vendoredVersion(g);
|
||||
let up;
|
||||
try {
|
||||
up = resolveUpstream(g);
|
||||
} catch (err) {
|
||||
report.push({ grammar: key, error: String(err.message || err) });
|
||||
continue;
|
||||
}
|
||||
const newer = up.kind === 'npm' ? up.version !== have : !have || up.ref.slice(0, 7) !== have;
|
||||
let abi = null;
|
||||
if (newer) {
|
||||
try {
|
||||
abi = readAbi(fetchSource(g, up.ref));
|
||||
} catch {
|
||||
/* fetch/abi best-effort; null = unknown */
|
||||
}
|
||||
}
|
||||
report.push({
|
||||
grammar: key,
|
||||
vendored: have,
|
||||
upstream: up.version,
|
||||
ref: up.ref,
|
||||
kind: up.kind,
|
||||
update: newer,
|
||||
abi,
|
||||
abiCompatible: abi == null ? null : COMPATIBLE_ABI.has(abi),
|
||||
hold: g.hold || null,
|
||||
// Auto-appliable only when there's an update, the ABI is known-compatible,
|
||||
// AND the grammar is not on a policy hold (c).
|
||||
applicable: newer && abi != null && COMPATIBLE_ABI.has(abi) && !g.hold,
|
||||
});
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
const copyFile = (srcRoot, dest, rel) => {
|
||||
const from = path.join(srcRoot, rel);
|
||||
if (!fs.existsSync(from)) return false;
|
||||
const to = path.join(dest, rel);
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.copyFileSync(from, to);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-vendor one grammar in place from its ABI-compatible upstream candidate.
|
||||
* Copies ONLY the generated source-build + runtime files; deliberately KEEPS the
|
||||
* GitNexus-hardened binding.gyp (Windows cflags, target_name), README (vendor
|
||||
* notice), LICENSE, and prebuilds/ (the build workflow refreshes those). Bumps the
|
||||
* stripped vendor package.json version + provenance — never re-introduces
|
||||
* scripts/dependencies (#836/#1728). Returns the new version.
|
||||
*/
|
||||
function apply(key) {
|
||||
const g = GRAMMARS[key];
|
||||
if (!g) {
|
||||
console.error(`unknown grammar '${key}'`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (g.hold) {
|
||||
console.error(
|
||||
`${key}: report-only (${g.hold}); not auto-applied. Re-vendor manually if intended.`,
|
||||
);
|
||||
process.exit(3);
|
||||
}
|
||||
const have = vendoredVersion(g);
|
||||
const up = resolveUpstream(g);
|
||||
const newer = up.kind === 'npm' ? up.version !== have : !have || up.version !== have;
|
||||
if (!newer) {
|
||||
console.error(`${key}: already current (${have}); nothing to apply.`);
|
||||
process.exit(0);
|
||||
}
|
||||
const srcRoot = fetchSource(g, up.ref);
|
||||
const abi = readAbi(srcRoot);
|
||||
if (abi == null || !COMPATIBLE_ABI.has(abi)) {
|
||||
console.error(
|
||||
`${key}: candidate ${up.version} is ABI ${abi ?? 'unknown'} — not tree-sitter@0.21.1 ` +
|
||||
`compatible (need 13/14); refusing to re-vendor. Handle manually.`,
|
||||
);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const dest = path.join(VENDOR, g.name);
|
||||
// The source-build inputs + runtime entrypoints that change between versions.
|
||||
// binding.gyp / README / LICENSE / prebuilds are intentionally NOT touched.
|
||||
for (const rel of [
|
||||
'src/parser.c',
|
||||
'src/scanner.c',
|
||||
'src/node-types.json',
|
||||
'src/tree_sitter/alloc.h',
|
||||
'src/tree_sitter/array.h',
|
||||
'src/tree_sitter/parser.h',
|
||||
'bindings/node/binding.cc',
|
||||
'bindings/node/index.js',
|
||||
'bindings/node/index.d.ts',
|
||||
]) {
|
||||
copyFile(srcRoot, dest, rel);
|
||||
}
|
||||
|
||||
const pkgPath = path.join(dest, 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
pkg.version = up.version;
|
||||
pkg._vendoredBy =
|
||||
`gitnexus - re-vendored from ${g.npm ? `npm ${g.npm}@${up.version}` : `${g.github}@${up.ref}`} ` +
|
||||
`by grammar-update-monitor on ABI ${abi}. Source-build inputs (parser.c/scanner.c/src/) refreshed; ` +
|
||||
`the GitNexus-hardened binding.gyp + vendor README + prebuilds are preserved (prebuilds are ` +
|
||||
`rebuilt by build-tree-sitter-prebuilds.yml on this version change). No scripts/dependencies here ` +
|
||||
`(#836/#1728).`;
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
|
||||
console.log(`${key}: re-vendored ${g.name} → ${up.version} (ABI ${abi}).`);
|
||||
return up.version;
|
||||
}
|
||||
|
||||
// Run the CLI only when invoked directly (not when imported by a test) — detect()
|
||||
// makes live network calls, so importing must be side-effect-free.
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) {
|
||||
if (process.argv[2] === '--apply') {
|
||||
apply(process.argv[3]);
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(detect(), null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
export { detect, apply, resolveUpstream, readAbi, vendoredVersion, GRAMMARS, COMPATIBLE_ABI };
|
||||
@@ -0,0 +1,529 @@
|
||||
name: Build tree-sitter prebuilds
|
||||
|
||||
# Cross-builds the native tree-sitter prebuilds GitNexus vendors itself, so that
|
||||
# grammars whose upstream packages ship SOURCE ONLY (no usable prebuilds/) never
|
||||
# require a C/C++ toolchain at a user's install. This is the "no operational
|
||||
# risk for any tree-sitter grammar" pipeline.
|
||||
#
|
||||
# Grammars covered here (the at-risk set — everything else already ships 6
|
||||
# upstream prebuilds AND stays dependency-review-tracked, so it is left alone).
|
||||
# All five are vendored under gitnexus/vendor/; `kind` (below) only picks where
|
||||
# the build job fetches the C source to compile:
|
||||
# - tree-sitter-c (vendored prebuild-only; built from the published npm
|
||||
# package — closes upstream's 4/6 ARM gap #2116 for a
|
||||
# REQUIRED grammar)
|
||||
# - tree-sitter-dart (vendored source; built from gitnexus/vendor/)
|
||||
# - tree-sitter-proto (vendored source; built from gitnexus/vendor/)
|
||||
# - tree-sitter-kotlin (vendored source; built from the published npm package —
|
||||
# upstream ships source only)
|
||||
# - tree-sitter-swift (vendored source; built from gitnexus/vendor/ — its
|
||||
# prebuilds were originally upstream-shipped, now
|
||||
# GitNexus-cross-built like the rest for uniformity)
|
||||
#
|
||||
# Output: gitnexus/vendor/<grammar>/prebuilds/<platform-arch>/<grammar>.node for
|
||||
# all 6 targets ({linux,darwin,win32}-{x64,arm64}). tree-sitter grammars are
|
||||
# N-API, so one ABI-stable .node per platform-arch works across all Node majors.
|
||||
#
|
||||
# COST DISCIPLINE — this is a HEAVY native matrix (up to 3 grammars x 6 runners,
|
||||
# incl. macOS + arm64). It is DELIBERATELY NOT wired into normal PR/push CI. It
|
||||
# runs only:
|
||||
# 1. on manual dispatch (workflow_dispatch); or
|
||||
# 2. when a covered grammar's recorded version actually CHANGES — the `guard`
|
||||
# job is the real gate (it diffs the recorded version vs the PR base); the
|
||||
# `paths:` filter below only makes ordinary code PRs cost ZERO matrix time.
|
||||
# Net effect: an ordinary code PR triggers nothing; bumping one grammar costs
|
||||
# exactly one matrix run for that grammar, which opens a PR committing its rebuilt
|
||||
# binaries.
|
||||
#
|
||||
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
|
||||
#
|
||||
# NOTE: every action below is pinned to a release commit SHA (with the matching
|
||||
# `# vX.Y.Z` tag comment verified against the GitHub API). If a future bump adds
|
||||
# a new action, pin its real release SHA and allowlist it in .github/zizmor.yml /
|
||||
# Scorecard before merge.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
grammars:
|
||||
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,swift), or "all".'
|
||||
required: false
|
||||
type: string
|
||||
default: 'all'
|
||||
ref:
|
||||
description: 'Upstream version/tag/sha override (only honored when exactly one grammar is selected).'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
force:
|
||||
description: 'Build even if the recorded version is unchanged (re-cut a broken prebuild).'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
open_pr:
|
||||
description: 'Open a PR with the rebuilt prebuilds (false = artifacts only).'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
# Vendored grammars: their version lives in the vendor snapshot package.json.
|
||||
- 'gitnexus/vendor/tree-sitter-c/package.json'
|
||||
- 'gitnexus/vendor/tree-sitter-dart/package.json'
|
||||
- 'gitnexus/vendor/tree-sitter-proto/package.json'
|
||||
- 'gitnexus/vendor/tree-sitter-kotlin/package.json'
|
||||
- 'gitnexus/vendor/tree-sitter-swift/package.json'
|
||||
# Transition window: kotlin's pin still lives here until it is vendored.
|
||||
- 'gitnexus/package.json'
|
||||
# Self-test: re-run the guard (normally a no-op) when the recipe changes.
|
||||
- '.github/workflows/build-tree-sitter-prebuilds.yml'
|
||||
|
||||
# Least privilege by default; only `aggregate` opts up.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# One slot per ref. Collapse PR re-pushes, but never cancel a manual re-cut.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# ── Gate: decide which grammars (if any) need a native rebuild, and emit the
|
||||
# {grammar x platform-arch} matrix the build job consumes. ───────────────
|
||||
guard:
|
||||
name: Decide what to build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
any: ${{ steps.decide.outputs.any }}
|
||||
matrix: ${{ steps.decide.outputs.matrix }}
|
||||
release_app: ${{ steps.relapp.outputs.configured }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0 # need base history to diff recorded versions
|
||||
persist-credentials: false
|
||||
|
||||
- name: Decide
|
||||
id: decide
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
# Untrusted dispatch inputs — read via env only, validated in JS.
|
||||
INPUT_GRAMMARS: ${{ inputs.grammars }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.force || 'false' }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module - <<'NODE'
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
// Registry of the at-risk grammars this workflow owns. `kind` drives
|
||||
// how the build job resolves source: 'npm' pulls the published package;
|
||||
// 'vendored' builds from gitnexus/vendor/<name> (which carries the C
|
||||
// source + binding.gyp). Extend this list to cover a new grammar.
|
||||
const REGISTRY = {
|
||||
// c is vendored prebuild-only but BUILT from the published npm
|
||||
// package (kind 'npm'), held at 0.21.4 — it closes upstream's 4/6
|
||||
// ARM gap (#2116) for a REQUIRED grammar that otherwise hard-fails
|
||||
// install on toolchain-less ARM.
|
||||
c: { name: 'tree-sitter-c', kind: 'npm' },
|
||||
dart: { name: 'tree-sitter-dart', kind: 'vendored' },
|
||||
proto: { name: 'tree-sitter-proto', kind: 'vendored' },
|
||||
kotlin: { name: 'tree-sitter-kotlin', kind: 'npm' },
|
||||
// swift is vendored WITH its source (parser.c/scanner.c/binding.gyp),
|
||||
// so it builds from gitnexus/vendor/ like dart/proto. Its prebuilds
|
||||
// were originally upstream-shipped; rebuilding them here unifies it.
|
||||
swift: { name: 'tree-sitter-swift', kind: 'vendored' },
|
||||
};
|
||||
const PLATFORMS = [
|
||||
{ platform_arch: 'linux-x64', os: 'ubuntu-24.04' },
|
||||
{ platform_arch: 'linux-arm64', os: 'ubuntu-24.04-arm' },
|
||||
{ platform_arch: 'darwin-arm64', os: 'macos-15' },
|
||||
{ platform_arch: 'darwin-x64', os: 'macos-15-intel' }, // macos-13 retired Dec-2025; Intel EOL ~Aug-2027
|
||||
{ platform_arch: 'win32-x64', os: 'windows-2022' },
|
||||
{ platform_arch: 'win32-arm64', os: 'windows-11-arm' },
|
||||
];
|
||||
|
||||
const clean = (v) => (v || '').replace(/^[\^~]/, '').trim();
|
||||
const json = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
|
||||
|
||||
// Durable version key for a grammar at a checkout root. Prefer the
|
||||
// vendor snapshot (the post-vendor source of truth); fall back to the
|
||||
// optionalDependencies pin during the transition window. (A guard keyed
|
||||
// on the node_modules lock entry would self-disable once a grammar is
|
||||
// vendored, because that entry is deleted.)
|
||||
function recordedVersion(root, name) {
|
||||
const v = json(`${root}/gitnexus/vendor/${name}/package.json`);
|
||||
if (v && v.version) return clean(v.version);
|
||||
const pkg = json(`${root}/gitnexus/package.json`);
|
||||
const od = pkg && (pkg.optionalDependencies || {});
|
||||
const d = pkg && (pkg.dependencies || {});
|
||||
return clean((od && od[name]) || (d && d[name]) || '');
|
||||
}
|
||||
|
||||
const event = process.env.EVENT;
|
||||
const force = process.env.FORCE === 'true';
|
||||
|
||||
// Select which grammar shortnames are in play.
|
||||
let selected;
|
||||
if (event === 'workflow_dispatch') {
|
||||
const raw = (process.env.INPUT_GRAMMARS || 'all').trim();
|
||||
selected = raw === 'all' ? Object.keys(REGISTRY)
|
||||
: raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
for (const s of selected) if (!REGISTRY[s]) throw new Error(`unknown grammar '${s}'`);
|
||||
} else {
|
||||
selected = Object.keys(REGISTRY);
|
||||
}
|
||||
|
||||
// Resolve the base-ref recorded versions (pull_request only) so we can
|
||||
// diff. On dispatch, base is irrelevant (manual intent / force wins).
|
||||
const baseRoot = `${process.env.RUNNER_TEMP}/base`;
|
||||
if (event === 'pull_request') {
|
||||
const baseSha = process.env.BASE_SHA;
|
||||
for (const s of selected) {
|
||||
const name = REGISTRY[s].name;
|
||||
for (const rel of [`gitnexus/vendor/${name}/package.json`, `gitnexus/package.json`]) {
|
||||
const dst = `${baseRoot}/${rel}`;
|
||||
fs.mkdirSync(dst.slice(0, dst.lastIndexOf('/')), { recursive: true });
|
||||
try {
|
||||
const buf = execSync(`git show ${baseSha}:${rel}`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
fs.writeFileSync(dst, buf);
|
||||
} catch { /* file absent at base — fine */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The single-ref override is only meaningful for a one-grammar dispatch.
|
||||
const refOverride = clean(process.env.INPUT_REF);
|
||||
if (refOverride && !(event === 'workflow_dispatch' && selected.length === 1)) {
|
||||
throw new Error('ref override requires exactly one grammar selected');
|
||||
}
|
||||
const safeRef = (r) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(r);
|
||||
|
||||
const include = [];
|
||||
const built = [];
|
||||
for (const short of selected) {
|
||||
const { name, kind } = REGISTRY[short];
|
||||
const head = recordedVersion('.', name);
|
||||
const ref = refOverride || head;
|
||||
if (!ref) { console.log(`skip ${short}: no recorded version`); continue; }
|
||||
if (!safeRef(ref)) throw new Error(`unsafe ref for ${short}: '${ref}'`);
|
||||
|
||||
let build = false;
|
||||
if (event === 'workflow_dispatch') {
|
||||
build = true; // manual intent (force toggles only the unchanged-guard, which is bypassed here)
|
||||
} else {
|
||||
const base = recordedVersion(baseRoot, name);
|
||||
build = !!head && head !== base;
|
||||
console.log(`${short}: head='${head || '<absent>'}' base='${base || '<absent>'}' -> ${build ? 'BUILD' : 'skip'}`);
|
||||
}
|
||||
if (force) build = true;
|
||||
if (!build) continue;
|
||||
built.push(short);
|
||||
for (const p of PLATFORMS) include.push({ grammar: short, name, kind, ref, ...p });
|
||||
}
|
||||
|
||||
const out = process.env.GITHUB_OUTPUT;
|
||||
appendFileSync(out, `any=${include.length > 0}\n`);
|
||||
appendFileSync(out, `matrix=${JSON.stringify({ include })}\n`);
|
||||
if (include.length === 0) {
|
||||
console.log('::notice::No covered grammar version changed — skipping native matrix.');
|
||||
} else {
|
||||
console.log(`Building: ${built.join(', ')} (${include.length} jobs)`);
|
||||
}
|
||||
NODE
|
||||
|
||||
# The aggregate job opens a PR via a GitHub App token; without the App
|
||||
# secrets it would hard-fail AFTER a full native build. Surface their
|
||||
# presence as a guard output so aggregate skips cleanly (the build job's
|
||||
# artifacts still upload). secrets aren't available in a job-level `if:`,
|
||||
# so we compute the boolean here (a step CAN read secrets) and gate on it.
|
||||
- name: Check release App secret
|
||||
id: relapp
|
||||
env:
|
||||
HAS_APP: ${{ secrets.RELEASE_APP_ID != '' && secrets.RELEASE_APP_PRIVATE_KEY != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "configured=$HAS_APP" >> "$GITHUB_OUTPUT"
|
||||
if [ "$HAS_APP" != "true" ]; then
|
||||
echo "::notice::Release GitHub App secrets (RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY) are not configured — prebuilds will build and upload as artifacts, but the auto-PR is skipped. Provision the App, or run with open_pr=false to suppress this notice."
|
||||
fi
|
||||
|
||||
# ── Build one native prebuild per (grammar, platform-arch). No cross-compile. ─
|
||||
build:
|
||||
name: ${{ matrix.grammar }} ${{ matrix.platform_arch }}
|
||||
needs: guard
|
||||
if: needs.guard.outputs.any == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.guard.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
# 45 (not 30) for headroom: the kotlin parser.c is ~23 MB and swift's ~18 MB,
|
||||
# and compiling them under emulation on the arm runners is slow.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false # this job uploads artifacts (artipacked)
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Ensure Python (arm64 Windows only)
|
||||
if: matrix.platform_arch == 'win32-arm64'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Build prebuild
|
||||
id: build
|
||||
shell: bash
|
||||
env:
|
||||
GRAMMAR: ${{ matrix.grammar }}
|
||||
NAME: ${{ matrix.name }}
|
||||
KIND: ${{ matrix.kind }}
|
||||
REF: ${{ matrix.ref }}
|
||||
PLATFORM_ARCH: ${{ matrix.platform_arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
work="$RUNNER_TEMP/ts-build"
|
||||
rm -rf "$work"; mkdir -p "$work"; cd "$work"
|
||||
npm init -y >/dev/null
|
||||
|
||||
# node-addon-api must match what the grammar's binding.cc expects.
|
||||
# GitNexus hoists ^8 for the vendored grammars; npm grammars declare
|
||||
# their own (do NOT pin it for npm grammars — let the dep resolve it).
|
||||
if [ "$KIND" = "vendored" ]; then
|
||||
# Build from the vendored C source (carries parser.c + binding.gyp).
|
||||
srcdir="$work/$NAME"
|
||||
cp -R "$GITHUB_WORKSPACE/gitnexus/vendor/$NAME" "$srcdir"
|
||||
rm -rf "$srcdir/prebuilds" "$srcdir/build" "$srcdir/node_modules"
|
||||
npm install --no-audit --no-fund --ignore-scripts \
|
||||
prebuildify@^6 node-gyp@^11 node-addon-api@^8
|
||||
pkgdir="$srcdir"
|
||||
export npm_config_node_gyp="$work/node_modules/node-gyp/bin/node-gyp.js"
|
||||
else
|
||||
# Pull the published source-only package.
|
||||
npm install --no-audit --no-fund --ignore-scripts \
|
||||
"$NAME@${REF}" prebuildify@^6 node-gyp@^11
|
||||
pkgdir="$work/node_modules/$NAME"
|
||||
fi
|
||||
|
||||
test -f "$pkgdir/binding.gyp" || { echo "::error::no binding.gyp for $NAME@$REF"; exit 1; }
|
||||
|
||||
# Drop any prebuilds the package shipped in its own tarball before we
|
||||
# build. The tree-sitter-org npm grammars (e.g. tree-sitter-c) bundle
|
||||
# prebuilds/ for all 6 tuples; left in place, the `find ... -print -quit`
|
||||
# below would pick a non-host tuple (e.g. win32-x64 on a linux runner)
|
||||
# and the assertion would wrongly fail. prebuildify rebuilds THIS host's
|
||||
# tuple from the source the tarball also ships. (Vendored grammars are
|
||||
# already cleaned above; this also covers the npm branch.)
|
||||
rm -rf "$pkgdir/prebuilds"
|
||||
|
||||
# N-API, stripped, single ABI-stable binary for THIS host's arch. No
|
||||
# `-t <node-version>`: an N-API prebuild is Node-version-agnostic, and
|
||||
# prebuildify parses a bare `-t 22` as the NUMBER 22 and crashes
|
||||
# (`v.indexOf is not a function`). prebuildify emits
|
||||
# prebuilds/<platform>-<arch>/<something>.node.
|
||||
( cd "$pkgdir" && npx --no-install prebuildify --napi --strip )
|
||||
|
||||
out=$(find "$pkgdir/prebuilds" -name '*.node' -print -quit)
|
||||
test -n "$out" || { echo "::error::prebuildify produced no .node"; exit 1; }
|
||||
produced=$(basename "$(dirname "$out")")
|
||||
[ "$produced" = "$PLATFORM_ARCH" ] || { echo "::error::built $produced, expected $PLATFORM_ARCH"; exit 1; }
|
||||
|
||||
stage="$RUNNER_TEMP/stage/$GRAMMAR/$PLATFORM_ARCH"; mkdir -p "$stage"
|
||||
cp "$out" "$stage/$NAME.node"
|
||||
echo "stage=$stage" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate the .node loads and parses on this arch
|
||||
shell: bash
|
||||
env:
|
||||
GRAMMAR: ${{ matrix.grammar }}
|
||||
NAME: ${{ matrix.name }}
|
||||
PLATFORM_ARCH: ${{ matrix.platform_arch }}
|
||||
EXPECT_ARCH: ${{ contains(matrix.platform_arch, 'arm64') && 'arm64' || 'x64' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
probe="$RUNNER_TEMP/probe"; rm -rf "$probe"
|
||||
mkdir -p "$probe/prebuilds/$PLATFORM_ARCH"
|
||||
cp "$RUNNER_TEMP/stage/$GRAMMAR/$PLATFORM_ARCH/$NAME.node" \
|
||||
"$probe/prebuilds/$PLATFORM_ARCH/$NAME.node"
|
||||
cd "$probe"
|
||||
# Pin tree-sitter to the repo's exact runtime peer so an ABI mismatch
|
||||
# fails HERE, not in a user's install (mirrors the #1922 ABI gate).
|
||||
# NOT --ignore-scripts: tree-sitter@0.21.1's tarball ships prebuilds for
|
||||
# the common tuples but NOT linux-arm64 / win32-arm64, so on the arm64
|
||||
# runners node-gyp-build must source-build the runtime — give it node-gyp
|
||||
# + node-addon-api to do so. Where tree-sitter ships a prebuild (x64,
|
||||
# darwin-arm64) node-gyp-build uses it and nothing compiles. The grammar
|
||||
# .node we built is still loaded as a prebuild; only the runtime peer may
|
||||
# compile. The grammar-vs-runtime ABI check still fires at setLanguage.
|
||||
npm install --no-audit --no-fund \
|
||||
node-gyp-build@^4 node-gyp@^11 node-addon-api@^8 tree-sitter@0.21.1
|
||||
# The node script is single-quoted on purpose — its ${...} are JS
|
||||
# template literals read from the environment, not shell expansions.
|
||||
# shellcheck disable=SC2016
|
||||
GRAMMAR="$GRAMMAR" EXPECT_ARCH="$EXPECT_ARCH" node -e '
|
||||
const expect = process.env.EXPECT_ARCH;
|
||||
// Catch an emulated x64 Node silently mis-passing on an arm64 runner.
|
||||
if (process.arch !== expect) throw new Error(`runner arch ${process.arch} != ${expect}`);
|
||||
const snippets = {
|
||||
c: "int main(void) { return 0; }",
|
||||
dart: "void main() { print(\"hi\"); }",
|
||||
proto: "syntax = \"proto3\";\nmessage M { int32 id = 1; }",
|
||||
kotlin: "fun main() { println(\"hi\") }",
|
||||
swift: "func greet() { print(\"hi\") }",
|
||||
};
|
||||
const lang = require("node-gyp-build")(process.cwd());
|
||||
const Parser = require("tree-sitter");
|
||||
const p = new Parser(); p.setLanguage(lang);
|
||||
const tree = p.parse(snippets[process.env.GRAMMAR]);
|
||||
if (!tree || !tree.rootNode || tree.rootNode.hasError) {
|
||||
throw new Error("parse failed/error: " + (tree && tree.rootNode && tree.rootNode.type));
|
||||
}
|
||||
console.log("OK", process.env.GRAMMAR, process.platform + "-" + process.arch, tree.rootNode.type);
|
||||
'
|
||||
|
||||
- name: Upload prebuild artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ts-prebuild-${{ matrix.grammar }}-${{ matrix.platform_arch }}
|
||||
path: ${{ steps.build.outputs.stage }}/${{ matrix.name }}.node
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# ── Aggregate every grammar's six prebuilds, assert completeness, open a PR. ─
|
||||
aggregate:
|
||||
name: Vendor prebuilds + open PR
|
||||
needs: [guard, build]
|
||||
# Open the prebuild PR on a non-fork pull_request that bumped a grammar
|
||||
# version (the documented version-change -> prebuild-PR flow), or on a manual
|
||||
# dispatch with open_pr=true. Event-gating is explicit so we never rely on
|
||||
# GHA coercing a null `inputs.open_pr` on pull_request events (Codex F4):
|
||||
# `inputs.open_pr` is null off-dispatch, and `null != false` is direction-
|
||||
# ambiguous, so `open_pr` is only consulted on workflow_dispatch.
|
||||
if: >-
|
||||
needs.guard.outputs.any == 'true' &&
|
||||
needs.guard.outputs.release_app == 'true' &&
|
||||
github.event.pull_request.head.repo.fork != true &&
|
||||
(github.event_name == 'pull_request' || inputs.open_pr == true)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read # actual writes use a short-lived App token below
|
||||
id-token: write # SLSA provenance attestation
|
||||
attestations: write
|
||||
steps:
|
||||
- name: Mint GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download all prebuild artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ${{ runner.temp }}/dl
|
||||
pattern: ts-prebuild-*
|
||||
|
||||
- name: Place prebuilds, assert each built grammar has all 6, write SHA256SUMS
|
||||
id: place
|
||||
shell: bash
|
||||
env:
|
||||
MATRIX: ${{ needs.guard.outputs.matrix }}
|
||||
DL: ${{ runner.temp }}/dl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module - <<'NODE'
|
||||
import fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
const include = JSON.parse(process.env.MATRIX).include;
|
||||
const dl = process.env.DL;
|
||||
const byGrammar = {};
|
||||
for (const e of include) (byGrammar[e.grammar] ||= { name: e.name, archs: [] }).archs.push(e.platform_arch);
|
||||
const PLATFORMS = ['linux-x64','linux-arm64','darwin-arm64','darwin-x64','win32-x64','win32-arm64'];
|
||||
const changed = [];
|
||||
for (const [grammar, { name }] of Object.entries(byGrammar)) {
|
||||
const dest = `gitnexus/vendor/${name}/prebuilds`;
|
||||
// A vendored grammar with 5/6 prebuilds silently breaks node-gyp-build
|
||||
// on the 6th platform — refuse a partial result.
|
||||
for (const pa of PLATFORMS) {
|
||||
const art = `${dl}/ts-prebuild-${grammar}-${pa}/${name}.node`;
|
||||
if (!fs.existsSync(art)) throw new Error(`missing ${grammar} prebuild for ${pa}`);
|
||||
fs.mkdirSync(`${dest}/${pa}`, { recursive: true });
|
||||
fs.copyFileSync(art, `${dest}/${pa}/${name}.node`);
|
||||
}
|
||||
execSync(`cd ${dest} && find . -name '*.node' | sort | xargs sha256sum > SHA256SUMS`);
|
||||
changed.push(name);
|
||||
}
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `grammars=${changed.join(',')}\n`);
|
||||
console.log('Vendored prebuilds for:', changed.join(', '));
|
||||
NODE
|
||||
|
||||
- name: Attest build provenance (SLSA)
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'
|
||||
|
||||
- name: Create or update PR
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GRAMMARS: ${{ steps.place.outputs.grammars }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { execSync } = require('node:child_process');
|
||||
const run = (c) => execSync(c, { stdio: ['ignore', 'pipe', 'inherit'] }).toString().trim();
|
||||
const grammars = process.env.GRAMMARS;
|
||||
const slug = grammars.replace(/[^a-z0-9]+/gi, '-');
|
||||
const branch = `chore/vendor-ts-prebuilds-${slug}-${context.runId}`;
|
||||
|
||||
run('git add gitnexus/vendor/tree-sitter-*/prebuilds');
|
||||
if (!run('git status --porcelain -- gitnexus/vendor/tree-sitter-*/prebuilds')) {
|
||||
core.notice('Prebuilds byte-identical to vendor; nothing to commit.');
|
||||
return;
|
||||
}
|
||||
run('git config user.name "gitnexus-release-bot[bot]"');
|
||||
run('git config user.email "gitnexus-release-bot[bot]@users.noreply.github.com"');
|
||||
run(`git checkout -b "${branch}"`);
|
||||
run(`git commit -m "chore(vendor): rebuild native prebuilds (${grammars})\n\nBuilt by ${process.env.RUN_URL}"`);
|
||||
const { owner, repo } = context.repo;
|
||||
const remote = `https://x-access-token:${process.env.GH_TOKEN}@github.com/${owner}/${repo}.git`;
|
||||
// Plain --force, not --force-with-lease: the branch is ephemeral and
|
||||
// unique per run (keyed by context.runId), written ONLY by this job, so
|
||||
// there is no concurrent writer to protect against. --force-with-lease
|
||||
// would compare against a remote-tracking ref this fresh checkout never
|
||||
// fetched, so re-running the SAME run (branch already pushed by attempt
|
||||
// 1) fails with "stale info" instead of overwriting.
|
||||
run(`git push --force "${remote}" "HEAD:${branch}"`);
|
||||
const body = [
|
||||
`Rebuilt the vendored native prebuilds for: **${grammars}**.`,
|
||||
'',
|
||||
`Builder run: ${process.env.RUN_URL}`,
|
||||
'Each `.node` was `require()`-loaded + parsed a real snippet on its target',
|
||||
'platform-arch before upload. SLSA build-provenance attested; `SHA256SUMS`',
|
||||
'committed alongside each grammar.',
|
||||
].join('\n');
|
||||
const { data: pr } = await github.rest.pulls.create({
|
||||
owner, repo, head: branch, base: 'main',
|
||||
title: `chore(vendor): tree-sitter prebuilds (${grammars})`, body,
|
||||
});
|
||||
core.info(`Opened PR #${pr.number}`);
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
# persist-credentials: false — this job only reads (tests and syntax
|
||||
# checks) and never pushes. The setting keeps GITHUB_TOKEN out of
|
||||
# .git/config, which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# persist-credentials: false — this is a read-only build smoke that
|
||||
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
|
||||
# which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
outputs:
|
||||
web_changed: ${{ steps.filter.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Configure e2e GitNexus home
|
||||
run: echo "GITNEXUS_HOME=${RUNNER_TEMP}/gitnexus-home" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
- run: npx tsc -b --noEmit
|
||||
working-directory: gitnexus-web
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Validate workflow concurrency convention
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
sparse-checkout: gitnexus/vitest.config.ts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
# test-reports artifact (if: always()). The default-persisted token in
|
||||
# .git/config must not be capturable through that upload (zizmor
|
||||
# credential-persistence / artipacked audit). The job never pushes.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
steps:
|
||||
# persist-credentials: false — runs tests only, never pushes (zizmor
|
||||
# credential-persistence / artipacked audit).
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -94,8 +94,9 @@ jobs:
|
||||
# 1. Static, offline: assert every grammar's compiled ABI loads on the
|
||||
# pinned runtime (check-tree-sitter-upgrade-readiness.py --assert-current).
|
||||
# 2. Dynamic: run the parser-loader ABI load-smoke on the OS matrix so an
|
||||
# ABI-incompatible prebuilt (esp. the binary-only Swift vendor, which the
|
||||
# static check can't introspect) fails on the platform it ships to.
|
||||
# ABI-incompatible committed vendor prebuilt (e.g. Swift's — the static
|
||||
# check introspects source, not the shipped .node) fails on the platform
|
||||
# it ships to.
|
||||
abi-assert:
|
||||
name: tree-sitter ABI (${{ matrix.os }})
|
||||
strategy:
|
||||
@@ -105,7 +106,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
@@ -137,7 +138,7 @@ jobs:
|
||||
# from a tarball and never pushes back; the token in .git/config would
|
||||
# be at risk of leaking through any future artifact-upload step
|
||||
# (zizmor artipacked audit). Disable upfront.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -245,7 +246,7 @@ jobs:
|
||||
# and never pushes; the default-persisted token in .git/config would be at
|
||||
# risk of leaking through an artifact upload (zizmor credential-persistence
|
||||
# / artipacked audit). Mirrors the packaged-install-smoke job below.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -265,6 +266,16 @@ jobs:
|
||||
run: node --import tsx bench/scope-capture/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: CFG construction time / disk / memory guards (#2081 M1)
|
||||
# Build-free: asserts collectFunctionCfgs output is unchanged
|
||||
# (fingerprint) and that wall-time, cfgSideChannel disk bytes, AND
|
||||
# retained heap all stay sub-quadratic for the straight-line /
|
||||
# many-functions / branchy scenarios. Catches an O(n^2) re-regression in
|
||||
# the per-function CFG builder (e.g. an extendBlock concat chain) and a
|
||||
# memory/disk blow-up. --expose-gc enables the retained-heap measurement.
|
||||
run: node --expose-gc --import tsx bench/cfg/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
|
||||
env:
|
||||
GITNEXUS_BENCH: '1'
|
||||
|
||||
@@ -27,9 +27,8 @@ 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.
|
||||
|
||||
@@ -49,11 +48,6 @@ 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
|
||||
@@ -62,7 +56,7 @@ jobs:
|
||||
save-pr-meta:
|
||||
name: Save PR Metadata
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs: [quality, tests, e2e, scope-parity]
|
||||
needs: [quality, tests, e2e]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -73,14 +67,12 @@ 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
|
||||
@@ -103,7 +95,7 @@ jobs:
|
||||
# Single required check for branch protection.
|
||||
ci-status:
|
||||
name: CI Gate
|
||||
needs: [quality, tests, e2e, scope-parity]
|
||||
needs: [quality, tests, e2e]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
@@ -117,14 +109,15 @@ 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.
|
||||
@@ -137,14 +130,3 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
|
||||
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
|
||||
|
||||
@@ -42,13 +42,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
@@ -69,6 +69,6 @@ jobs:
|
||||
- '**/test/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# When triggered by workflow_call the caller passes the RC tag as an input;
|
||||
# we check out that tag so the Dockerfile and package.json match the built image.
|
||||
# For tag-push events github.ref is already the tag ref — no override needed.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
@@ -138,17 +138,17 @@ jobs:
|
||||
|
||||
# Required for multi-platform (linux/arm64) emulation.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# Full history needed for the on-push full-history scan; on PRs the
|
||||
# action diffs against the base ref so the cost is bounded by the PR.
|
||||
@@ -38,6 +38,18 @@ 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
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
name: Vendored grammar update monitor
|
||||
|
||||
# Periodically checks each vendored tree-sitter grammar against its
|
||||
# source-of-origin and opens a PR re-vendoring any update that is ABI-COMPATIBLE
|
||||
# with the pinned tree-sitter@0.21.1 (LANGUAGE_VERSION 13–14, #1922). The version
|
||||
# bump then triggers build-tree-sitter-prebuilds.yml, which cross-builds + ABI-
|
||||
# validates the prebuilds — so a re-vendor that is subtly wrong can never silently
|
||||
# ship: its PR's CI goes red.
|
||||
#
|
||||
# ABI-INCOMPATIBLE updates (the common case — upstreams move to newer tree-sitter)
|
||||
# are reported as a notice + job summary, NOT applied, so the monitor never opens
|
||||
# doomed PRs. tree-sitter-c is MONITORED but report-only: it is ABI-pinned at
|
||||
# 0.21.4 (#1242/#858), so an available c update is surfaced (notice + summary) but
|
||||
# never auto-bumped — a maintainer re-vendors it deliberately after a runtime
|
||||
# upgrade.
|
||||
#
|
||||
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 6 * * 1' # weekly, Monday 06:17 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
# Least privilege; the actual writes use a short-lived App token minted below.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Check upstreams + open update PRs
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# secrets aren't usable in a job/step `if:`, so compute presence here.
|
||||
- name: Check release App secret
|
||||
id: relapp
|
||||
env:
|
||||
HAS_APP: ${{ secrets.RELEASE_APP_ID != '' && secrets.RELEASE_APP_PRIVATE_KEY != '' }}
|
||||
run: echo "configured=$HAS_APP" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Mint GitHub App token
|
||||
id: app-token
|
||||
if: steps.relapp.outputs.configured == 'true'
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Detect updates, re-vendor ABI-compatible ones, open PRs
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
HAS_APP: ${{ steps.relapp.outputs.configured }}
|
||||
# App token writes; falls back to the read-only job token (PRs then skip).
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
script: |
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const SCRIPT = '.github/scripts/update-vendored-grammars.mjs';
|
||||
const run = (cmd, args, opts = {}) =>
|
||||
execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
|
||||
|
||||
const report = JSON.parse(run('node', [SCRIPT]));
|
||||
const { owner, repo } = context.repo;
|
||||
const hasApp = process.env.HAS_APP === 'true';
|
||||
const applied = [], held = [], errors = [], skipped = [];
|
||||
|
||||
run('git', ['config', 'user.name', 'gitnexus-release-bot[bot]']);
|
||||
run('git', ['config', 'user.email', 'gitnexus-release-bot[bot]@users.noreply.github.com']);
|
||||
const baseSha = run('git', ['rev-parse', 'HEAD']).trim();
|
||||
|
||||
for (const r of report) {
|
||||
if (r.error) { errors.push(r); continue; }
|
||||
if (!r.update) continue;
|
||||
if (!r.applicable) { held.push(r); continue; } // ABI-incompatible / unknown
|
||||
|
||||
const name = `tree-sitter-${r.grammar}`;
|
||||
const branch = `chore/update-${name}-${r.upstream}`.replace(/[^a-z0-9._/-]+/gi, '-');
|
||||
|
||||
// Idempotency: don't reopen an existing PR for this exact version.
|
||||
const existing = await github.rest.pulls.list({ owner, repo, head: `${owner}:${branch}`, state: 'all' });
|
||||
if (existing.data.length > 0) { skipped.push({ ...r, reason: 'PR exists' }); continue; }
|
||||
|
||||
// Re-vendor in place (refuses + exits non-zero if ABI turns out wrong).
|
||||
try {
|
||||
run('node', [SCRIPT, '--apply', r.grammar]);
|
||||
} catch (e) {
|
||||
errors.push({ ...r, error: `apply failed: ${String(e.message || e).slice(0, 200)}` });
|
||||
run('git', ['checkout', '--', 'gitnexus/vendor']);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasApp) {
|
||||
skipped.push({ ...r, reason: 'no RELEASE_APP secret — PR not opened' });
|
||||
run('git', ['checkout', '--', 'gitnexus/vendor']);
|
||||
continue;
|
||||
}
|
||||
|
||||
const remote = `https://x-access-token:${process.env.GH_TOKEN}@github.com/${owner}/${repo}.git`;
|
||||
run('git', ['checkout', '-B', branch, baseSha]);
|
||||
run('git', ['add', `gitnexus/vendor/${name}`]);
|
||||
run('git', ['commit', '-m', `chore(vendor): update ${name} to ${r.upstream}`]);
|
||||
run('git', ['push', '--force-with-lease', remote, `HEAD:${branch}`]);
|
||||
const body = [
|
||||
`Automated re-vendor of **${name}** to \`${r.upstream}\` (from ${r.kind === 'npm' ? `npm \`${name}\`` : `\`${r.ref}\``}).`,
|
||||
'',
|
||||
`Verified ABI **${r.abi}** — compatible with the pinned \`tree-sitter@0.21.1\` (13–14).`,
|
||||
'Source-build inputs refreshed; the GitNexus binding.gyp / README / prebuilds are preserved.',
|
||||
'The version bump triggers `build-tree-sitter-prebuilds.yml` to rebuild + ABI-validate the',
|
||||
'prebuilds — review its result before merging.',
|
||||
].join('\n');
|
||||
const pr = await github.rest.pulls.create({
|
||||
owner, repo, head: branch, base: 'main',
|
||||
title: `chore(vendor): update ${name} to ${r.upstream}`, body,
|
||||
});
|
||||
applied.push({ ...r, pr: pr.data.number });
|
||||
run('git', ['checkout', '--force', baseSha]);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const s = core.summary.addHeading('Vendored grammar update monitor');
|
||||
if (applied.length) s.addRaw(`\n**Opened PRs:** ${applied.map((a) => `${a.grammar}→${a.upstream} (#${a.pr})`).join(', ')}\n`);
|
||||
if (held.length) s.addRaw(`\n**Held (not auto-applied):** ${held.map((h) => `${h.grammar} ${h.upstream} (${h.hold ? 'report-only: ' + h.hold : 'ABI ' + (h.abi ?? '?') + ' — needs the tree-sitter runtime upgrade'})`).join(', ')}\n`);
|
||||
if (skipped.length) s.addRaw(`\n**Skipped:** ${skipped.map((x) => `${x.grammar} (${x.reason})`).join(', ')}\n`);
|
||||
if (errors.length) s.addRaw(`\n**Errors:** ${errors.map((e) => `${e.grammar}: ${e.error}`).join('; ')}\n`);
|
||||
if (!applied.length && !held.length && !skipped.length && !errors.length) s.addRaw('\nAll vendored grammars are up to date. ✅\n');
|
||||
await s.write();
|
||||
|
||||
for (const h of held) core.notice(`${h.grammar}: update to ${h.upstream} available — ${h.hold ? `report-only (${h.hold})` : `ABI ${h.abi ?? 'unknown'} (need 13/14), held until the tree-sitter runtime upgrade`}.`);
|
||||
if (!hasApp && (applied.length || skipped.some((x) => /secret/.test(x.reason)))) {
|
||||
core.notice('RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY not configured — update PRs were not opened. Provision the App to enable auto-PRs.');
|
||||
}
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
# Push auth is provided inline at push time via the URL.
|
||||
- name: Checkout PR head
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5.0.4
|
||||
with:
|
||||
repository: ${{ steps.locate.outputs.head_repo }}
|
||||
ref: ${{ steps.locate.outputs.head_sha }}
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# PR head commit (not the synthetic merge ref) — we need the
|
||||
# exact tree the contributor pushed so suggestions line up.
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
# Pinned to v7.2.0. Verify SHA via:
|
||||
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
|
||||
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
|
||||
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
|
||||
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
should_run: ${{ steps.decide.outputs.should_run }}
|
||||
head_sha: ${{ steps.decide.outputs.head_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -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-scope-parity, ci-report) reference zero `secrets.*` values;
|
||||
# ci-e2e, 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -349,7 +349,7 @@ jobs:
|
||||
|
||||
- name: Checkout (stable)
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
|
||||
# path performs no git pushes; the default scope is sufficient.
|
||||
with:
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -53,6 +53,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# Needed to open/update the tracking issue on scheduled runs.
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts/triage
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
@@ -45,15 +45,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Build image (load locally for scan)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
title = "GitNexus"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
# Fake embedding API keys in unit tests (current probe + historical placeholder).
|
||||
[allowlist]
|
||||
description = "fake embedding API keys in http-embedder unit tests"
|
||||
regexes = [
|
||||
'''secret-key-12345''',
|
||||
'''test-api-key-redaction-check''',
|
||||
]
|
||||
@@ -39,8 +39,7 @@ 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-resolution DAG (legacy path):** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`.
|
||||
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (see `registry-primary-flag.ts`). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR.
|
||||
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
|
||||
- **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.
|
||||
|
||||
@@ -81,18 +80,18 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||
|
||||
## Resources
|
||||
|
||||
@@ -174,6 +173,6 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind
|
||||
|
||||
### Gotchas
|
||||
|
||||
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift, builds tree-sitter-proto). Native bindings need `python3`, `make`, `g++`.
|
||||
- `tree-sitter-kotlin` and `tree-sitter-swift` are optional — install warnings expected.
|
||||
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (materializes the vendored grammars into `node_modules/`, then prefers a committed prebuild per platform-arch and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback.
|
||||
- The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin}` are handled uniformly: c is required; dart/proto/swift/kotlin are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable.
|
||||
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.
|
||||
|
||||
+35
-114
@@ -15,7 +15,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
|
||||
## End-to-end flow: index → graph → tools
|
||||
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 12 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 14 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
|
||||
2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
|
||||
|
||||
@@ -65,7 +65,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
| 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/MRO | `src/core/ingestion/call-processor.ts` + `model/resolve.ts` |
|
||||
| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) |
|
||||
| Type extraction | `src/core/ingestion/type-extractors/` |
|
||||
| Worker pool | `src/core/ingestion/workers/` |
|
||||
| Web UI | `gitnexus-web/src/` |
|
||||
@@ -77,11 +77,11 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
|
||||
## Pipeline Phase DAG
|
||||
|
||||
12 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
14 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
|
||||
```
|
||||
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
→ crossFile → mro → communities → processes
|
||||
→ crossFile → scopeResolution → pruneLocalSymbols → mro → communities → processes
|
||||
```
|
||||
|
||||
| Phase | File | Deps | Output |
|
||||
@@ -95,11 +95,13 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
|
||||
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
|
||||
| `mro` | `mro.ts` | `crossFile`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `communities` | `communities.ts` | `mro`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
|
||||
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
|
||||
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
|
||||
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `orm-extraction.ts` (sequential ORM fallback), `types.ts`, `runner.ts`, `index.ts`.
|
||||
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `types.ts`, `runner.ts`, `index.ts`.
|
||||
|
||||
### DAG runner
|
||||
|
||||
@@ -119,7 +121,8 @@ scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
|
||||
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
|
||||
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests). `skipWorkers` forces sequential parsing.
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
|
||||
- **Local-symbol pruning** — `pruneLocalSymbols` removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with `PipelineOptions.keepLocalValueSymbols` or globally with the `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var.
|
||||
|
||||
### How to add a new phase
|
||||
|
||||
@@ -147,105 +150,18 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
||||
|
||||
---
|
||||
|
||||
## Call-Resolution DAG
|
||||
## Semantic model
|
||||
|
||||
Typed 6-stage pipeline in `call-processor.ts` (inside the `parse` phase) that resolves method/function calls and emits CALLS edges. Language behavior plugs in at two `LanguageProvider` hook points (stages 3–4); shared code names no languages. Scope: call resolution only — import resolution, type extraction, heritage, and symbol-table population live in other phases.
|
||||
`SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). The scope-resolution pipeline reads from here: `findOwnedMember`, `pickOverload`, and `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`.
|
||||
|
||||
### 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`.
|
||||
`ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact the scope-resolution pipeline consumes. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
|
||||
|
||||
The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` for `Scope`-valued lookups (`classScopeByDefId`, `moduleScopeByFile`) that `SemanticModel` structurally cannot hold. No symbol-indexed duplicates exist outside `SemanticModel`.
|
||||
|
||||
**Write / read phase contract.** The model is mutable during three ordered phases and read-only afterward:
|
||||
|
||||
```
|
||||
Phase 1: legacy parse ──► symbolTable.add fans into types/methods/fields
|
||||
Phase 1: parse ──► symbolTable.add fans into types/methods/fields
|
||||
Phase 2: scope-resolution ──► reconcileOwnership() registers corrected ownerIds
|
||||
Phase 3: finalize ──► model.attachScopeIndexes(bundle) — one-shot freeze
|
||||
─────────────────────────── phase boundary ───────────────────────────
|
||||
@@ -255,7 +171,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.
|
||||
|
||||
**Transitional: reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose legacy extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose legacy extractor already carries `ownerId` (C#).
|
||||
**Reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose parse-time extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose extractor already carries `ownerId` (C#).
|
||||
|
||||
The architectural end state is for every language's parse-time extractor to emit the correct `ownerId` directly, making reconciliation a no-op (tracked as a follow-up refactor). The dev-mode validator `validateOwnershipParity` surfaces any drift via `onWarn` under `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`.
|
||||
|
||||
@@ -265,7 +181,7 @@ References: `semantic-model.ts` file-head (full write/read contract); `contract/
|
||||
|
||||
## Scope-Resolution Pipeline (RFC #909 Ring 3)
|
||||
|
||||
Language-agnostic registry-primary resolver. Replaces the Call-Resolution DAG for migrated languages. Adding a language is one interface implementation (`ScopeResolver`) plus two registrations — no changes to shared code, no new pipeline phase.
|
||||
Language-agnostic scope-resolution resolver. This is the resolution path for every language — it owns CALLS/ACCESSES/USES emission and inheritance edges. Adding a language is one interface implementation (`ScopeResolver`) plus one registration in the `SCOPE_RESOLVERS` map — no changes to shared code, no new pipeline phase. (RING4-1 #942 removed the legacy call-resolution DAG and the per-language `MIGRATED_LANGUAGES` flag, so `SCOPE_RESOLVERS` registration is all that's needed.)
|
||||
|
||||
### Pipeline stages
|
||||
|
||||
@@ -286,7 +202,11 @@ Language-agnostic registry-primary resolver. Replaces the Call-Resolution DAG fo
|
||||
```
|
||||
|
||||
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates `SCOPE_RESOLVERS ∩ MIGRATED_LANGUAGES`, reads per-file Trees from the parse phase's `scopeTreeCache`, disposes the cache at the end.
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` over the worker-serialized `ParsedFile`s. (Per-language `emitScopeCaptures` hooks may reuse a cached Tree via the orchestrator's `treeCache`, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted `ParsedFile` instead; § Performance notes.)
|
||||
|
||||
### Optional CFG/PDG emission (`--pdg`, #2081 M1)
|
||||
|
||||
On a `--pdg` run, the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript in M1) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits `BasicBlock` nodes + `CFG` edges from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the CFG emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and bounded by a per-function edge cap that logs any dropped edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides in the `CFG` relationship's `reason` (CFG is a single `CodeRelation` type, not one type per kind). See `core/ingestion/cfg/`.
|
||||
|
||||
### `ScopeResolver` contract
|
||||
|
||||
@@ -312,7 +232,6 @@ Single interface a language implements to plug into the pipeline. Contract fully
|
||||
|
||||
1. Implement `ScopeResolver` in `languages/<lang>/scope-resolver.ts`.
|
||||
2. Add entry to `SCOPE_RESOLVERS` in `scope-resolution/pipeline/registry.ts`.
|
||||
3. Add the language to `MIGRATED_LANGUAGES` in `registry-primary-flag.ts` when the shadow-harness corpus parity ≥ 99% fixtures / ≥ 98% corpus.
|
||||
|
||||
CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
|
||||
@@ -328,7 +247,6 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
| `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 |
|
||||
@@ -337,7 +255,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
|
||||
### Performance notes
|
||||
|
||||
- **Cross-phase Tree cache**: parse phase writes Trees into `scopeTreeCache` (separate from the chunk-local `astCache`) ONLY for languages with `emitScopeCaptures`. Scope-resolution reads from it to skip the second parse. Cleared at end of the phase. Workers leave the cache empty — Trees can't cross MessageChannels; cache miss = fresh parse. `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
|
||||
- **Cross-phase Tree cache**: the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) lets a scope-resolution per-language hook (`emitScopeCaptures`) reuse a tree instead of re-parsing. Workers leave it empty — Trees can't cross MessageChannels — so in normal (worker-pool) runs scope-resolution does NOT rely on it: workers serialize each file's `ParsedFile` (+ capture side-channel) and stream them in, so scope-resolution consumes the pre-extracted artifact rather than re-parsing on the main thread (§ Chunked parse-and-resolve). `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
|
||||
- **Typed relationship iteration**: heritage + MRO walk only the EXTENDS / IMPLEMENTS / HAS_METHOD edges via `iterRelationshipsByType`, not the full relationship map.
|
||||
- **Workspace-resolution-index**: O(1) `findOwnedMember` / `findExportedDef` / `classScopeByDefId` built once per run.
|
||||
- **SCC-ordered cross-file return-type propagation** (PR #1050): `propagateImportedReturnTypes` walks `indexes.sccs` in reverse-topological order (leaves first), so multi-hop alias chains like `models.User → service.user → app.user` collapse to the terminal class in a single linear pass. Within each importer, the source module's `typeBindings` is chain-followed BEFORE mirroring (so we mirror terminal types, not intermediate refs), and the importer's own `typeBindings` is chain-followed AFTER mirroring (so local `const x = importedFn()` resolves before downstream importers run). Cyclic SCCs reach a partial fixpoint within a single pass without iterating to convergence — see the `ts-circular` cross-file-binding fixture which only asserts pipeline-no-throw. PROF output (`PROF_SCOPE_RESOLUTION=1`) splits `finalize` from `propagate` so quadratic regressions in the chain-follow surface independently.
|
||||
@@ -351,7 +269,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
```
|
||||
Unified Graph Schema (44 node types, 21 relationship types)
|
||||
↑
|
||||
Unified Resolution (3-tier name lookup + MRO walk)
|
||||
Scope-Resolution Pipeline (registry lookup + 3-tier import resolution + MRO)
|
||||
↑
|
||||
Language Providers (import semantics, type config, export checker, MRO strategy)
|
||||
↑
|
||||
@@ -376,7 +294,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
||||
|
||||
### Unified capture tags
|
||||
|
||||
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@heritage.extends`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
|
||||
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@reference.inherits`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
|
||||
|
||||
### Import resolution
|
||||
|
||||
@@ -400,23 +318,26 @@ Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSe
|
||||
### Chunked parse-and-resolve
|
||||
|
||||
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
|
||||
1. Worker pool dispatches files (or sequential fallback via `skipWorkers`)
|
||||
1. Worker pool dispatches files (the sole parse path — there is no sequential fallback; `skipWorkers`, `--workers 0`, and `GITNEXUS_WORKER_POOL_SIZE=0` are rejected with an actionable error)
|
||||
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
|
||||
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
|
||||
4. Resolve imports and heritage
|
||||
4. Resolve imports
|
||||
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`.
|
||||
|
||||
### Heritage and MRO
|
||||
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the *sole* parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
|
||||
|
||||
All languages emit unified `ExtractedHeritage` (child, parent, `EXTENDS`/`IMPLEMENTS`). MRO phase walks the heritage graph using per-language strategy:
|
||||
### Inheritance and MRO
|
||||
|
||||
Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy:
|
||||
- **`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
|
||||
|
||||
@@ -4,6 +4,11 @@ All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Hook db-lock probe no longer strands unkillable `lsof`/`ps` orphans** — the probe's `lsof`/`ps` subprocesses are now wrapped in a self-tested coreutils `timeout`/`gtimeout` (`timeout -k 1 …`), so a hook SIGKILLed by the runner's 10s timeout can no longer leave `lsof` running forever (orphan lifetime bounded at ~3s); `acquireHookSlot` now also gates the probe itself, capping concurrent probes at 3 per repo. Opt out with `GITNEXUS_HOOK_TIMEOUT_PATH=disabled`. (#2163)
|
||||
- **Hook augment CLI no longer strands orphans either** — `runGitNexusCli` in the Claude, plugin, and Antigravity hook adapters now wraps the `gitnexus augment` subprocess (the longest-lived hook child: 7s local / 12s npx inner budgets) in the same self-tested coreutils `timeout` guard as the probe's `lsof`/`ps`, with a budget of ceil(inner/1000)+1 seconds — strictly above the inner `spawnSync` timeout, so on the supervised path Node's SIGTERM still fires first and observable behavior is unchanged. Once the hook itself has been SIGKILLed the guard takes over, with per-branch semantics: on the direct-exec branches (the CLI is the guard's child) it SIGTERMs at budget and `-k 1` SIGKILLs 1s later; on the npx branches (the CLI is a *grandchild* behind npx) it uses `-s KILL`, SIGKILLing the whole process group at budget — a TERM-first guard there would only kill the obedient npx parent and exit before its `-k` escalation fires, stranding a SIGTERM-immune CLI. Two npx-branch caveats remain (both no worse than the pre-fix behavior, where the grandchild received no signal at all): the group-wide KILL is coreutils semantics, so a busybox `timeout` — which passes the self-test — still signals only its direct child and cannot reach the grandchild; and on the supervised path (hook alive, inner `spawnSync` timeout SIGTERMs the guard) coreutils forwards TERM rather than KILL, so a SIGTERM-immune CLI grandchild is still not reaped there. The guard self-test now also requires exit-status propagation (`sh -c 'exit 42'` must yield 42), so an always-exit-0 stub at `GITNEXUS_HOOK_TIMEOUT_PATH` can no longer be adopted and silently swallow the probe and augment. Windows, `GITNEXUS_HOOK_TIMEOUT_PATH=disabled`, and Unix hosts with no usable coreutils `timeout`/`gtimeout` at all (e.g. macOS without Homebrew coreutils) keep the exact pre-wrap unguarded invocation — every guard-less Unix run, whatever the reason (disabled, nothing usable, probe version skew), is now diagnosed once per hook run under `GITNEXUS_DEBUG`. The Cursor hook is not wrapped yet (it does not install the probe helper) but now reports its slot-saturated skip under `GITNEXUS_DEBUG`. (#2163 follow-up)
|
||||
|
||||
### Changed
|
||||
- Migrated from KuzuDB to LadybugDB v0.15 (`@ladybugdb/core`, `@ladybugdb/wasm-core`)
|
||||
- Renamed all internal paths from `kuzu` to `lbug` (storage: `.gitnexus/kuzu` → `.gitnexus/lbug`)
|
||||
|
||||
@@ -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-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks instead (see AGENTS.md).
|
||||
- **Call & inheritance resolution:** See ARCHITECTURE.md § Scope-Resolution Pipeline. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` / `ScopeResolver` hooks instead (see AGENTS.md). (The legacy call-resolution DAG was removed in #942.)
|
||||
- **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
|
||||
|
||||
## Changelog
|
||||
@@ -62,18 +62,18 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
+8
-1
@@ -13,6 +13,8 @@ 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`
|
||||
@@ -155,7 +157,12 @@ routes between two modes based on the triggering event:
|
||||
suffix; RC tags are excluded at trigger via a negative glob). Publishes to
|
||||
the `latest` dist-tag with a changelog-backed GitHub release. Maintainers
|
||||
are expected to tag from `main` as a convention; the workflow itself does
|
||||
not enforce branch reachability. No Docker build (RC-only).
|
||||
not enforce branch reachability. No Docker build (RC-only). Before cutting a
|
||||
stable release, keep `gitnexus/package.json`,
|
||||
`gitnexus-claude-plugin/.claude-plugin/plugin.json`,
|
||||
`.claude-plugin/marketplace.json`, and the matching `CHANGELOG.md` entry in
|
||||
lockstep — the always-on `gitnexus` unit suite now fails if those manifest
|
||||
versions drift.
|
||||
- **Release-candidate mode** — runs on every push to `main` (typically a
|
||||
merged PR) plus manual `workflow_dispatch`. Docs-only changes are skipped
|
||||
via `paths-ignore`. Publishes to the `rc` dist-tag with version
|
||||
|
||||
@@ -36,6 +36,17 @@ RUN npm ci --prefix gitnexus
|
||||
# Drop dev dependencies for a smaller runtime layer.
|
||||
RUN npm prune --omit=dev --prefix gitnexus
|
||||
|
||||
# `npm prune` removes anything not in package.json's dependency tree — which
|
||||
# includes the VENDORED tree-sitter grammars (materialized into node_modules/ by
|
||||
# postinstall, but not declared as deps) and their freshly-built native bindings.
|
||||
# The `serve` image analyzes/parses uploaded repos at runtime, so those grammars
|
||||
# must survive into the runtime layer. Re-run the grammar postinstall here in the
|
||||
# builder (which still has python3/make/g++ and the hoisted node-addon-api /
|
||||
# node-gyp-build) to re-materialize + rebuild them after the prune. This is
|
||||
# load-bearing for tree-sitter-c (a core, REQUIRED grammar now vendored, #2116):
|
||||
# as a former `dependency` it used to survive prune; vendored, it would not.
|
||||
RUN npm run postinstall --prefix gitnexus
|
||||
|
||||
# -- Runtime -----------------------------------------------------------
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
@@ -67,6 +78,44 @@ COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
|
||||
# unreachable from $PATH.
|
||||
RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus
|
||||
|
||||
# Bake the LadybugDB FTS extension into the image so BM25 keyword search works
|
||||
# at runtime. The server runs the default `load-only` extension policy (the read
|
||||
# pool pins `{ policy: 'load-only' }`), so a runtime `LOAD EXTENSION fts` never
|
||||
# INSTALLs — the extension must already exist in the runtime user's HOME
|
||||
# extension dir, or every keyword search silently degrades (no FTS indexes are
|
||||
# written and ranking falls back to vector-only with only a `warning` field).
|
||||
# Run the installer as the `node` user with the SAME HOME the server runs under,
|
||||
# so `INSTALL fts` materializes the extension under `$HOME/.lbdb/extension` where
|
||||
# the runtime `LOAD` resolves it offline. `ENV HOME` is pinned because Docker
|
||||
# does not derive HOME from `USER`, so without it build-install and runtime-load
|
||||
# would resolve different paths. Requires network egress for the one-time
|
||||
# INSTALL; the build fails loudly if it cannot fetch the extension. The DB-size
|
||||
# default comes from GITNEXUS_LBUG_MAX_DB_SIZE (single source of truth, matches
|
||||
# the runtime) — it only sizes the throwaway scratch DB used to run INSTALL.
|
||||
# The second `--verify-only` step re-LOADs the extension in a FRESH process
|
||||
# under the same HOME, so a HOME/extension-dir mismatch fails the build here
|
||||
# rather than silently degrading keyword search to vector-only at runtime.
|
||||
ENV HOME=/home/node \
|
||||
GITNEXUS_LBUG_MAX_DB_SIZE=17179869184
|
||||
RUN su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts" \
|
||||
&& su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts --verify-only"
|
||||
|
||||
# Published runtime assets (in package.json `files`). Placed AFTER the DuckDB
|
||||
# FTS-extension RUN above so editing hook/skill content does not invalidate that
|
||||
# network-fetching cache layer; they have no input dependency on it.
|
||||
# `hooks/`: dist/cli/resolve-invocation.js does
|
||||
# `require('../../hooks/claude/resolve-analyze-cmd.cjs')` at module load — the
|
||||
# single source of truth for the npm-11 npx-crash invocation decision (#1939).
|
||||
# Without it, `gitnexus analyze` inside the image crashes with MODULE_NOT_FOUND
|
||||
# before it does any work (#2130). `skills/`: the CLI reads the bundled SKILL.md
|
||||
# templates from `<pkg>/skills/` for `gitnexus analyze --skills` and `gitnexus
|
||||
# setup`/`uninstall`; absent, those degrade silently (placeholder content / zero
|
||||
# skills installed). (The web UI bundle `web/`, also in `files`, is deliberately
|
||||
# NOT shipped: this builder never builds gitnexus-web, so the image is API-only;
|
||||
# the UI is the separate Dockerfile.web image / hosted app.)
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/hooks ./gitnexus/hooks
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/skills ./gitnexus/skills
|
||||
|
||||
USER node
|
||||
|
||||
# The web UI defaults to http://localhost:4747 - keep that contract.
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
<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>
|
||||
|
||||
@@ -114,11 +117,13 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
|
||||
|
||||
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
|
||||
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` — those four won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild. See the `tree-sitter-kotlin` note below.
|
||||
>
|
||||
> **About `tree-sitter-kotlin`:** like Dart/Proto/Swift, Kotlin is a **vendored** grammar (under `gitnexus/vendor/tree-sitter-kotlin`). Upstream `tree-sitter-kotlin` ships **source only** (no prebuilt binaries), so GitNexus builds the Kotlin platform prebuilds itself (via the `build-tree-sitter-prebuilds` GitHub Actions workflow) and vendors them — the same uniform pipeline now used for Dart, Proto, and Swift (Swift's prebuilds were originally copied from upstream; they're now GitNexus-cross-built too). `node-gyp-build` selects the right `.node` at require time, so **no C/C++ toolchain is needed**. If no prebuild matches your platform-arch, only Kotlin (`.kt`/`.kts`) parsing is unavailable; the rest of `gitnexus` is unaffected.
|
||||
|
||||
### MCP Setup
|
||||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`.
|
||||
|
||||
### Editor Support
|
||||
|
||||
@@ -219,19 +224,22 @@ args = ["-y", "gitnexus@latest", "mcp"]
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus setup # Configure MCP for detected editors (one-time; use -c to select)
|
||||
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
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 (default: cores-1, capped at 16; 0 = sequential)
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16, auto-sized to the repo). 0 is rejected — there is no sequential mode.
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
gitnexus list # List all indexed repositories
|
||||
@@ -254,6 +262,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
|
||||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
#### Embeddings node limit
|
||||
@@ -273,13 +283,43 @@ 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. `0` disables the pool (sequential fallback). Equivalent to `--workers <n>`. | Constrained containers (cgroup CPU limits), CI runners with explicit quotas, or debugging a worker-only crash via `0`. |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
@@ -293,7 +333,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD`| `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` at install time. | Installing on a host without a C++ toolchain or where Swift prebuilds don't match; you're willing to skip Dart/Proto/Swift parsing. |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
|
||||
|
||||
#### Publishing to understand-quickly (opt-in)
|
||||
|
||||
@@ -307,7 +347,7 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
|
||||
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ----------------- | ---------------------------------------------------------------- | ------------ |
|
||||
| `list_repos` | Discover all indexed repositories | — |
|
||||
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
|
||||
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
|
||||
@@ -663,6 +703,8 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
||||
|
||||
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
|
||||
|
||||
**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default.
|
||||
|
||||
---
|
||||
|
||||
## Tool Examples
|
||||
|
||||
+32
-42
@@ -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,21 +74,11 @@ When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to
|
||||
- **Resolver / parity** — Language-specific call-resolution tests in `test/integration/resolvers/`.
|
||||
- **E2E (web)** — Critical user paths only; prefer `data-testid` attributes for stable selectors. Tests run against real backend (`gitnexus serve`) and Vite dev server.
|
||||
|
||||
## Scope-resolution parity
|
||||
## Scope-resolution tests
|
||||
|
||||
Migrated languages (listed in `MIGRATED_LANGUAGES` in `src/core/ingestion/registry-primary-flag.ts`) are tested in both legacy and registry-primary modes on every PR.
|
||||
Every language resolves calls and inheritance through the scope-resolution pipeline — the legacy call-resolution DAG and the per-language `REGISTRY_PRIMARY_<LANG>` flag were removed in RING4-1 (#942). Each language's resolver test lives at `test/integration/resolvers/<slug>.test.ts` and runs once, on the single scope-resolution path, as part of the normal `tests` job (`vitest test/**/*.test.ts`).
|
||||
|
||||
For each migrated language, CI runs the resolver test file twice:
|
||||
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`
|
||||
Adding a language: register its `ScopeResolver` in `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`) and add the resolver test file — no workflow or config edit needed.
|
||||
|
||||
## Cross-platform testing
|
||||
|
||||
@@ -120,12 +110,12 @@ To check the cross-platform list is up to date, run `npm run test:cross-platform
|
||||
|
||||
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
+106
-90
@@ -21,7 +21,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.4"
|
||||
version = "3.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -30,95 +30,111 @@ 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/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/61/d11f7d9a3144bffe825247d6367cd93053666da50b94707c9129c78868d5/aiohttp-3.14.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:25400d710641a8040bf022a8a99f579e581ffa1c5bd42c33255d7d6f3957c127", size = 502399, upload-time = "2026-06-01T19:38:25.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/9b/a7e317625d36356844f8bb022cabd305b541f968856cc3c2e0b58e53ee6e/aiohttp-3.14.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:c5492b9929826e07cc3fcb9739ae87aab05dff6b5e67a9b73fd1700c6d008981", size = 510068, upload-time = "2026-06-01T19:38:27.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/41/cc2d2cfbfbdc3126ba258f3cd27d1ac8a33492ae3c35a4583ee21f0ba7f1/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3366751d68d237c621264233a32f3078bbc21b7904ab90a77e03d21390c742c6", size = 481670, upload-time = "2026-06-01T19:38:29.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/07/381f4023c3b08cb616e520f566d8c58957abad54e56441d41fe67cfb0195/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:57ea07d28695a7a40304d42251892a8df765e5588c10ee32afeddcd5df33c0a2", size = 487591, upload-time = "2026-06-01T19:38:31.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/4d/4506fdb7a022bdf70011a3bbb4ca00c5c570026ef6a3c5bd7bc70c39089c/aiohttp-3.14.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:076cb014191ae2e65d949e1ad01f1dcfe33e32789b5172510f3e79c79fc04d50", size = 496503, upload-time = "2026-06-01T19:38:33.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/7d/c814111e04894a45d9e2defc94443879a6f118d9633d5fedfe6e2e8af5f0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f3fc37054564dee64a855b5b092d87ec35dcddfaabf7dacb1c8a2b1f83dc0a9", size = 745870, upload-time = "2026-06-01T19:38:36.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/ee/80eee0efddfe187e7cd05027086b7ce1c0e492e82a4eda58f5c5543a44a0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8fcaef74d2ab0f607d7ff85a0d15e21bb5a258c4a58df1908396eb50d7f4ed3c", size = 505588, upload-time = "2026-06-01T19:38:38.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f8/0f28f04eef75d52fc9c715dde7ce9c0abb810fd20cfeb0fea7afd2ab1e98/aiohttp-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4c01b0bfc6209590960e68eac083cd22d5d87c21f974dd6208cafa5d3542bc8", size = 504492, upload-time = "2026-06-01T19:38:40.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/db/44c755232085545065c94378dfce38641b1aee647f4939fcd32f5b32e719/aiohttp-3.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f12eb7896e81caf403a2b18c9406426f1207361e7239c057ab29c076d4257e83", size = 1752111, upload-time = "2026-06-01T19:38:42.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/6a/42e030a46743841414402a3b00cd3d78419055e86c66fb5822c14b5abfc6/aiohttp-3.14.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c79a044cacf360ec46738d863d2f41c9300d2a06ef4a7402ea0df306a350e61", size = 1729674, upload-time = "2026-06-01T19:38:44.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/26/3199beb415202e3108e7b83ecebe10914d806d33fb9860c3e4aa60a19be3/aiohttp-3.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85e0675f47be4eff0636bf88c02140ea89168ae0df3ff1f3f464e9de9610d277", size = 1798808, upload-time = "2026-06-01T19:38:47.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/94/b9b6fcf0ee17c21d0d19fb8c22bf83ad18f82e702a9c3bd901a868f5e446/aiohttp-3.14.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b33e751cab03fdc960095b1e326cb5a03f5ee577d6ded59f3d1c100f8668882", size = 1891921, upload-time = "2026-06-01T19:38:49.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a3/3800dbd095cb2bb165a7ea5d94d790914677e27f45638c7d80e3f34c8945/aiohttp-3.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9224c6dd7f5c749aba4f61315a894601448b28d94d12f4dea0903e26d2096", size = 1777241, upload-time = "2026-06-01T19:38:52.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/2a/45be91ad1b860508557448d4cc2e165a2ee68dd865657b73bf66cc5a00fb/aiohttp-3.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6281aecdf2732940f4fe06bd6adec5ae4d59b78b080b8e3a6b81467301010988", size = 1579554, upload-time = "2026-06-01T19:38:54.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/3d/dc94df99ed1511fdf28314f722643ed334112643cab00223577085e788c4/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23e8314e7aed8576fbe33314d218bd81447a3adbc91dc36f1163bf583cd3084c", size = 1714864, upload-time = "2026-06-01T19:38:56.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/e4/1f1c8acbb3acd5c8f795473b92c9c3d44eb60a5692c6104256c8a1c83a0c/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3b54fbff46127aeafdd764cecd0d99fa2f24a0e37ea5c18a7c3a4ac450df1db3", size = 1749803, upload-time = "2026-06-01T19:38:59.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c8/c45ea6e7ed84cebba939b9c334498a045ba19d79c61b0110df5f21580de3/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b27d89af91a555f58e08e4902dbcbc48862fd40095720ca705990476bd93b7ac", size = 1765023, upload-time = "2026-06-01T19:39:01.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a1/a932941784432962fe390e1066823aaef64b4e5ac9fa595df57b5fe472a9/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:25d2326a4967bf705a9f9913a13005e93b6020ad8a9f6bd6bd78850d5171332e", size = 1571671, upload-time = "2026-06-01T19:39:04.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/01/e1280feac522597a4d46eb67a0cdfa053cfae263033030b761ab146f29fb/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a1d209375c503472b3c0a340cdf3c55fcd82e84b46dda7caeaced59faba373ec", size = 1789904, upload-time = "2026-06-01T19:39:06.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/10/ab28818262f4d26bdb47ed5f1fc7999b69e2fc6e0370b02d0f49011f45ea/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:666c7c5036df57b693026398b69b41874a1931ac5b3485fd910e57bfac253869", size = 1754516, upload-time = "2026-06-01T19:39:08.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/cc/c122eabd7a1b7e0c9bbdd6be60e4715905b858399145d9df872bb94f1427/aiohttp-3.14.0-cp313-cp313-win32.whl", hash = "sha256:23f094a1ef64823fd35854ddf5c7a80a078162f37f9d2f7c6142b51a6affa456", size = 448656, upload-time = "2026-06-01T19:39:11.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/a5/bab07d79848a00eedd8ed979ccb302aaea3ac6eb9fa16bd0ed87135869b4/aiohttp-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e03abdaa17d553f17e1d1d06bb266b3970106c78051d06795723e748d8e49d11", size = 475803, upload-time = "2026-06-01T19:39:13.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/f03ade8566c153666a3871afccbedf6d99911da006325e1fc6cf72a2de99/aiohttp-3.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:acdb400538cf4769543548bb5d1eb23d39bed4f96554a6078cb728c7cb2c268b", size = 443889, upload-time = "2026-06-01T19:39:15.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -926,7 +942,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.83.14"
|
||||
version = "1.87.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -942,9 +958,9 @@ dependencies = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[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.3.6",
|
||||
"version": "1.6.7",
|
||||
"author": {
|
||||
"name": "GitNexus"
|
||||
},
|
||||
|
||||
@@ -15,7 +15,10 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.js');
|
||||
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
|
||||
const {
|
||||
hasGitNexusDbLockedByGitNexusServer,
|
||||
resolveUnixGuardTimeout,
|
||||
} = require('./hook-db-lock-probe.cjs');
|
||||
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
|
||||
|
||||
/**
|
||||
@@ -110,10 +113,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
|
||||
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
|
||||
* non-error skip paths must stay silent unless the operator explicitly asks
|
||||
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
|
||||
*/
|
||||
function isDebugEnabled() {
|
||||
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
const debug = isDebugEnabled();
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
|
||||
@@ -186,18 +199,90 @@ function extractPattern(toolName, toolInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
|
||||
// at most one line per (short-lived) hook process, even if a future change
|
||||
// runs the CLI more than once.
|
||||
let unguardedCliWarned = false;
|
||||
|
||||
/**
|
||||
* Spawn a gitnexus CLI command synchronously.
|
||||
* Detects binary on PATH once, then runs exactly once.
|
||||
*
|
||||
* SECURITY: Never use shell: true with user-controlled arguments.
|
||||
* On Windows, invoke gitnexus.cmd directly (no shell needed).
|
||||
*
|
||||
* Unix orphan containment (#2163 follow-up): the augment CLI is the
|
||||
* longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
|
||||
* npx), so on Unix every CLI-running branch gets the same SIGKILL-surviving
|
||||
* coreutils `timeout` wrapper as the probe's lsof/ps (the cheap which/where
|
||||
* PATH check stays unwrapped). The wrapper budget is ceil(inner/1000)+1
|
||||
* seconds — STRICTLY greater than the inner spawnSync timeout, so on the
|
||||
* supervised path Node's SIGTERM always fires first and the existing
|
||||
* error/status contract is untouched. Once the hook itself has been
|
||||
* SIGKILLed (exactly the orphan case the wrapper exists for), the guard
|
||||
* semantics differ per branch:
|
||||
* - direct exec (GITNEXUS_HOOK_CLI_PATH / PATH-installed `gitnexus`; the
|
||||
* CLI is the guard's CHILD): `-k 1` TERM-first — a SIGTERM-immune CLI
|
||||
* can hold the guard ~1s past the inner timeout before the `-k` SIGKILL
|
||||
* escalation reaps it.
|
||||
* - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
|
||||
* budget expiry SIGKILLs the whole process group outright. TERM-first
|
||||
* would kill only the obedient npx parent, making `timeout` reap it and
|
||||
* return before the `-k` escalation ever fires, stranding a
|
||||
* SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
|
||||
* 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
|
||||
* `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
|
||||
* on this branch, both bounded by "no worse than pre-fix" (where the
|
||||
* grandchild received no signal at all): the group-wide SIGKILL is
|
||||
* coreutils semantics — a busybox `timeout` passes the self-test (it
|
||||
* has `-k` and propagates exit status) but signals only its direct
|
||||
* child, so a busybox guard cannot reach the grandchild; and on the
|
||||
* SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
|
||||
* guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
|
||||
* and the guard exits before any KILL fires — so a SIGTERM-immune CLI
|
||||
* grandchild still escapes in those two cases.
|
||||
* If the sibling probe predates the resolveUnixGuardTimeout export (version
|
||||
* skew), the adapter degrades to the unwrapped invocation instead of
|
||||
* throwing. Windows is deliberately NOT wrapped — there is no coreutils
|
||||
* timeout to resolve there and the resolver's self-test spawns /bin/sh — so
|
||||
* on win32 (the gitnexus.cmd / npx.cmd paths) and whenever the guard
|
||||
* resolves to null (e.g. macOS without Homebrew coreutils — reported once
|
||||
* under GITNEXUS_DEBUG) the argv stays byte-identical to the pre-wrap
|
||||
* invocation.
|
||||
*/
|
||||
function runGitNexusCli(args, cwd, timeout) {
|
||||
const isWin = process.platform === 'win32';
|
||||
// Version-skew guard (#2163 follow-up review): an older sibling probe
|
||||
// without the resolveUnixGuardTimeout export must degrade to the unwrapped
|
||||
// invocation — a TypeError here would be swallowed by the caller's catch
|
||||
// and silently kill the augment.
|
||||
const guard =
|
||||
isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
|
||||
if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
|
||||
// Diagnose the "stays unwrapped" Unix paths once per hook process: no
|
||||
// usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
|
||||
// coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
|
||||
unguardedCliWarned = true;
|
||||
process.stderr.write(
|
||||
'[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
|
||||
);
|
||||
}
|
||||
const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH;
|
||||
if (hookCli !== undefined && String(hookCli).trim() && fs.existsSync(String(hookCli))) {
|
||||
return spawnSync(process.execPath, [String(hookCli), ...args], {
|
||||
const [cmd, cmdArgs] = guard
|
||||
? [
|
||||
guard,
|
||||
[
|
||||
'-k',
|
||||
'1',
|
||||
String(Math.ceil(timeout / 1000) + 1),
|
||||
process.execPath,
|
||||
String(hookCli),
|
||||
...args,
|
||||
],
|
||||
]
|
||||
: [process.execPath, [String(hookCli), ...args]];
|
||||
return spawnSync(cmd, cmdArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
@@ -221,7 +306,12 @@ function runGitNexusCli(args, cwd, timeout) {
|
||||
}
|
||||
|
||||
if (useDirectBinary) {
|
||||
return spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, {
|
||||
// A non-null guard implies non-Windows, so the wrapped arm can hardcode
|
||||
// plain `gitnexus` (the guard resolves it via PATH, like spawnSync does).
|
||||
const [cmd, cmdArgs] = guard
|
||||
? [guard, ['-k', '1', String(Math.ceil(timeout / 1000) + 1), 'gitnexus', ...args]]
|
||||
: [isWin ? 'gitnexus.cmd' : 'gitnexus', args];
|
||||
return spawnSync(cmd, cmdArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
@@ -229,8 +319,27 @@ function runGitNexusCli(args, cwd, timeout) {
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
// npx fallback needs shell on Windows since npx is a .cmd script
|
||||
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
|
||||
// npx fallback needs shell on Windows since npx is a .cmd script. The
|
||||
// wrapped arm leads with `-s KILL` (NOT TERM-first like the direct
|
||||
// branches above): the CLI here is a grandchild behind npx — see the
|
||||
// docblock.
|
||||
const [cmd, cmdArgs] = guard
|
||||
? [
|
||||
guard,
|
||||
[
|
||||
'-s',
|
||||
'KILL',
|
||||
'-k',
|
||||
'1',
|
||||
String(Math.ceil((timeout + 5000) / 1000) + 1),
|
||||
'npx',
|
||||
'-y',
|
||||
'gitnexus',
|
||||
...args,
|
||||
],
|
||||
]
|
||||
: [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
|
||||
return spawnSync(cmd, cmdArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
@@ -266,16 +375,34 @@ function handlePreToolUse(input) {
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
|
||||
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
||||
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
||||
// as the augment, or concurrent sessions fan out unbounded probe
|
||||
// subprocesses. Keep the acquire right after the cheap guards above —
|
||||
// moving it earlier would churn slot files on tool calls that never probe.
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
||||
// the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
||||
let result = '';
|
||||
try {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
// Normal skip path: the MCP server owns the DB, so the CLI augment would
|
||||
// contend on the lock. Stay silent for strict hook runners (issue #1913);
|
||||
// surface the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = extractAugmentContext(child.stderr || '');
|
||||
@@ -366,7 +493,7 @@ function main() {
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
if (isDebugEnabled()) {
|
||||
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,27 @@
|
||||
*
|
||||
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
|
||||
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
|
||||
*
|
||||
* Unix subprocess containment contract (#2163):
|
||||
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
|
||||
* wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
|
||||
* is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
|
||||
* survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
|
||||
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
|
||||
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
|
||||
* wrapper off deterministically; any other value is adopted only when it
|
||||
* exists AND passes a one-shot `-k` exit-propagation self-test — otherwise
|
||||
* resolution FALLS THROUGH to the built-in candidate list (first self-test
|
||||
* pass wins), so no malformed value of any shape can silently disable
|
||||
* orphan containment.
|
||||
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
|
||||
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
|
||||
* A probe before that first query is therefore always false — a known,
|
||||
* pre-existing race, not a bug in this probe.
|
||||
* - resolveUnixGuardTimeout is exported so the hook adapters can wrap the
|
||||
* `gitnexus augment` CLI child — the longest-lived hook subprocess (7s
|
||||
* local / 12s npx inner budgets) — in the same guard; see runGitNexusCli
|
||||
* in the adapters (#2163 follow-up).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -46,6 +67,95 @@ function resolveHookBinary(tool) {
|
||||
return tool;
|
||||
}
|
||||
|
||||
// Sentinel:
|
||||
// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
|
||||
// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
|
||||
// null = no usable wrapper (disabled, none found, or self-test failed)
|
||||
let unixGuardTimeoutCache;
|
||||
|
||||
/**
|
||||
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
|
||||
* (#2163). Unix-only by contract: the probe's win32 dispatch returns before
|
||||
* reaching it, and the exported callers (the adapters' runGitNexusCli,
|
||||
* #2163 follow-up) must check the platform first — the self-test below
|
||||
* spawns /bin/sh. The memoized result is module-wide, so probe and adapter
|
||||
* share one lazy self-test per hook process.
|
||||
*
|
||||
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
|
||||
* wrapper off; any other value is only a CANDIDATE — an existing file path
|
||||
* is tried first, but it must pass the `-k` exit-propagation self-test to
|
||||
* be adopted. On any failure (non-existent path, directory, non-executable
|
||||
* file, wrapper without `-k` support, always-exit-0 stub, …) resolution
|
||||
* falls through to the built-in candidates below, tried in order, first
|
||||
* self-test pass wins. This is strictly stronger than the sibling
|
||||
* GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only
|
||||
* check existence): no bad env value of ANY shape can silently disable
|
||||
* orphan containment.
|
||||
*
|
||||
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
|
||||
* first reached, and the result is memoized. A candidate is adopted only
|
||||
* when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 — i.e. it must RUN
|
||||
* the wrapped command AND PROPAGATE its exit status. This rejects two
|
||||
* failure shapes: wrappers without the coreutils `-k` flag — busybox <1.34,
|
||||
* toybox, broken symlinks — which would exit with a usage error without
|
||||
* ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed
|
||||
* contract into fail-open (#1492 regression); and always-exit-0 stubs
|
||||
* (/bin/true shapes), which would otherwise be adopted and "succeed" every
|
||||
* wrapped spawn instantly without running it — a constant no-owner probe
|
||||
* answer and, worse, a silently dead augment (status 0, empty stderr passes
|
||||
* the adapters' success check with no context; #2163 follow-up review).
|
||||
* Only when EVERY candidate fails does the probe fall back to the unwrapped
|
||||
* status quo (memoized null). busybox ≥1.34 passes the test and is fully
|
||||
* usable for everything THIS file spawns (lsof/ps are the guard's direct
|
||||
* children) and for the adapters' direct-exec arm. The adapters' npx arm
|
||||
* additionally relies on coreutils' process-GROUP signalling for its
|
||||
* `-s KILL` grandchild reaping; busybox signals only its direct child, and
|
||||
* this self-test deliberately does not probe that capability — see the
|
||||
* adapter docblocks for the residual-gap statement.
|
||||
*/
|
||||
function passesGuardSelfTest(guard) {
|
||||
try {
|
||||
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return !selfTest.error && selfTest.status === 42;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUnixGuardTimeout() {
|
||||
if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
|
||||
unixGuardTimeoutCache = null;
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
|
||||
const trimmed = fromEnv ? String(fromEnv).trim() : '';
|
||||
if (trimmed === 'disabled') return unixGuardTimeoutCache;
|
||||
const candidates = [];
|
||||
if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
|
||||
for (const builtin of [
|
||||
'/usr/bin/timeout',
|
||||
'/bin/timeout',
|
||||
'/opt/homebrew/bin/gtimeout',
|
||||
'/usr/local/bin/gtimeout',
|
||||
]) {
|
||||
try {
|
||||
if (fs.existsSync(builtin)) candidates.push(builtin);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (passesGuardSelfTest(candidate)) {
|
||||
unixGuardTimeoutCache = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return unixGuardTimeoutCache;
|
||||
}
|
||||
|
||||
function resolveWindowsPowerShellPath() {
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
|
||||
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
|
||||
@@ -188,20 +298,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
|
||||
}
|
||||
|
||||
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
const guard = resolveUnixGuardTimeout();
|
||||
const lsofPath = resolveHookBinary('lsof');
|
||||
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
|
||||
// The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
|
||||
// SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
|
||||
// SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
|
||||
// fail-closed contract are untouched. The wrapper only matters once this
|
||||
// hook process has been SIGKILLed and can no longer deliver that SIGTERM.
|
||||
const [lsofCmd, lsofArgs] = guard
|
||||
? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
|
||||
: [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
|
||||
const lsof = spawnSync(lsofCmd, lsofArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
|
||||
// Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
|
||||
// result shapes, verified against coreutils 9.1:
|
||||
// - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
|
||||
// SELF-RAISES the signal, so spawnSync reports {status: null, signal}
|
||||
// with no .error (spawnSync's own ETIMEDOUT was handled above). The
|
||||
// same shape appears when this hook is frozen >2s (SIGSTOP, laptop
|
||||
// suspend) and the guard expires while it sleeps. By construction, a
|
||||
// guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
|
||||
// is a budget/kill outcome.
|
||||
// - 124: budget expired and the child exited after the plain SIGTERM.
|
||||
// - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
|
||||
// or a child SIGKILLed externally (e.g. the OOM killer).
|
||||
if (guard && lsof.status === null && lsof.signal) return true;
|
||||
if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
|
||||
|
||||
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
|
||||
const psPath = resolveHookBinary('ps');
|
||||
for (const pid of pids) {
|
||||
if (Number(pid) === myPid) continue;
|
||||
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
|
||||
const [psCmd, psArgs] = guard
|
||||
? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
|
||||
: [psPath, ['-p', pid, '-o', 'command=']];
|
||||
const ps = spawnSync(psCmd, psArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 500,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
@@ -211,6 +347,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
if (ps.error.code === 'ETIMEDOUT') return true;
|
||||
continue;
|
||||
}
|
||||
// Same guard-mediated-death mapping as the lsof call above (signal-death
|
||||
// from the -k escalation or a frozen hook; 124 budget expiry; 137 only
|
||||
// for exit-code-propagating wrappers / external SIGKILL).
|
||||
if (guard && ps.status === null && ps.signal) return true;
|
||||
if (guard && (ps.status === 124 || ps.status === 137)) return true;
|
||||
if (isGitNexusServerCommand(ps.stdout || '')) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -238,4 +379,16 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
|
||||
|
||||
module.exports = {
|
||||
hasGitNexusDbLockedByGitNexusServer,
|
||||
// #2163 follow-up: the hook adapters wrap the augment CLI in the same
|
||||
// guard. Returns a self-tested wrapper path — the built-in candidates are
|
||||
// always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
|
||||
// exact string that passed the self-test. Same string is also the same
|
||||
// RESOLUTION for absolute paths and for slashless names (PATH lookup is
|
||||
// cwd-independent); a slash-containing RELATIVE override, however, is
|
||||
// existsSync-checked and self-tested against this process's cwd while the
|
||||
// adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
|
||||
// a value can pass here yet ENOENT at the augment call site — set the
|
||||
// override to an absolute path. Returns null when the wrapper is
|
||||
// disabled/unavailable. Never call on win32 (see its JSDoc).
|
||||
resolveUnixGuardTimeout,
|
||||
};
|
||||
|
||||
@@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
1. query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
4. cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
@@ -28,11 +28,11 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
@@ -40,7 +40,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Error message | `query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
@@ -48,24 +48,24 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
**query** — find code related to error:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
**context** — full context for a suspect:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
**cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
@@ -75,11 +75,11 @@ RETURN [n IN nodes(path) | n.name] AS chain
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
1. query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
2. context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
|
||||
@@ -18,8 +18,8 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
3. query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
@@ -29,9 +29,9 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
@@ -47,18 +47,18 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
**query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
**context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
@@ -68,10 +68,10 @@ gitnexus_context({name: "validateUser"})
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
2. query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
3. context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
|
||||
@@ -38,7 +38,49 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
|
||||
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
|
||||
|
||||
### Paginating `list_repos`
|
||||
|
||||
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"repositories": [
|
||||
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
|
||||
],
|
||||
"pagination": {
|
||||
"total": 437,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"returned": 50,
|
||||
"hasMore": true,
|
||||
"nextOffset": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
|
||||
|
||||
```text
|
||||
list_repos {} → repos 1–50, nextOffset 50, hasMore true
|
||||
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
|
||||
…
|
||||
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
|
||||
```
|
||||
|
||||
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
|
||||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
|
||||
|
||||
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
|
||||
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
|
||||
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
|
||||
|
||||
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
|
||||
|
||||
## Resources Reference
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ description: "Use when the user wants to know what will break if they change som
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
1. impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
3. detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
@@ -28,11 +28,11 @@ description: "Use when the user wants to know what will break if they change som
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
@@ -55,10 +55,10 @@ description: "Use when the user wants to know what will break if they change som
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
**impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
gitnexus_impact({
|
||||
impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
@@ -73,10 +73,10 @@ gitnexus_impact({
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
**detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
@@ -86,7 +86,7 @@ gitnexus_detect_changes({scope: "staged"})
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
1. impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
@@ -32,10 +32,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] detect_changes to map changes to affected execution flows
|
||||
- [ ] impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
@@ -63,20 +63,20 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
**detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
**impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
@@ -86,20 +86,20 @@ gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
**impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
**context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
@@ -112,20 +112,20 @@ gitnexus_context({name: "validatePayment"})
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
2. detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
3. impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
4. impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
5. context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
|
||||
@@ -16,9 +16,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
1. impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. query({query: "X"}) → Find execution flows involving X
|
||||
3. context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
@@ -29,65 +29,65 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
**rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
**impact** — map all dependents first:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
**detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
**cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
@@ -98,24 +98,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Many callers (>5) | Use rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| String/dynamic refs | query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
4. detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: gitnexus-taint-analysis
|
||||
description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
|
||||
---
|
||||
|
||||
# CFG & Taint Analysis with GitNexus
|
||||
|
||||
Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
|
||||
graphs, reaching definitions, and intra- + inter-procedural taint. Read this
|
||||
before touching `gitnexus/src/core/ingestion/cfg/**` or
|
||||
`gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
|
||||
|
||||
## When to Use
|
||||
|
||||
- "How does the taint engine work / why is this flow (not) reported?"
|
||||
- Adding a source, sink, or sanitizer to the model.
|
||||
- Extending or reviewing the CFG / reaching-defs / taint / summary code.
|
||||
- Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
|
||||
- Debugging a false positive or false negative in `--pdg` output.
|
||||
|
||||
## The layered substrate (build order)
|
||||
|
||||
Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
|
||||
and a default `analyze` run is **byte-identical** (the golden parity gate is the
|
||||
hard floor for every change here).
|
||||
|
||||
```
|
||||
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
|
||||
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
|
||||
L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
|
||||
L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
|
||||
```
|
||||
|
||||
- **Worker-built, main-thread-solved.** The parse worker builds each function's
|
||||
CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
|
||||
(plain, structured-clone-safe data — never AST nodes). The main thread runs
|
||||
the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
|
||||
OOM).
|
||||
- **In-phase emit (KTD1).** L1–L4-harvest all run INSIDE the scope-resolution
|
||||
pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
|
||||
because the disk-backed ParsedFile store is cleared when that phase ends — a
|
||||
standalone post-`mro` phase would read empty data. The cross-function fixpoint
|
||||
(L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
|
||||
AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
|
||||
small plain summary data threaded out via `ScopeResolutionOutput`.
|
||||
- **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
|
||||
`harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
|
||||
(no graph, no I/O, no logger; sorted outputs). Snapshot tests and
|
||||
content-derived edge ids depend on it.
|
||||
|
||||
## Intra-procedural taint (L3)
|
||||
|
||||
Forward reachability over RD facts from matched **sources** to matched **sinks**,
|
||||
killed by **sanitizers**. Key design points worth internalizing:
|
||||
|
||||
- **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
|
||||
`exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
|
||||
call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
|
||||
precise.
|
||||
- **Kind-set sanitizer model.** A taint carries a set of *neutralized*
|
||||
`SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
|
||||
suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
|
||||
kill would be a suppressed live injection (the forbidden FN direction).
|
||||
`path.basename(t)` neutralizes path-traversal only, not command-injection.
|
||||
- **Statement-level finding identity.** NOT block-pair (block conflation drops
|
||||
distinct findings; `exec(req.body, req.query)` is two findings).
|
||||
- Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
|
||||
`reason` column via the shared versioned codec (`taint/path-codec.ts`).
|
||||
|
||||
## Interprocedural taint (L4) — the functional/summary method
|
||||
|
||||
The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
|
||||
Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
|
||||
reduced to a compact **summary**, and summaries are composed over the already-
|
||||
resolved `CALLS` graph.
|
||||
|
||||
**Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
|
||||
|
||||
| Edge | Meaning | Analogue |
|
||||
|------|---------|----------|
|
||||
| `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
|
||||
| `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
|
||||
| `param→sink` | a param reaches a modelled sink | partial/triggered sink |
|
||||
| `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
|
||||
| `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
|
||||
| `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
|
||||
|
||||
**The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
|
||||
parameter, source)`. Seed from `source→callee-arg`, propagate via
|
||||
`param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
|
||||
|
||||
- **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
|
||||
lattice (`fn × param × source`), so the worklist converges — a recursive call
|
||||
just re-proposes an already-visited entry. SCC condensation would only refine
|
||||
processing order; correctness/termination don't require it.
|
||||
- **Source-discriminated state (load-bearing).** Key the state by the SOURCE
|
||||
too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
|
||||
tainted by source A is marked visited and a later flow from source B is dropped
|
||||
before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
|
||||
- **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
|
||||
CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
|
||||
site) is fragile; the callee identity is exact and context-insensitivity
|
||||
taints the callee's param identically at every call site.
|
||||
- Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
|
||||
in `reason` via the same codec; confidence < the intra-procedural 1.0.
|
||||
|
||||
**Context-insensitivity** is the accepted trade-off at this tier: one summary
|
||||
per function, return/call-site merging accepted (security-conservative). Expect
|
||||
some FP from merging; the bigger FN sources are unmodeled features (below).
|
||||
|
||||
## Known false-negative classes (documented, deferred)
|
||||
|
||||
The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
|
||||
into a callback is dropped without per-library models (true of CodeQL's JS libs
|
||||
too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
|
||||
field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
|
||||
flows, promise/async-await threading, and **destructured/rest params before a
|
||||
tainted simple param** (the summary port index is the binding ordinal, not the
|
||||
formal arg position — needs a formal-param index threaded from the worker
|
||||
`BindingEntry`). The interprocedural join is also context-insensitive: when one
|
||||
caller invokes two distinct **same-named callees**, a flow into one
|
||||
over-attributes to both (sound — over-report, never a missed flow). Absence of a
|
||||
finding is NOT proof of safety.
|
||||
|
||||
## GitNexus-specific gotchas
|
||||
|
||||
- **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
|
||||
`Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
|
||||
have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
|
||||
ambiguous → drop (the summary driver counts `unresolved`) rather than
|
||||
cross-wire.
|
||||
- **No rel-property index (S1).** Kuzu has no secondary index on relationship
|
||||
properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
|
||||
TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
|
||||
traversed live; `explain` reads it source-anchored + LIMIT-guarded.
|
||||
- **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
|
||||
deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
|
||||
schema (pinned in `security.test.ts`). `explain` enumerates both layers
|
||||
(cross-function findings carry `interprocedural: true`).
|
||||
- **One shared codec.** Both the emit path and `explain` import
|
||||
`taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
|
||||
fork it. New metadata extends the format WITHIN the version when writer +
|
||||
reader ship together.
|
||||
- **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
|
||||
NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
|
||||
Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
|
||||
full writeback. Model content rides `taintModelVersion`.
|
||||
|
||||
## Adding a source / sink / sanitizer
|
||||
|
||||
Edit the language model in `taint/typescript-model.ts` (registered via the
|
||||
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
|
||||
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
|
||||
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
|
||||
finding (or its absence) in `test/unit/taint/` (real-source harness:
|
||||
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
|
||||
`test/integration/cfg/`.
|
||||
|
||||
## Validation checklist for any `--pdg` change
|
||||
|
||||
```
|
||||
1. tsc clean (schema additions are exhaustiveness-checked; watch the
|
||||
api.ts getNodeQuery runtime read-path if a node label is added).
|
||||
2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
|
||||
test/integration/cfg) — verify by ISOLATION, not full-suite exit
|
||||
(known load-flakes). `node scripts/build.js` before worker/integration runs.
|
||||
3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
|
||||
4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
|
||||
5. detect_changes() before commit; impact({direction:'upstream'}) before
|
||||
editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
|
||||
```
|
||||
|
||||
## Prior art (for deeper design questions)
|
||||
|
||||
Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
|
||||
FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
|
||||
propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
|
||||
notation, incl. callback ports); Infer (content-keyed incremental summaries).
|
||||
@@ -241,7 +241,18 @@ function main() {
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stays silent by default; surfaced only under the cursor
|
||||
// hook's own GITNEXUS_DEBUG (truthy) convention. NOTE: unlike the
|
||||
// claude/plugin/antigravity adapters this integration does not install
|
||||
// hook-db-lock-probe.cjs, so its augment child is not guard-wrapped
|
||||
// yet — tracked on the #2163 follow-up list ("cursor probe").
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
|
||||
@@ -15,10 +15,10 @@ description: Trace bugs through call chains using knowledge graph
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
1. query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
4. cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
@@ -27,11 +27,11 @@ description: Trace bugs through call chains using knowledge graph
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
@@ -39,7 +39,7 @@ description: Trace bugs through call chains using knowledge graph
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|---------|-------------------|
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Error message | `query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
@@ -47,22 +47,22 @@ description: Trace bugs through call chains using knowledge graph
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
**query** — find code related to error:
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
**context** — full context for a suspect:
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
**cypher** — custom call chain traces:
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
@@ -71,11 +71,11 @@ RETURN [n IN nodes(path) | n.name] AS chain
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
1. query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
2. context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
|
||||
@@ -17,8 +17,8 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
3. query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
@@ -28,9 +28,9 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
@@ -46,16 +46,16 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
**query** — find execution flows related to a concept:
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
**context** — 360-degree view of a symbol:
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
@@ -65,10 +65,10 @@ gitnexus_context({name: "validateUser"})
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
2. query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
3. context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
|
||||
@@ -16,9 +16,9 @@ description: Analyze blast radius before making code changes
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
1. impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
3. detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
@@ -27,11 +27,11 @@ description: Analyze blast radius before making code changes
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
@@ -54,9 +54,9 @@ description: Analyze blast radius before making code changes
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
**impact** — the primary tool for symbol blast radius:
|
||||
```
|
||||
gitnexus_impact({
|
||||
impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
@@ -71,9 +71,9 @@ gitnexus_impact({
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
**detect_changes** — git-diff based impact analysis:
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
@@ -83,7 +83,7 @@ gitnexus_detect_changes({scope: "staged"})
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
1. impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
@@ -32,10 +32,10 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] detect_changes to map changes to affected execution flows
|
||||
- [ ] impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
@@ -63,20 +63,20 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
**detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
**impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
@@ -86,20 +86,20 @@ gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
**impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
**context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
@@ -112,20 +112,20 @@ gitnexus_context({name: "validatePayment"})
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
2. detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
3. impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
4. impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
5. context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
|
||||
@@ -15,9 +15,9 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
1. impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. query({query: "X"}) → Find execution flows involving X
|
||||
3. context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
@@ -27,60 +27,60 @@ description: Plan safe refactors using blast radius and dependency mapping
|
||||
|
||||
### Rename Symbol
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
**rename** — automated multi-file rename:
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
**impact** — map all dependents first:
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
**detect_changes** — verify your changes after refactoring:
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
**cypher** — custom reference queries:
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
@@ -90,24 +90,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
|-------------|------------|
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Many callers (>5) | Use rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| String/dynamic refs | query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
4. detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
|
||||
@@ -44,7 +44,10 @@ export type NodeLabel =
|
||||
| 'Template'
|
||||
| 'Section'
|
||||
| 'Route'
|
||||
| 'Tool';
|
||||
| 'Tool'
|
||||
// Taint/PDG substrate (issue #2080). Intra-procedural control-flow node.
|
||||
// Emitted by no phase yet — M1 (#2081) populates these behind an opt-in.
|
||||
| 'BasicBlock';
|
||||
|
||||
export type NodeProperties = {
|
||||
name: string;
|
||||
@@ -89,6 +92,8 @@ export type NodeProperties = {
|
||||
responseKeys?: string[];
|
||||
errorKeys?: string[];
|
||||
middleware?: string[];
|
||||
// BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
|
||||
text?: string;
|
||||
// Extensible
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -131,7 +136,28 @@ export type RelationshipType =
|
||||
* `reason` encodes the event name: `vue-emit: <eventName>`.
|
||||
* Complements `BINDS_EVENT_HANDLER`; a Cypher query joining on the
|
||||
* component File node reveals all (emitter, handler) pairs. */
|
||||
| 'EMITS_EVENT';
|
||||
| 'EMITS_EVENT'
|
||||
// ── Taint/PDG substrate (issue #2080) ────────────────────────────────────
|
||||
// Reserved edge types for the taint-first PDG substrate. No phase emits any
|
||||
// of these yet; they are populated behind an opt-in by later milestones
|
||||
// (CFG → M1 #2081, REACHING_DEF → M2 #2082, TAINTED/SANITIZES/TAINT_PATH →
|
||||
// M3/M4 #2083/#2084). Adding them here keeps the shared schema stable so
|
||||
// downstream work does not re-ripple the exhaustiveness sites.
|
||||
/** Control-flow edge between two BasicBlock nodes (intra-procedural CFG). */
|
||||
| 'CFG'
|
||||
/** Data-dependence edge: a definition of `variable` reaches a use of it.
|
||||
* The `variable` name is stored in the relation's existing `reason` column
|
||||
* (M0/S1 verdict: LadybugDB has no secondary index on relationship
|
||||
* properties, so a dedicated indexed column would not speed the
|
||||
* variable-filtered path query). */
|
||||
| 'REACHING_DEF'
|
||||
/** A tainted value flows from source toward sink. */
|
||||
| 'TAINTED'
|
||||
/** A sanitizer clears taint along a flow. */
|
||||
| 'SANITIZES'
|
||||
/** Materialized source→sink taint path. Working name — final name/representation
|
||||
* is confirmed when M3/M4 emits it; no persisted edge exists before then. */
|
||||
| 'TAINT_PATH';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
|
||||
@@ -183,13 +183,3 @@ export {
|
||||
stripGitSuffix,
|
||||
} from './integrations/understand-quickly.js';
|
||||
export type { UqDispatchPayload } from './integrations/understand-quickly.js';
|
||||
|
||||
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
|
||||
export { diffResolutions } from './scope-resolution/shadow/diff.js';
|
||||
export type {
|
||||
ShadowAgreement,
|
||||
ShadowCallsite,
|
||||
ShadowDiff,
|
||||
} from './scope-resolution/shadow/diff.js';
|
||||
export { aggregateDiffs } from './scope-resolution/shadow/aggregate.js';
|
||||
export type { LanguageParityRow, ShadowParityReport } from './scope-resolution/shadow/aggregate.js';
|
||||
|
||||
@@ -40,6 +40,8 @@ export const NODE_TABLES = [
|
||||
'Module',
|
||||
'Route',
|
||||
'Tool',
|
||||
// Taint/PDG substrate (issue #2080) — inert until M1 (#2081) emits blocks.
|
||||
'BasicBlock',
|
||||
] as const;
|
||||
|
||||
export type NodeTableName = (typeof NODE_TABLES)[number];
|
||||
@@ -67,6 +69,14 @@ export const REL_TYPES = [
|
||||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
'QUERIES',
|
||||
// Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no
|
||||
// phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH →
|
||||
// M3/M4). REACHING_DEF's variable name rides the relation's `reason` column.
|
||||
'CFG',
|
||||
'REACHING_DEF',
|
||||
'TAINTED',
|
||||
'SANITIZES',
|
||||
'TAINT_PATH',
|
||||
] as const;
|
||||
|
||||
export type RelType = (typeof REL_TYPES)[number];
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #913.
|
||||
*
|
||||
* Consumed by: #915 (SCC finalize link pass), #923 (shadow harness when
|
||||
* resolving callsite file → enclosing module).
|
||||
* Consumed by: #915 (SCC finalize link pass).
|
||||
*/
|
||||
|
||||
import type { ScopeId } from './types.js';
|
||||
|
||||
@@ -74,4 +74,50 @@ export interface ParsedFile {
|
||||
*/
|
||||
readonly localDefs: readonly SymbolDefinition[];
|
||||
readonly referenceSites: readonly ReferenceSite[];
|
||||
/**
|
||||
* Opaque, language-private serialization of capture-time side-channel
|
||||
* state that a provider's `emitScopeCaptures` populates into module-level
|
||||
* maps as a SIDE EFFECT (not onto the scopes/defs of this `ParsedFile`).
|
||||
*
|
||||
* Such state is computed inside the parse worker (where `emitScopeCaptures`
|
||||
* runs) and would otherwise be lost across the worker→main MessageChannel
|
||||
* and the disk store, because scope-resolution reuses the serialized
|
||||
* `ParsedFile` and SKIPS re-extraction on the main thread (#1983 — the
|
||||
* whole point is to avoid a main-thread tree-sitter re-parse). Carrying the
|
||||
* data here lets the main thread repopulate those maps WITHOUT re-parsing.
|
||||
*
|
||||
* Shared / ingestion code treats this as opaque (`unknown`) per AGENTS.md
|
||||
* (no language names in shared code). The producing language fills it via
|
||||
* the `LanguageProvider.collectCaptureSideChannel` hook (worker side) and
|
||||
* consumes it via the `ScopeResolver.applyCaptureSideChannel` hook
|
||||
* (main-thread resolution side). It MUST be plain JSON-serializable data
|
||||
* (objects / arrays / primitives) so it round-trips through the disk-backed
|
||||
* `parsedfile-store` (JSON.stringify + interning reviver).
|
||||
*
|
||||
* Optional: providers whose `emitScopeCaptures` is pure (no module-level
|
||||
* side effects — the contract default) leave this undefined.
|
||||
*/
|
||||
readonly captureSideChannel?: unknown;
|
||||
|
||||
/**
|
||||
* Per-function control-flow graphs for this file (#2081 M1, PDG/taint
|
||||
* substrate). A DISTINCT field from {@link captureSideChannel} — different
|
||||
* producer, consumer, and lifecycle: the worker builds it from the
|
||||
* tree-sitter AST via `LanguageProvider.cfgVisitor` (only on a `--pdg` run),
|
||||
* and scope-resolution emits BasicBlock nodes + CFG edges from it while the
|
||||
* disk-backed ParsedFile store is still live (it is NOT a capture-time
|
||||
* marker the resolver restores into module maps). Kept separate so a future
|
||||
* change to either channel's shape invalidates independently.
|
||||
*
|
||||
* Shared / ingestion code treats this as opaque (`unknown`) per AGENTS.md.
|
||||
* Concretely it is a `readonly FunctionCfg[]` (see
|
||||
* `core/ingestion/cfg/types.ts`) — plain JSON-serializable data (no AST
|
||||
* refs, no class instances) so it round-trips through the parse cache and
|
||||
* the `parsedfile-store` (whose interning reviver keys on `nodeId`, which
|
||||
* these blocks/edges deliberately lack).
|
||||
*
|
||||
* Optional: `undefined` on non-`--pdg` runs and for languages with no
|
||||
* `cfgVisitor` — the default for every run today.
|
||||
*/
|
||||
readonly cfgSideChannel?: unknown;
|
||||
}
|
||||
|
||||
@@ -57,8 +57,7 @@ export interface RawSignals {
|
||||
*
|
||||
* Emission order mirrors the `EvidenceWeights` layout: where-found →
|
||||
* type-binding → corroborators → arity → degraded. Stable order makes
|
||||
* the per-signal contributions easy to reason about in tests and in the
|
||||
* shadow-mode parity dashboard.
|
||||
* the per-signal contributions easy to reason about in tests.
|
||||
*/
|
||||
export function composeEvidence(signals: RawSignals): readonly ResolutionEvidence[] {
|
||||
const out: ResolutionEvidence[] = [];
|
||||
@@ -141,7 +140,7 @@ export function composeEvidence(signals: RawSignals): readonly ResolutionEvidenc
|
||||
|
||||
/**
|
||||
* Sum evidence weights and clamp to `[0, 1]`. Separate from `composeEvidence`
|
||||
* so tests and the parity dashboard can inspect the raw evidence list.
|
||||
* so tests can inspect the raw evidence list.
|
||||
*/
|
||||
export function confidenceFromEvidence(evidence: readonly ResolutionEvidence[]): number {
|
||||
let sum = 0;
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* Shadow-mode aggregation — per-language parity %, per-evidence-kind
|
||||
* breakdown of divergences. Consumed by the parity dashboard (RING2-PKG-5).
|
||||
*
|
||||
* Pure functions; no I/O. The harness persists per-run JSON; the dashboard
|
||||
* reads `.gitnexus/shadow-parity/latest.json` and renders.
|
||||
*
|
||||
* Related types — `ShadowAgreement`, `ShadowCallsite`, `ShadowDiff` — are
|
||||
* defined alongside `diffResolutions` in `./diff.ts` and re-exported
|
||||
* through the top-level `gitnexus-shared` barrel. Consumers import all
|
||||
* three from `gitnexus-shared`, not from this module.
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #918.
|
||||
*/
|
||||
|
||||
import type { SupportedLanguages } from '../../languages.js';
|
||||
import type { ResolutionEvidence } from '../types.js';
|
||||
import type { ShadowAgreement, ShadowDiff } from './diff.js';
|
||||
|
||||
// ─── Aggregated report shape ────────────────────────────────────────────────
|
||||
|
||||
export interface LanguageParityRow {
|
||||
readonly language: SupportedLanguages;
|
||||
readonly totalCalls: number;
|
||||
readonly bothAgree: number;
|
||||
readonly onlyLegacy: number;
|
||||
readonly onlyNew: number;
|
||||
readonly bothDisagree: number;
|
||||
readonly bothEmpty: number;
|
||||
/**
|
||||
* Fraction in [0, 1]. Numerator = `bothAgree`; denominator = "calls where
|
||||
* at least one side resolved" = `totalCalls - bothEmpty`.
|
||||
*
|
||||
* When the denominator is 0 (all calls for this language were
|
||||
* `both-empty`), returns 0. Callers rendering the dashboard should treat
|
||||
* a 0 parity alongside `totalCalls === bothEmpty` as "no signal" rather
|
||||
* than "total disagreement".
|
||||
*/
|
||||
readonly parity: number;
|
||||
/**
|
||||
* Divergence signals broken down by `ResolutionEvidence.kind`. Sourced
|
||||
* from `ShadowDiff.evidenceDelta` on non-agreeing rows only — `both-agree`
|
||||
* and `both-empty` do not contribute.
|
||||
*/
|
||||
readonly evidenceBreakdown: ReadonlyMap<ResolutionEvidence['kind'], number>;
|
||||
}
|
||||
|
||||
export interface ShadowParityReport {
|
||||
readonly generatedAt: string; // ISO 8601
|
||||
readonly perLanguage: readonly LanguageParityRow[];
|
||||
readonly overall: Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'>;
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aggregate a stream of `ShadowDiff` records into a `ShadowParityReport`,
|
||||
* bucketed by language. Pure function.
|
||||
*
|
||||
* - `perLanguage` rows are sorted alphabetically by `SupportedLanguages`
|
||||
* value for stable JSON output (the dashboard reads
|
||||
* `.gitnexus/shadow-parity/latest.json` and diffing snapshots is useful).
|
||||
* - `overall` is the column-wise sum across languages.
|
||||
* - `generatedAt` is injected via the `now` parameter so tests stay
|
||||
* deterministic; production callers let it default to `new Date()`.
|
||||
*/
|
||||
export function aggregateDiffs(
|
||||
diffs: readonly { readonly language: SupportedLanguages; readonly diff: ShadowDiff }[],
|
||||
now: Date = new Date(),
|
||||
): ShadowParityReport {
|
||||
const perLanguageMap = new Map<SupportedLanguages, MutableCounts>();
|
||||
|
||||
for (const { language, diff } of diffs) {
|
||||
let counts = perLanguageMap.get(language);
|
||||
if (!counts) {
|
||||
counts = makeEmptyCounts();
|
||||
perLanguageMap.set(language, counts);
|
||||
}
|
||||
tallyDiff(counts, diff);
|
||||
}
|
||||
|
||||
const perLanguage: LanguageParityRow[] = Array.from(perLanguageMap.entries())
|
||||
.map(([language, counts]) => buildRow(language, counts))
|
||||
.sort((a, b) => a.language.localeCompare(b.language));
|
||||
|
||||
const overall = buildOverallRow(perLanguage);
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
perLanguage,
|
||||
overall,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
interface MutableCounts {
|
||||
totalCalls: number;
|
||||
bothAgree: number;
|
||||
onlyLegacy: number;
|
||||
onlyNew: number;
|
||||
bothDisagree: number;
|
||||
bothEmpty: number;
|
||||
evidenceBreakdown: Map<ResolutionEvidence['kind'], number>;
|
||||
}
|
||||
|
||||
function makeEmptyCounts(): MutableCounts {
|
||||
return {
|
||||
totalCalls: 0,
|
||||
bothAgree: 0,
|
||||
onlyLegacy: 0,
|
||||
onlyNew: 0,
|
||||
bothDisagree: 0,
|
||||
bothEmpty: 0,
|
||||
evidenceBreakdown: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function tallyDiff(counts: MutableCounts, diff: ShadowDiff): void {
|
||||
counts.totalCalls += 1;
|
||||
incrementAgreement(counts, diff.agreement);
|
||||
if (diff.agreement === 'both-agree' || diff.agreement === 'both-empty') return;
|
||||
for (const ev of diff.evidenceDelta) {
|
||||
counts.evidenceBreakdown.set(ev.kind, (counts.evidenceBreakdown.get(ev.kind) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function incrementAgreement(counts: MutableCounts, agreement: ShadowAgreement): void {
|
||||
switch (agreement) {
|
||||
case 'both-agree':
|
||||
counts.bothAgree += 1;
|
||||
return;
|
||||
case 'only-legacy':
|
||||
counts.onlyLegacy += 1;
|
||||
return;
|
||||
case 'only-new':
|
||||
counts.onlyNew += 1;
|
||||
return;
|
||||
case 'both-disagree':
|
||||
counts.bothDisagree += 1;
|
||||
return;
|
||||
case 'both-empty':
|
||||
counts.bothEmpty += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRow(language: SupportedLanguages, counts: MutableCounts): LanguageParityRow {
|
||||
const resolved = counts.totalCalls - counts.bothEmpty;
|
||||
const parity = resolved > 0 ? counts.bothAgree / resolved : 0;
|
||||
return {
|
||||
language,
|
||||
totalCalls: counts.totalCalls,
|
||||
bothAgree: counts.bothAgree,
|
||||
onlyLegacy: counts.onlyLegacy,
|
||||
onlyNew: counts.onlyNew,
|
||||
bothDisagree: counts.bothDisagree,
|
||||
bothEmpty: counts.bothEmpty,
|
||||
parity,
|
||||
// Freeze via `new Map` on a sorted-kind copy so downstream consumers
|
||||
// can't mutate the aggregator's internal state.
|
||||
evidenceBreakdown: new Map(
|
||||
Array.from(counts.evidenceBreakdown.entries()).sort(([a], [b]) => a.localeCompare(b)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOverallRow(
|
||||
perLanguage: readonly LanguageParityRow[],
|
||||
): Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'> {
|
||||
let totalCalls = 0;
|
||||
let bothAgree = 0;
|
||||
let onlyLegacy = 0;
|
||||
let onlyNew = 0;
|
||||
let bothDisagree = 0;
|
||||
let bothEmpty = 0;
|
||||
for (const row of perLanguage) {
|
||||
totalCalls += row.totalCalls;
|
||||
bothAgree += row.bothAgree;
|
||||
onlyLegacy += row.onlyLegacy;
|
||||
onlyNew += row.onlyNew;
|
||||
bothDisagree += row.bothDisagree;
|
||||
bothEmpty += row.bothEmpty;
|
||||
}
|
||||
const resolved = totalCalls - bothEmpty;
|
||||
const parity = resolved > 0 ? bothAgree / resolved : 0;
|
||||
return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity };
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Shadow-mode diff logic — RFC §6.3.
|
||||
*
|
||||
* Pure comparison logic for shadow mode. Takes two `Resolution[]` (legacy
|
||||
* DAG result + new scope-based registry result) and produces a structured
|
||||
* diff record for the parity dashboard.
|
||||
*
|
||||
* Consumed by the Ring 2 PKG shadow harness (#923), which dual-runs each
|
||||
* call through legacy + new paths, diffs results, and persists per-run JSON
|
||||
* for the parity dashboard.
|
||||
*
|
||||
* Part of RFC #909 Ring 2 SHARED — #918.
|
||||
*/
|
||||
|
||||
import type { Resolution, ResolutionEvidence } from '../types.js';
|
||||
|
||||
// ─── Diff record shape ──────────────────────────────────────────────────────
|
||||
|
||||
export type ShadowAgreement =
|
||||
| 'both-agree' // top match identical (same DefId)
|
||||
| 'only-legacy' // legacy resolved; new did not
|
||||
| 'only-new' // new resolved; legacy did not
|
||||
| 'both-disagree' // both resolved, but to different targets
|
||||
| 'both-empty'; // both returned empty
|
||||
|
||||
export interface ShadowDiff {
|
||||
readonly callsite: ShadowCallsite;
|
||||
readonly legacy: Resolution | null;
|
||||
readonly newResult: Resolution | null;
|
||||
readonly agreement: ShadowAgreement;
|
||||
/**
|
||||
* Symmetric difference of the two top resolutions' `evidence` arrays,
|
||||
* keyed on `ResolutionEvidence.kind`.
|
||||
*
|
||||
* - For `'both-agree'` and `'both-empty'` agreements, always empty.
|
||||
* - For `'both-disagree'`, contains evidence kinds present on exactly one
|
||||
* side (not in both).
|
||||
* - For `'only-legacy'`, contains all of legacy's top evidence.
|
||||
* - For `'only-new'`, contains all of new's top evidence.
|
||||
*/
|
||||
readonly evidenceDelta: readonly ResolutionEvidence[];
|
||||
}
|
||||
|
||||
export interface ShadowCallsite {
|
||||
readonly filePath: string;
|
||||
readonly line: number;
|
||||
readonly col: number;
|
||||
readonly calledName: string;
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compare two `Resolution[]` arrays (top matches at `[0]`) and produce a
|
||||
* `ShadowDiff`. Pure function.
|
||||
*
|
||||
* Agreement rules:
|
||||
* - both arrays empty → `'both-empty'`, `evidenceDelta: []`
|
||||
* - legacy empty, new non-empty → `'only-new'`, `evidenceDelta` = new's top evidence
|
||||
* - legacy non-empty, new empty → `'only-legacy'`, `evidenceDelta` = legacy's top evidence
|
||||
* - both non-empty, same top `def.nodeId` → `'both-agree'`, `evidenceDelta: []`
|
||||
* - both non-empty, different top `def.nodeId` → `'both-disagree'`,
|
||||
* `evidenceDelta` = symmetric difference by `ResolutionEvidence.kind`
|
||||
* (first occurrence of a kind-only-on-legacy then kind-only-on-new; order
|
||||
* preserved from input arrays)
|
||||
*
|
||||
* Evidence-delta rationale: callers aggregating divergences want to know
|
||||
* which signal kinds explain a disagreement. Keying on `kind` (not full
|
||||
* equality over `weight`/`note`) avoids spurious deltas when the same
|
||||
* signal fires with slightly different calibration weights on each side.
|
||||
*/
|
||||
export function diffResolutions(
|
||||
callsite: ShadowCallsite,
|
||||
legacy: readonly Resolution[],
|
||||
newResult: readonly Resolution[],
|
||||
): ShadowDiff {
|
||||
const legacyTop: Resolution | null = legacy.length > 0 ? legacy[0] : null;
|
||||
const newTop: Resolution | null = newResult.length > 0 ? newResult[0] : null;
|
||||
|
||||
const agreement: ShadowAgreement = (() => {
|
||||
if (legacyTop === null && newTop === null) return 'both-empty';
|
||||
if (legacyTop === null) return 'only-new';
|
||||
if (newTop === null) return 'only-legacy';
|
||||
return legacyTop.def.nodeId === newTop.def.nodeId ? 'both-agree' : 'both-disagree';
|
||||
})();
|
||||
|
||||
const evidenceDelta = computeEvidenceDelta(legacyTop, newTop, agreement);
|
||||
|
||||
return {
|
||||
callsite,
|
||||
legacy: legacyTop,
|
||||
newResult: newTop,
|
||||
agreement,
|
||||
evidenceDelta,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Symmetric difference of two evidence arrays, keyed on
|
||||
* `ResolutionEvidence.kind`. Preserves input order: legacy-only signals
|
||||
* first (in legacy's original order), then new-only signals (in new's order).
|
||||
*
|
||||
* For `'both-agree'` / `'both-empty'` the delta is empty by contract. For
|
||||
* `'only-legacy'` / `'only-new'` one side's evidence is the delta (nothing to
|
||||
* subtract against).
|
||||
*/
|
||||
function computeEvidenceDelta(
|
||||
legacy: Resolution | null,
|
||||
newResult: Resolution | null,
|
||||
agreement: ShadowAgreement,
|
||||
): readonly ResolutionEvidence[] {
|
||||
if (agreement === 'both-agree' || agreement === 'both-empty') return [];
|
||||
if (agreement === 'only-legacy') return legacy!.evidence;
|
||||
if (agreement === 'only-new') return newResult!.evidence;
|
||||
|
||||
// both-disagree: symmetric difference keyed on `kind`
|
||||
const legacyKinds = new Set(legacy!.evidence.map((e) => e.kind));
|
||||
const newKinds = new Set(newResult!.evidence.map((e) => e.kind));
|
||||
|
||||
const onlyInLegacy = legacy!.evidence.filter((e) => !newKinds.has(e.kind));
|
||||
const onlyInNew = newResult!.evidence.filter((e) => !legacyKinds.has(e.kind));
|
||||
|
||||
return [...onlyInLegacy, ...onlyInNew];
|
||||
}
|
||||
@@ -57,6 +57,19 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* E2E for the browser folder-upload flow (replaces the removed server-side
|
||||
* directory picker). Mocks the backend so no live gitnexus server is needed.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = 'http://localhost:4747';
|
||||
|
||||
let fixtureDir: string;
|
||||
|
||||
test.beforeAll(() => {
|
||||
// A tiny "repo" folder; Playwright sets webkitRelativePath = <folder>/<file>.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-upload-e2e-'));
|
||||
fixtureDir = path.join(root, 'myrepo');
|
||||
fs.mkdirSync(path.join(fixtureDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixtureDir, 'README.md'), '# hi\n');
|
||||
fs.writeFileSync(path.join(fixtureDir, 'src', 'index.ts'), 'export const x = 1;\n');
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] }));
|
||||
await page.route(`${BACKEND_URL}/api/info`, (route) =>
|
||||
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
|
||||
);
|
||||
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body: ':ok\n\n',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('uploading a folder posts a multipart upload and starts analysis', async ({ page }) => {
|
||||
let uploadContentType = '';
|
||||
await page.route(`${BACKEND_URL}/api/analyze/upload`, async (route) => {
|
||||
uploadContentType = route.request().headers()['content-type'] ?? '';
|
||||
await route.fulfill({ json: { jobId: 'job-e2e', status: 'analyzing' } });
|
||||
});
|
||||
// SSE progress → immediately complete.
|
||||
await page.route(`${BACKEND_URL}/api/analyze/job-e2e/progress`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
body: 'event: complete\ndata: {"repoName":"myrepo"}\n\n',
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
|
||||
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
|
||||
|
||||
// Select the fixture folder via the hidden webkitdirectory input.
|
||||
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
|
||||
|
||||
// The upload endpoint should be hit with a multipart body, and the UI should
|
||||
// leave the input phase (upload button no longer shown).
|
||||
await expect.poll(() => uploadContentType).toContain('multipart/form-data');
|
||||
await expect(page.locator('[data-testid="upload-folder"]')).toBeHidden({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('switching modes mid-upload aborts it and never shows progress', async ({ page }) => {
|
||||
// Hold the upload response until the test releases it, so the mode switch
|
||||
// happens while the POST is in flight (the review 4470339833 repro).
|
||||
let releaseUpload!: () => void;
|
||||
const uploadGate = new Promise<void>((res) => (releaseUpload = res));
|
||||
let uploadAborted = false;
|
||||
let progressOpened = false;
|
||||
|
||||
// The client-side AbortController kills the POST at mode-switch time; that
|
||||
// surfaces as a failed request (net::ERR_ABORTED), not as a response.
|
||||
page.on('requestfailed', (req) => {
|
||||
if (req.url().includes('/api/analyze/upload') && /ABORTED/.test(req.failure()?.errorText ?? ''))
|
||||
uploadAborted = true;
|
||||
});
|
||||
await page.route(`${BACKEND_URL}/api/analyze/upload`, async (route) => {
|
||||
await uploadGate;
|
||||
await route.fulfill({ json: { jobId: 'job-stale', status: 'analyzing' } }).catch(() => {}); // the request may already be gone — that's the point
|
||||
});
|
||||
await page.route(`${BACKEND_URL}/api/analyze/job-stale/progress`, (route) => {
|
||||
progressOpened = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
body: 'event: complete\ndata: {"repoName":"myrepo"}\n\n',
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
|
||||
await expect(page.locator('[data-testid="upload-progress"]')).toBeVisible();
|
||||
|
||||
// Switch back to GitHub while the upload POST is still pending, then let
|
||||
// the (now-stale) route handler finish.
|
||||
await page.getByRole('tab', { name: 'GitHub URL' }).click();
|
||||
await expect.poll(() => uploadAborted, { timeout: 10_000 }).toBe(true);
|
||||
releaseUpload();
|
||||
|
||||
// The GitHub form stays clean (no error, immediately usable), and no SSE
|
||||
// progress stream is ever opened by the stale upload.
|
||||
await expect(page.getByPlaceholder('https://github.com/owner/repo')).toBeEditable();
|
||||
await expect(page.locator('[data-testid="upload-progress"]')).toBeHidden();
|
||||
expect(progressOpened).toBe(false);
|
||||
});
|
||||
@@ -218,8 +218,8 @@ test.describe('Flow 3: Analyze form', () => {
|
||||
// Switch to Local Folder tab
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
|
||||
// Browse button should be visible
|
||||
await expect(page.getByText('Browse for folder')).toBeVisible();
|
||||
// Upload-a-folder button should be visible (browser folder upload)
|
||||
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('local-folder-tab.png') });
|
||||
});
|
||||
|
||||
|
||||
Generated
+107
-145
@@ -18,7 +18,7 @@
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.3",
|
||||
"dompurify": "^3.4.8",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
@@ -26,11 +26,11 @@
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.3.5",
|
||||
"langchain": "^1.4.4",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"sigma": "^3.0.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^4.4.3"
|
||||
@@ -57,9 +57,9 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.2",
|
||||
"@vercel/node": "^5.8.12",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
@@ -1357,16 +1357,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.1.45",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.45.tgz",
|
||||
"integrity": "sha512-Y/wvuglLTMKJahkl4QD9dBIdF/z/CxZJWdTfHJF/q2jtlJtoFf6Mb5JpGxZfsi3mBY6NSG941FSLTcqhCKrhBA==",
|
||||
"version": "1.1.48",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.48.tgz",
|
||||
"integrity": "sha512-fQU6Guyb1pwc2fEplmA8FPbKfOMAofjnyJzExevro0FxEiuGHE18Ov/ZHmT9trWCDTZRI9eW1VIc6aChxV8pAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"camelcase": "6",
|
||||
"decamelize": "1.2.0",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"langsmith": ">=0.5.0 <1.0.0",
|
||||
"mustache": "^4.2.0",
|
||||
@@ -1965,9 +1962,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
|
||||
"integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2430,9 +2427,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2933,9 +2930,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/build-utils": {
|
||||
"version": "13.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.25.0.tgz",
|
||||
"integrity": "sha512-p2wqxi2I95T+g/+uP+Dc/uq2PApW7F9RbE/Vvwp28JY8SoCGHNUs2pxigttgaNDNF6IlUEMOTz+eJvsXToV/1w==",
|
||||
"version": "13.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.27.1.tgz",
|
||||
"integrity": "sha512-BD9H2U8I/IPGS1c1stSIkdPxBRu6bkCQFqtRjcT2dcdnBawyHXvzTsnnBQhgB3fJVQtgJT47gVZe1oHDmv0Ktg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2952,16 +2949,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/error-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-DiJcXBOB9N6QM4d7hYPM9Ck/AUjzBl58XNQPxS74o7CuvIanjzrGgygP/70VsyEASeIJMazk1LrhwcNTR/eZGQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.0.tgz",
|
||||
"integrity": "sha512-WFWiRxfPzoYWYifaj4thSKvAaZZwUOqD4k5GINRIgZgCiS2E3iAJbWbIsIZmkQdTecWFHcWGA6q48CjisgpOBA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@vercel/nft": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz",
|
||||
"integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.10.0.tgz",
|
||||
"integrity": "sha512-iLOW4fcsgkipfOh2Bw3wB38YDfxTlxr7+j4uFeui2OswkNT28jIitS/aMce7tS0mef1YPQ8zLIDYr3a0aahNrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2986,9 +2983,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/node": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.2.tgz",
|
||||
"integrity": "sha512-Wt6KBr0LoIhUuzeH7E8S+1HRlS6oSA+98FJH/59wY2tYoxsHXbx5uiNkSBqBsM0nIwlJWR3B86tm9q9Xi22XdQ==",
|
||||
"version": "5.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.12.tgz",
|
||||
"integrity": "sha512-XK2ML9YVdAlZ3BmGTW4jQL0D55ZHeRWKS+CLPSWReDyOBKaC4tTnTL2tp3z76bAs0pfgbT55nplMiX2mneSbLA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2996,10 +2993,10 @@
|
||||
"@edge-runtime/primitives": "4.1.0",
|
||||
"@edge-runtime/vm": "3.2.0",
|
||||
"@types/node": "20.11.0",
|
||||
"@vercel/build-utils": "13.25.0",
|
||||
"@vercel/error-utils": "2.1.0",
|
||||
"@vercel/nft": "1.5.0",
|
||||
"@vercel/static-config": "3.3.0",
|
||||
"@vercel/build-utils": "13.27.1",
|
||||
"@vercel/error-utils": "2.2.0",
|
||||
"@vercel/nft": "1.10.0",
|
||||
"@vercel/static-config": "3.4.0",
|
||||
"async-listen": "3.0.0",
|
||||
"cjs-module-lexer": "1.2.3",
|
||||
"edge-runtime": "2.5.9",
|
||||
@@ -3060,9 +3057,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/static-config": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.3.0.tgz",
|
||||
"integrity": "sha512-GpS3tPwUeDJCkrKbMNtS2XLRFgfxTlN7YNUL+Bo23+fGolrDw6Oq79R3yvxTYgqRaJMGSEqC7iMw6mj6I5loxg==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.0.tgz",
|
||||
"integrity": "sha512-wCq90CMUB//ggnFh77NQO1xaLFsS4LigQIqKrH6ohnr9Br/KI1FhlErx62WfCOuueWaW+LVsbLOqNXIUjK8t6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -3104,14 +3101,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
|
||||
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
|
||||
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -3125,8 +3122,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.5",
|
||||
"vitest": "4.1.5"
|
||||
"@vitest/browser": "4.1.8",
|
||||
"vitest": "4.1.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -3135,16 +3132,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
||||
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
||||
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -3153,13 +3150,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
|
||||
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -3190,9 +3187,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
|
||||
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
||||
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3203,13 +3200,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
|
||||
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
||||
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -3217,14 +3214,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
|
||||
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
||||
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -3233,9 +3230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
|
||||
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
||||
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -3243,13 +3240,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
|
||||
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
||||
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -3331,7 +3328,9 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -3598,18 +3597,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001764",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
|
||||
@@ -4396,15 +4383,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@@ -4483,9 +4461,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.3.tgz",
|
||||
"integrity": "sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==",
|
||||
"version": "3.4.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -5029,22 +5007,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -5334,9 +5296,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
|
||||
"integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
|
||||
"version": "26.3.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
|
||||
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -5790,12 +5752,12 @@
|
||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langchain": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.5.tgz",
|
||||
"integrity": "sha512-QSB8TEo6G1tWupgNt1Osm8ylLLoOMq1lLw5NeijnIwRPI5BqdBjUn4/U8usbjEJJcQnbXSK2qTKgTx7zvblnBw==",
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.4.tgz",
|
||||
"integrity": "sha512-tepOCwUDaIZOYJ9Eo0O6o5dXEN/0KJheiFDnHHFL8Tx8rfkDLL4cOTSTln4Vpn9LpWzXYkjQ8lkHnnNDQWZPeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/langgraph": "^1.3.2",
|
||||
"@langchain/langgraph-checkpoint": "^1.0.1",
|
||||
"langsmith": ">=0.5.0 <1.0.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
@@ -5804,7 +5766,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.42"
|
||||
"@langchain/core": "^1.1.48"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
@@ -6142,9 +6104,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
|
||||
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -8191,9 +8153,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sigma": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
|
||||
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz",
|
||||
"integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"events": "^3.3.0",
|
||||
@@ -8350,9 +8312,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"version": "7.5.16",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
|
||||
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -8860,19 +8822,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
|
||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/runner": "4.1.5",
|
||||
"@vitest/snapshot": "4.1.5",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/expect": "4.1.8",
|
||||
"@vitest/mocker": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/runner": "4.1.8",
|
||||
"@vitest/snapshot": "4.1.8",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -8900,12 +8862,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/browser-preview": "4.1.5",
|
||||
"@vitest/browser-webdriverio": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"@vitest/ui": "4.1.5",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/browser-preview": "4.1.8",
|
||||
"@vitest/browser-webdriverio": "4.1.8",
|
||||
"@vitest/coverage-istanbul": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vitest/ui": "4.1.8",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.3",
|
||||
"dompurify": "^3.4.8",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
@@ -36,11 +36,11 @@
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.3.5",
|
||||
"langchain": "^1.4.4",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
@@ -51,7 +51,7 @@
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"sigma": "^3.0.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^4.4.3"
|
||||
@@ -67,9 +67,9 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.2",
|
||||
"@vercel/node": "^5.8.12",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { EmbeddingStatus } from './EmbeddingStatus';
|
||||
import { RepoAnalyzer } from './RepoAnalyzer';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { translateProgressMessage } from '../i18n/progress';
|
||||
import { formatBackendError } from '../i18n/error-messages';
|
||||
|
||||
// Color mapping for node types in search results
|
||||
const NODE_TYPE_COLORS: Record<string, string> = {
|
||||
@@ -58,7 +59,7 @@ export const Header = ({
|
||||
onAnalyzeComplete,
|
||||
onReposChanged,
|
||||
}: HeaderProps) => {
|
||||
const { t } = useTranslation(['common', 'header']);
|
||||
const { t } = useTranslation(['common', 'header', 'errors']);
|
||||
const {
|
||||
projectName,
|
||||
graph,
|
||||
@@ -72,6 +73,7 @@ export const Header = ({
|
||||
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
|
||||
const [showAnalyzer, setShowAnalyzer] = useState(false);
|
||||
const [reanalyzing, setReanalyzing] = useState<string | null>(null); // repo name being re-analyzed
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null); // surfaced when a delete is rejected (e.g. origin-blocked 403)
|
||||
const [reanalyzeProgress, setReanalyzeProgress] = useState<JobProgress | null>(null);
|
||||
const reanalyzeSseRef = useRef<AbortController | null>(null);
|
||||
const repoDropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -305,6 +307,7 @@ export const Header = ({
|
||||
setReanalyzeProgress(null);
|
||||
reanalyzeSseRef.current = null;
|
||||
}
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteRepo(repo.name);
|
||||
const updated = await fetchRepos();
|
||||
@@ -317,7 +320,11 @@ export const Header = ({
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
// Surface the failure instead of silently no-opping —
|
||||
// e.g. an origin-blocked 403 when driving a local
|
||||
// backend from the hosted UI.
|
||||
console.error('Failed to delete repo:', err);
|
||||
setDeleteError(formatBackendError(err, t));
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer rounded p-1 text-text-muted/0 transition-all group-hover:text-text-muted hover:!text-red-400"
|
||||
@@ -330,6 +337,13 @@ export const Header = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Surfaced delete failure (e.g. origin-blocked 403) */}
|
||||
{deleteError && (
|
||||
<div className="px-3 py-2 text-xs text-red-400" role="alert">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Re-analyze progress bar */}
|
||||
{reanalyzing && reanalyzeProgress && (
|
||||
<div className="border-t border-border-subtle bg-accent/5 px-4 py-2.5">
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
startAnalyze,
|
||||
cancelAnalyze,
|
||||
streamAnalyzeProgress,
|
||||
uploadFolder,
|
||||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { AnalyzeProgress } from './AnalyzeProgress';
|
||||
import { filterRepoFiles } from '@/lib/upload-filter';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -165,8 +167,11 @@ export interface RepoAnalyzerProps {
|
||||
export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProps) => {
|
||||
const { t } = useTranslation(['common', 'errors', 'onboarding']);
|
||||
const inputId = useId();
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const [mode, setMode] = useState<InputMode>('github');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadSummary, setUploadSummary] = useState<{ count: number; dropped: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [gitlabUrl, setGitlabUrl] = useState('');
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
@@ -181,28 +186,73 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
|
||||
const jobIdRef = useRef<string | null>(null);
|
||||
const sseControllerRef = useRef<AbortController | null>(null);
|
||||
// Owns the in-flight analyze/upload request. The controller doubles as the
|
||||
// staleness token: each request captures its own controller in a closure and
|
||||
// bails after the await when that controller was aborted, so a resolution
|
||||
// arriving after a mode switch / cancel / unmount can never drive state.
|
||||
const requestControllerRef = useRef<AbortController | null>(null);
|
||||
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
sseControllerRef.current?.abort();
|
||||
requestControllerRef.current?.abort();
|
||||
if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Abort any in-flight analyze/upload request so its settlement can't drive
|
||||
// state. Aborting is load-bearing: once a mode switch resets `uploading`,
|
||||
// the `uploading || isLoading` re-entry guard no longer covers the stale
|
||||
// request — only its aborted signal does.
|
||||
const invalidateRequest = (): void => {
|
||||
requestControllerRef.current?.abort();
|
||||
requestControllerRef.current = null;
|
||||
};
|
||||
|
||||
// Invalidate the previous request and hand the caller a fresh controller.
|
||||
const renewRequestController = (): AbortController => {
|
||||
invalidateRequest();
|
||||
const controller = new AbortController();
|
||||
requestControllerRef.current = controller;
|
||||
return controller;
|
||||
};
|
||||
|
||||
// An upload that resolved after invalidation has still created a server-side
|
||||
// job; cancel it so the single analyze slot isn't held for the job's full
|
||||
// duration. Upload-path only: every upload owns a fresh job (the server
|
||||
// stages each upload into a unique dir, never dedup-aliasing), whereas URL
|
||||
// analyzes dedup-alias by repo — the returned jobId may belong to a job
|
||||
// another session (or this user's own resubmit) is actively watching, so
|
||||
// cancelling on that path could kill a live analysis. A stale URL job is
|
||||
// left to finish: a same-URL resubmit re-attaches to it via dedup, and the
|
||||
// server's job timeout/TTL sweep bounds the slot occupancy.
|
||||
const cancelStaleUploadJob = (jobId: string): void => {
|
||||
void cancelAnalyze(jobId).catch(() => {});
|
||||
};
|
||||
|
||||
const handleModeChange = (m: InputMode) => {
|
||||
// ModeTabs fires onChange on every click, including the already-active
|
||||
// tab — never abort the user's own in-flight request for a no-op click.
|
||||
if (m === mode) return;
|
||||
invalidateRequest();
|
||||
setMode(m);
|
||||
setGithubUrl('');
|
||||
setGitlabUrl('');
|
||||
setLocalPath('');
|
||||
setValidationError(null);
|
||||
setUploadSummary(null);
|
||||
setUploading(false);
|
||||
// An aborted request no longer resolves to move `phase` off 'starting';
|
||||
// reset so the new mode's form is immediately usable (also clears a stale
|
||||
// 'error' phase). Only reachable while showInput is true.
|
||||
setPhase('input');
|
||||
};
|
||||
|
||||
// Use the browser's native directory picker (webkitdirectory doesn't give paths,
|
||||
// so we use a text input + a "Browse" button that opens a standard file input
|
||||
// to let users pick files from the folder — the path is typed manually since
|
||||
// browsers don't expose absolute paths for security reasons).
|
||||
// For local paths, the user types or pastes the absolute path.
|
||||
// Local-folder mode uploads the selected folder's files (the browser never
|
||||
// exposes an absolute path, so the old typed-path/browse approach couldn't
|
||||
// work — see handleFolderUpload). A typed server path is also still accepted.
|
||||
|
||||
const canSubmit =
|
||||
mode === 'github'
|
||||
@@ -228,6 +278,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
setValidationError(null);
|
||||
setPhase('starting');
|
||||
|
||||
// Staleness guard only (no wire abort): the POST is short-lived and
|
||||
// self-terminates, but its resolution must not drive state after a mode
|
||||
// switch / cancel / unmount invalidated this request.
|
||||
const controller = renewRequestController();
|
||||
try {
|
||||
const request =
|
||||
mode === 'github'
|
||||
@@ -236,8 +290,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
? { url: gitlabUrl.trim() }
|
||||
: { path: localPath.trim() };
|
||||
const { jobId } = await startAnalyze(request);
|
||||
jobIdRef.current = jobId;
|
||||
setPhase('analyzing');
|
||||
// Stale resolution: return without cancelling — URL jobIds may be
|
||||
// dedup-aliased to a job another session owns (see cancelStaleUploadJob).
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const nameSource =
|
||||
mode === 'github'
|
||||
@@ -245,29 +300,84 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
: mode === 'gitlab'
|
||||
? gitlabUrl.trim()
|
||||
: localPath.trim();
|
||||
const controller = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setProgress(p),
|
||||
(data) => {
|
||||
const name =
|
||||
data.repoName ??
|
||||
nameSource.split(/[/\\]/).filter(Boolean).at(-1) ??
|
||||
t('onboarding:repoAnalyzer.defaultRepoName');
|
||||
setCompletedRepoName(name);
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
completeTimerRef.current = setTimeout(() => {
|
||||
completeTimerRef.current = null;
|
||||
onComplete(name);
|
||||
}, 1200);
|
||||
},
|
||||
(errMsg) => {
|
||||
setValidationError(errMsg || t('errors:analysisFailed'));
|
||||
setPhase('error');
|
||||
},
|
||||
);
|
||||
sseControllerRef.current = controller;
|
||||
trackJob(jobId, nameSource);
|
||||
} catch (err) {
|
||||
// Unmount aborts the controller, so this also covers the unmounted case.
|
||||
if (controller.signal.aborted) return;
|
||||
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Drive an already-created analysis job through the SSE progress stream to
|
||||
// completion. Shared by the path/URL analyze flow and the folder-upload flow.
|
||||
const trackJob = (jobId: string, fallbackNameSource: string | null) => {
|
||||
// Callers reach here only with a live (non-aborted) request controller, so
|
||||
// the component is mounted — unmount aborts the controller.
|
||||
jobIdRef.current = jobId;
|
||||
setPhase('analyzing');
|
||||
const controller = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setProgress(p),
|
||||
(data) => {
|
||||
const name =
|
||||
data.repoName ??
|
||||
(fallbackNameSource
|
||||
? fallbackNameSource.split(/[/\\]/).filter(Boolean).at(-1)
|
||||
: undefined) ??
|
||||
t('onboarding:repoAnalyzer.defaultRepoName');
|
||||
setCompletedRepoName(name);
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
completeTimerRef.current = setTimeout(() => {
|
||||
completeTimerRef.current = null;
|
||||
onComplete(name);
|
||||
}, 1200);
|
||||
},
|
||||
(errMsg) => {
|
||||
setValidationError(errMsg || t('errors:analysisFailed'));
|
||||
setPhase('error');
|
||||
},
|
||||
);
|
||||
sseControllerRef.current = controller;
|
||||
};
|
||||
|
||||
// Upload a browser-selected folder (webkitdirectory) and start analysis. The
|
||||
// upload endpoint returns a jobId, which then joins the normal SSE flow.
|
||||
const handleFolderUpload = async (fileList: FileList) => {
|
||||
if (uploading || isLoading) return; // guard against a concurrent upload
|
||||
const { files, manifest, droppedCount } = filterRepoFiles(fileList);
|
||||
if (files.length === 0) {
|
||||
setValidationError(t('onboarding:repoAnalyzer.upload.empty'));
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
setUploadSummary({ count: files.length, dropped: droppedCount });
|
||||
setUploading(true);
|
||||
setPhase('starting');
|
||||
// The selected folder's name (manifest entries are `<folder>/<rest>`) is a
|
||||
// sensible fallback if the server's complete event omits repoName.
|
||||
const folderName = manifest[0]?.split('/')[0] ?? null;
|
||||
const controller = renewRequestController();
|
||||
try {
|
||||
const { jobId } = await uploadFolder(files, manifest, controller.signal);
|
||||
if (controller.signal.aborted) {
|
||||
// The abort raced the response: the server already created the job.
|
||||
// (Unmount aborts the controller, so this also covers unmounted.)
|
||||
cancelStaleUploadJob(jobId);
|
||||
return;
|
||||
}
|
||||
setUploading(false);
|
||||
trackJob(jobId, folderName);
|
||||
} catch (err) {
|
||||
// An abort surfaces in two shapes — BackendError('Request aborted')
|
||||
// when it lands during fetch, raw AbortError when it lands during the
|
||||
// response-body read — so branch on the closure controller's signal,
|
||||
// never on the error identity. In the second shape the server may have
|
||||
// already launched a job whose id we never learn; that orphan is bounded
|
||||
// by the server's job timeout and terminal-job TTL sweep.
|
||||
if (controller.signal.aborted) return;
|
||||
setUploading(false);
|
||||
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
|
||||
setPhase('error');
|
||||
}
|
||||
@@ -276,6 +386,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
const handleCancel = async () => {
|
||||
sseControllerRef.current?.abort();
|
||||
sseControllerRef.current = null;
|
||||
// Defensive: no UI path can reach handleCancel while a request is in
|
||||
// flight (the cancel affordance renders only at phase === 'analyzing'),
|
||||
// but invalidate it anyway so the guard topology has no holes.
|
||||
invalidateRequest();
|
||||
if (jobIdRef.current) {
|
||||
try {
|
||||
await cancelAnalyze(jobIdRef.current);
|
||||
@@ -284,6 +398,8 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
}
|
||||
setPhase('input');
|
||||
setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') });
|
||||
setUploading(false);
|
||||
setUploadSummary(null);
|
||||
};
|
||||
|
||||
const isLoading = phase === 'starting';
|
||||
@@ -443,35 +559,51 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* Native folder picker + Browse button — below the input */}
|
||||
{/* Upload a folder from your computer — no server path or mount needed.
|
||||
The browser can't expose an absolute path, so we upload the files. */}
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
// @ts-expect-error -- webkitdirectory is non-standard but widely supported
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
className="hidden"
|
||||
data-testid="folder-upload-input"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const rel = files[0].webkitRelativePath;
|
||||
const folderName = rel.split('/')[0];
|
||||
if (folderName) {
|
||||
setLocalPath(folderName);
|
||||
setValidationError(null);
|
||||
}
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFolderUpload(e.target.files);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="upload-folder"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{t('onboarding:repoAnalyzer.browseForFolder')}
|
||||
{t('onboarding:repoAnalyzer.upload.button')}
|
||||
</button>
|
||||
{uploading && (
|
||||
<div role="status" aria-busy="true" data-testid="upload-progress" className="space-y-1">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
|
||||
<div className="h-full w-1/3 animate-pulse rounded-full bg-accent" />
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('onboarding:repoAnalyzer.upload.uploading')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{uploadSummary && !uploading && phase !== 'error' && (
|
||||
<p className="text-xs text-text-muted" data-testid="upload-summary">
|
||||
{t('onboarding:repoAnalyzer.upload.selected', {
|
||||
fileCount: uploadSummary.count,
|
||||
dropped: uploadSummary.dropped,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export function formatBackendError(error: unknown, t: TFunction): string {
|
||||
return t('errors:backend.rateLimited', { seconds, defaultValue: fallback });
|
||||
case 'not_found':
|
||||
return t('errors:backend.notFound', { defaultValue: fallback });
|
||||
case 'origin_blocked':
|
||||
return t('errors:backend.originBlocked', { defaultValue: fallback });
|
||||
case 'client':
|
||||
return t('errors:backend.client', { message: error.message, defaultValue: fallback });
|
||||
case 'server':
|
||||
|
||||
@@ -38,6 +38,7 @@ export const NODE_COLORS: Record<NodeLabel, string> = {
|
||||
Template: '#a78bfa', // Violet light - like Type
|
||||
Route: '#f43f5e', // Rose - like Process
|
||||
Tool: '#a855f7', // Purple - like Project
|
||||
BasicBlock: '#475569', // Slate darker - control-flow node (muted, taint/PDG substrate)
|
||||
};
|
||||
|
||||
// Node sizes by type - clear visual hierarchy with dramatic size differences
|
||||
@@ -79,6 +80,7 @@ export const NODE_SIZES: Record<NodeLabel, number> = {
|
||||
Template: 3, // Like Type
|
||||
Route: 5, // Like Enum
|
||||
Tool: 5, // Like Enum
|
||||
BasicBlock: 2, // Tiny - control-flow node (taint/PDG substrate)
|
||||
};
|
||||
|
||||
// Community color palette for cluster-based coloring
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterRepoFiles, MAX_FILE_BYTES } from './upload-filter';
|
||||
|
||||
type FileLike = { name: string; size: number; webkitRelativePath?: string };
|
||||
|
||||
function f(webkitRelativePath: string, size = 10): FileLike {
|
||||
const name = webkitRelativePath.split('/').pop() ?? webkitRelativePath;
|
||||
return { name, size, webkitRelativePath };
|
||||
}
|
||||
|
||||
describe('filterRepoFiles', () => {
|
||||
it('keeps source files and builds an order-aligned manifest', () => {
|
||||
const input = [f('repo/src/index.ts', 100), f('repo/README.md', 50)];
|
||||
const r = filterRepoFiles(input);
|
||||
expect(r.files).toHaveLength(2);
|
||||
expect(r.manifest).toEqual(['repo/src/index.ts', 'repo/README.md']);
|
||||
expect(r.totalBytes).toBe(150);
|
||||
expect(r.droppedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes .git / node_modules / build dirs anywhere in the path', () => {
|
||||
const input = [
|
||||
f('repo/.git/HEAD'),
|
||||
f('repo/node_modules/x/index.js'),
|
||||
f('repo/dist/bundle.js'),
|
||||
f('repo/src/app.ts'),
|
||||
f('repo/.gitnexus/meta.json'),
|
||||
];
|
||||
const r = filterRepoFiles(input);
|
||||
expect(r.manifest).toEqual(['repo/src/app.ts']);
|
||||
expect(r.droppedCount).toBe(4);
|
||||
});
|
||||
|
||||
it('drops files over the per-file size cap', () => {
|
||||
const input = [f('repo/big.bin', MAX_FILE_BYTES + 1), f('repo/small.ts', 10)];
|
||||
const r = filterRepoFiles(input);
|
||||
expect(r.manifest).toEqual(['repo/small.ts']);
|
||||
expect(r.droppedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to name when webkitRelativePath is absent', () => {
|
||||
const r = filterRepoFiles([{ name: 'lone.ts', size: 5 }]);
|
||||
expect(r.manifest).toEqual(['lone.ts']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Client-side pre-filter for a webkitdirectory folder upload.
|
||||
*
|
||||
* Drops VCS metadata, dependency/build directories, and oversized files before
|
||||
* upload — `.git` alone is often larger than the working tree — so payloads
|
||||
* stay small and the upload matches what the analyzer actually needs. Produces
|
||||
* an order-aligned `manifest` of webkitRelativePaths (the server keys on this,
|
||||
* not the multipart filename, which browsers rewrite).
|
||||
*/
|
||||
|
||||
/** Directory names excluded anywhere in a file's path. */
|
||||
export const EXCLUDED_DIRS = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'target',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.cache',
|
||||
'coverage',
|
||||
'.idea',
|
||||
'.gitnexus',
|
||||
]);
|
||||
|
||||
/** Per-file size cap; matches the server's per-file limit. */
|
||||
export const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
export interface FilterResult {
|
||||
files: File[];
|
||||
manifest: string[];
|
||||
droppedCount: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
type FileLike = Pick<File, 'name' | 'size'> & { webkitRelativePath?: string };
|
||||
|
||||
/**
|
||||
* Filter a webkitdirectory `FileList` (or array) into the files to upload plus
|
||||
* their relative-path manifest.
|
||||
*/
|
||||
export function filterRepoFiles(input: ArrayLike<FileLike>): FilterResult {
|
||||
const files: File[] = [];
|
||||
const manifest: string[] = [];
|
||||
let droppedCount = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const f = input[i];
|
||||
const rel =
|
||||
f.webkitRelativePath && f.webkitRelativePath.length > 0 ? f.webkitRelativePath : f.name;
|
||||
const segments = rel.split('/');
|
||||
if (segments.some((s) => EXCLUDED_DIRS.has(s))) {
|
||||
droppedCount++;
|
||||
continue;
|
||||
}
|
||||
if (f.size > MAX_FILE_BYTES) {
|
||||
droppedCount++;
|
||||
continue;
|
||||
}
|
||||
files.push(f as File);
|
||||
manifest.push(rel);
|
||||
totalBytes += f.size;
|
||||
}
|
||||
|
||||
return { files, manifest, droppedCount, totalBytes };
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"timeout": "The server took too long to respond. Try again in a moment.",
|
||||
"rateLimited": "Too many requests. Try again in {{seconds}}s.",
|
||||
"notFound": "The requested repository or resource was not found.",
|
||||
"originBlocked": "This action isn't available from the hosted UI. Open GitNexus from the server's own address (e.g. http://localhost:4747) to continue.",
|
||||
"client": "Request failed: {{message}}",
|
||||
"server": "Server error: {{message}}"
|
||||
}
|
||||
|
||||
@@ -61,7 +61,12 @@
|
||||
"gitlabRepositoryUrl": "GitLab Repository URL",
|
||||
"gitlabSupported": "Supports GitLab.com and self-hosted GitLab instances.",
|
||||
"localFolderPath": "Local Folder Path",
|
||||
"browseForFolder": "Browse for folder",
|
||||
"hideBackground": "Hide (analysis continues in background)"
|
||||
"hideBackground": "Hide (analysis continues in background)",
|
||||
"upload": {
|
||||
"button": "Upload a folder",
|
||||
"uploading": "Uploading…",
|
||||
"selected": "{{fileCount}} files ready ({{dropped}} skipped: .git, node_modules, build output)",
|
||||
"empty": "No analyzable files found in that folder."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"timeout": "服务器响应超时,请稍后重试。",
|
||||
"rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。",
|
||||
"notFound": "未找到请求的仓库或资源。",
|
||||
"originBlocked": "此操作无法从托管界面执行。请通过服务器自身地址(例如 http://localhost:4747)打开 GitNexus 后再继续。",
|
||||
"client": "请求失败:{{message}}",
|
||||
"server": "服务器错误:{{message}}"
|
||||
}
|
||||
|
||||
@@ -61,7 +61,12 @@
|
||||
"gitlabRepositoryUrl": "GitLab 仓库 URL",
|
||||
"gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。",
|
||||
"localFolderPath": "本地文件夹路径",
|
||||
"browseForFolder": "浏览文件夹",
|
||||
"hideBackground": "隐藏(分析继续在后台进行)"
|
||||
"hideBackground": "隐藏(分析继续在后台进行)",
|
||||
"upload": {
|
||||
"button": "上传文件夹",
|
||||
"uploading": "上传中…",
|
||||
"selected": "已准备 {{fileCount}} 个文件(已跳过 {{dropped}} 个:.git、node_modules、构建产物)",
|
||||
"empty": "该文件夹中未找到可分析的文件。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,11 @@ export class BackendError extends Error {
|
||||
| 'client'
|
||||
| 'not_found'
|
||||
| 'timeout'
|
||||
| 'rate_limited',
|
||||
| 'rate_limited'
|
||||
// The write-route same-host Origin guard rejected this request (HTTP 403
|
||||
// with `{ code: 'origin_not_allowed' }`). Distinct from a generic `client`
|
||||
// 403 so the UI can show actionable "open the local UI" guidance.
|
||||
| 'origin_blocked',
|
||||
/**
|
||||
* Milliseconds until the caller should retry. Populated for rate-limited
|
||||
* responses (HTTP 429) from the server's `Retry-After` header. `undefined`
|
||||
@@ -283,12 +287,11 @@ const fetchWithTimeout = async (
|
||||
): Promise<Response> => {
|
||||
// Merge the external caller signal (if any) with an
|
||||
// `AbortSignal.timeout()` so a timer-fired abort produces a
|
||||
// `DOMException` with `name === 'TimeoutError'` — which
|
||||
// `resilientFetch` correctly classifies as terminal-network (no
|
||||
// retry, no breaker hit). A manual `AbortController.abort()` would
|
||||
// produce `name === 'AbortError'` and route through the
|
||||
// retryable-network branch, which mis-penalizes the breaker for
|
||||
// user-side network slowness.
|
||||
// `DOMException` with `name === 'TimeoutError'`. Both shapes are
|
||||
// breaker-safe: `resilientFetch` classifies TimeoutError AND a manual
|
||||
// `AbortController.abort()`'s AbortError as terminal-network (no
|
||||
// retry, breaker-neutral via recordNeutral), so caller-driven
|
||||
// cancellation never penalizes the breaker.
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const externalSignal = init.signal;
|
||||
const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
|
||||
@@ -362,6 +365,7 @@ const assertOk = async (response: Response): Promise<void> => {
|
||||
if (response.ok) return;
|
||||
|
||||
let message = response.statusText;
|
||||
let bodyCode: string | undefined;
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body && typeof body.error === 'string') {
|
||||
@@ -369,6 +373,9 @@ const assertOk = async (response: Response): Promise<void> => {
|
||||
} else if (body && typeof body.message === 'string') {
|
||||
message = body.message;
|
||||
}
|
||||
if (body && typeof body.code === 'string') {
|
||||
bodyCode = body.code;
|
||||
}
|
||||
} catch {
|
||||
// Response body was not JSON
|
||||
}
|
||||
@@ -378,9 +385,13 @@ const assertOk = async (response: Response): Promise<void> => {
|
||||
? 'not_found'
|
||||
: response.status === 429
|
||||
? 'rate_limited'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'client'
|
||||
: 'server';
|
||||
: // The write-route Origin guard returns 403 with this discriminator;
|
||||
// surface it as a distinct code so the UI can give actionable guidance.
|
||||
bodyCode === 'origin_not_allowed'
|
||||
? 'origin_blocked'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'client'
|
||||
: 'server';
|
||||
|
||||
// Retry-After is the standard HTTP signal for when the client may try again.
|
||||
// express-rate-limit emits it on 429 with seconds (integer) or HTTP-date.
|
||||
@@ -755,6 +766,35 @@ export const fetchClusterDetail = async (repo: string, name: string): Promise<un
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// ── Upload API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a folder (selected via `<input webkitdirectory>`) and start analysis.
|
||||
* Sends the file blobs plus a JSON `manifest` of their relative paths — the
|
||||
* multipart filename can't carry the path (browsers strip separators), so the
|
||||
* manifest is the source of truth. Routed through fetchWithTimeout (the shared,
|
||||
* origin-validated request path) rather than a raw XHR; returns the analysis
|
||||
* jobId, which the caller drives through the normal SSE flow.
|
||||
*/
|
||||
export const uploadFolder = async (
|
||||
files: File[],
|
||||
manifest: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ jobId: string; status: string }> => {
|
||||
const form = new FormData();
|
||||
// Manifest MUST precede the file parts (the server enforces this).
|
||||
form.append('manifest', JSON.stringify(manifest));
|
||||
for (const f of files) form.append('files', f);
|
||||
|
||||
const response = await fetchWithTimeout(
|
||||
`${_backendUrl}/api/analyze/upload`,
|
||||
{ method: 'POST', body: form, signal },
|
||||
5 * 60_000, // up to 5 min for large repos
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json() as Promise<{ jobId: string; status: string }>;
|
||||
};
|
||||
|
||||
// ── Analyze API ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Start a server-side analysis job. */
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getBreaker } from 'gitnexus-shared';
|
||||
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
|
||||
import { fetchRepos, setBackendUrl, startAnalyze } from '../../src/services/backend-client';
|
||||
import {
|
||||
deleteRepo,
|
||||
fetchRepos,
|
||||
setBackendUrl,
|
||||
startAnalyze,
|
||||
} from '../../src/services/backend-client';
|
||||
|
||||
const BASE = 'http://localhost:4747';
|
||||
|
||||
@@ -89,6 +94,40 @@ describe('backend-client retry budget (method-aware)', () => {
|
||||
expect(getBreaker(bKey).getConsecutiveFailures()).toBe(0);
|
||||
});
|
||||
|
||||
it('maps an origin-blocked 403 to BackendError code "origin_blocked"', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: 'This endpoint is restricted to same-host origins',
|
||||
code: 'origin_not_allowed',
|
||||
}),
|
||||
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(deleteRepo('my-repo')).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: 'origin_blocked',
|
||||
});
|
||||
// 403 is a terminal client error — never retried.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('maps a generic 403 (no recognized code) to BackendError code "client" (back-compat)', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ error: 'forbidden' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(deleteRepo('my-repo')).rejects.toMatchObject({ status: 403, code: 'client' });
|
||||
});
|
||||
|
||||
it('breaker not incremented when timeout fires (TimeoutError, not AbortError)', async () => {
|
||||
// Reject directly with a TimeoutError DOMException, mimicking what
|
||||
// `fetch` produces when its `AbortSignal.timeout()`-wired signal
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Stale-request guards in RepoAnalyzer (PR #1850 review 4470339833).
|
||||
*
|
||||
* An analyze request (folder upload or URL analyze) that is still in flight
|
||||
* when the user switches modes, cancels, or unmounts must not drive state
|
||||
* when it later settles: no SSE stream, no phase/error flip — and a
|
||||
* stale-but-created server job gets a fire-and-forget cancel so the single
|
||||
* analyze slot is freed.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
|
||||
import { i18nReady } from '../../src/i18n';
|
||||
import {
|
||||
cancelAnalyze,
|
||||
startAnalyze,
|
||||
streamAnalyzeProgress,
|
||||
uploadFolder,
|
||||
} from '../../src/services/backend-client';
|
||||
|
||||
vi.mock('../../src/services/backend-client', () => ({
|
||||
startAnalyze: vi.fn(),
|
||||
cancelAnalyze: vi.fn(),
|
||||
streamAnalyzeProgress: vi.fn(),
|
||||
uploadFolder: vi.fn(),
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const JOB = { jobId: 'job-1', status: 'queued' };
|
||||
|
||||
/** Gate uploadFolder on a deferred promise and expose the signal it received. */
|
||||
function mockUploadWith(d: { promise: Promise<typeof JOB> }) {
|
||||
let captured: AbortSignal | undefined;
|
||||
vi.mocked(uploadFolder).mockImplementation((_files, _manifest, signal) => {
|
||||
captured = signal;
|
||||
return d.promise;
|
||||
});
|
||||
return { signal: () => captured };
|
||||
}
|
||||
|
||||
/** Render, switch to Local Folder mode, and fire a folder selection. */
|
||||
function startUpload() {
|
||||
const view = render(<RepoAnalyzer variant="onboarding" onComplete={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
fireEvent.change(screen.getByTestId('folder-upload-input'), {
|
||||
target: { files: [new File(['x'], 'a.ts')] },
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18nReady;
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(cancelAnalyze).mockResolvedValue(undefined as never);
|
||||
vi.mocked(streamAnalyzeProgress).mockImplementation(() => new AbortController());
|
||||
});
|
||||
|
||||
describe('folder upload', () => {
|
||||
it('a mode switch mid-upload makes the resolution inert and cancels the job', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
const upload = mockUploadWith(d);
|
||||
|
||||
startUpload();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'GitHub URL' }));
|
||||
|
||||
// The wire abort happened at mode-switch time, not at resolution time.
|
||||
expect(upload.signal()?.aborted).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
d.resolve(JOB);
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
|
||||
expect(cancelAnalyze).toHaveBeenCalledWith('job-1');
|
||||
// The GitHub form is clean and submittable (phase back to 'input').
|
||||
expect(screen.getByRole('textbox')).toBeEnabled();
|
||||
expect(screen.queryByTestId('upload-progress')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['BackendError shape', new Error('Request aborted')],
|
||||
['raw AbortError shape', new DOMException('The operation was aborted.', 'AbortError')],
|
||||
])('an aborted rejection is silent — %s', async (_label, err) => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(uploadFolder).mockReturnValue(d.promise);
|
||||
|
||||
startUpload();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'GitHub URL' }));
|
||||
await act(async () => {
|
||||
d.reject(err);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Request aborted')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('The operation was aborted.')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('a same-tab click does not abort the in-flight upload', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
const upload = mockUploadWith(d);
|
||||
|
||||
startUpload();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
|
||||
expect(upload.signal()?.aborted).toBe(false);
|
||||
await act(async () => {
|
||||
d.resolve(JOB);
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
|
||||
expect(cancelAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an unmount mid-upload makes the resolution inert', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
const upload = mockUploadWith(d);
|
||||
|
||||
const { unmount } = startUpload();
|
||||
unmount();
|
||||
|
||||
expect(upload.signal()?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
d.resolve(JOB);
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the happy path still tracks the job', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(uploadFolder).mockReturnValue(d.promise);
|
||||
|
||||
startUpload();
|
||||
await act(async () => {
|
||||
d.resolve(JOB);
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(streamAnalyzeProgress).mock.calls[0][0]).toBe('job-1');
|
||||
expect(cancelAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a genuine error still surfaces', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(uploadFolder).mockReturnValue(d.promise);
|
||||
|
||||
startUpload();
|
||||
await act(async () => {
|
||||
d.reject(new Error('upload exploded'));
|
||||
});
|
||||
|
||||
expect(screen.getByText('upload exploded')).toBeInTheDocument();
|
||||
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL analyze', () => {
|
||||
function startGithubAnalyze() {
|
||||
render(<RepoAnalyzer variant="onboarding" onComplete={vi.fn()} />);
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: 'https://github.com/owner/repo' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Analyze Repository/ }));
|
||||
}
|
||||
|
||||
it('a mode switch mid-analyze makes the resolution inert without cancelling', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(startAnalyze).mockReturnValue(d.promise);
|
||||
|
||||
startGithubAnalyze();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
await act(async () => {
|
||||
d.resolve({ jobId: 'job-2', status: 'queued' });
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
|
||||
// No cancel on the URL path: the jobId may be dedup-aliased to a job
|
||||
// another session owns, so cancelling could kill a live analysis.
|
||||
expect(cancelAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a stale rejection is silent', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(startAnalyze).mockReturnValue(d.promise);
|
||||
|
||||
startGithubAnalyze();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
await act(async () => {
|
||||
d.reject(new Error('analyze exploded'));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('analyze exploded')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('upload-folder')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('the happy path still tracks the job', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(startAnalyze).mockReturnValue(d.promise);
|
||||
|
||||
startGithubAnalyze();
|
||||
await act(async () => {
|
||||
d.resolve({ jobId: 'job-3', status: 'queued' });
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(streamAnalyzeProgress).mock.calls[0][0]).toBe('job-3');
|
||||
expect(cancelAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,26 @@ node_modules/
|
||||
vendor/**/node_modules
|
||||
vendor/**/build
|
||||
|
||||
# ── Lean publish (FUTURE optimization — NOT done here) ─────────────────────────
|
||||
# Once the build-tree-sitter-prebuilds workflow has committed 6/6 prebuilds for
|
||||
# EVERY vendored grammar (c, dart, proto, kotlin, swift), the ~50 MB of generated
|
||||
# source (parser.c etc.) can be dropped from the tarball — node-gyp-build never
|
||||
# needs the source when a prebuild matches.
|
||||
#
|
||||
# IMPORTANT: this CANNOT be done from this file. package.json's `files: ["vendor"]`
|
||||
# allow-list OVERRIDES .npmignore for the vendor/ subtree (verified: an active
|
||||
# `vendor/**/src/parser.c` line here does NOT exclude it from `npm pack`). To slim
|
||||
# the tarball, narrow the `files` field instead — replace the blanket "vendor"
|
||||
# with the non-source subpaths only (vendor/**/prebuilds/**,
|
||||
# vendor/**/bindings/node/index.*, vendor/**/src/node-types.json,
|
||||
# vendor/**/package.json, vendor/**/LICENSE, vendor/**/README.md).
|
||||
#
|
||||
# Whatever the mechanism, the prepack guard
|
||||
# (scripts/assert-publish-grammar-coverage.cjs, also `npm run
|
||||
# assert-publish-coverage`) inspects the EFFECTIVE `npm pack` file list and FAILS
|
||||
# the publish whenever a grammar with <6 prebuilds loses a source-build input — so
|
||||
# the slim can never silently ship a dead grammar. Do not bypass it.
|
||||
|
||||
# Package lock (consumers use their own)
|
||||
package-lock.json
|
||||
|
||||
|
||||
@@ -4,6 +4,100 @@ All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.7] - 2026-06-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Toolchain-free tree-sitter install** — the `c`, `dart`, `proto`, `kotlin`, and `swift` grammars now ship vendored native prebuilds (six platform/arch each — linux/darwin/win32 × x64/arm64, every `.node` load-and-parse verified with committed `SHA256SUMS` and SLSA build provenance), so a fresh install no longer requires a C/C++ toolchain; `kotlin` moved off its `optionalDependency` into the vendored path, `dart`/`proto` keep a source-build fallback when no prebuild matches, and a registry-parameterized CI workflow builds, load-validates, and vendors the binaries (#2113, #2125, #2110)
|
||||
- **`gitnexus uninstall`** — reverses `gitnexus setup` target-by-target, surgically removing GitNexus MCP server entries (Cursor, Claude Code, Antigravity, OpenCode, Codex), installed skill directories, and Claude Code / Antigravity hook entries with their bundled scripts; idempotent, JSONC-preserving, dry-run by default with `--force` to apply (#2062, #2060)
|
||||
- **MCP `list_repos` pagination** — bounded `limit`/`offset` paging so clients can reliably enumerate every indexed repository instead of having the unpaginated array truncated by LLM token limits; the result is now a `{ repositories, pagination }` object (page until `pagination.hasMore` is false), with deterministic `(lower-cased name, path)` ordering (#2120, #2119)
|
||||
- **C++ inheritance-lattice member lookup** — receiver members now resolve through the inheritance lattice with dominance hiding, ambiguous-base suppression, virtual-diamond deduplication, and overload ranking, and class-scope `using Base::member` declarations are no longer mistaken for namespace imports (#2077, #1891)
|
||||
- **Taint/PDG substrate (M0)** — foundational graph schema and pipeline seams for reliable taint analysis on a PDG-expandable substrate: the `BasicBlock` node label and `CFG` / `REACHING_DEF` / `TAINTED` / `SANITIZES` / `TAINT_PATH` relationship types (round-tripped through the bulk-COPY path), a phase-registry seam (`registerPhase` / `enabledWhen`) generalising the graph-phase opt-in guard, and a per-language source/sink/sanitizer config registry. All additive and inert — no phase emits the new nodes/edges yet and a default `analyze` run is byte-identical to before (#2092, #2080)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Optional grammars lazy-loaded so `analyze` never crashes when one is missing** — the swift/dart/kotlin `query.ts` modules no longer statically import their tree-sitter binding at module load, so a missing optional grammar can no longer abort `gitnexus analyze` (or the MCP server, `doctor`, and `.githooks` auto-reindex) with `ERR_MODULE_NOT_FOUND` regardless of the repo's actual languages; grammars now resolve lazily at first use inside the worker, `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` is honored at runtime, the scope-resolution phase excludes unavailable-language files, and skip diagnostics/precheck globs were corrected (#2101, #2091, #2093)
|
||||
- **`tree-sitter-kotlin` optional-grammar install** — install now fails soft when no C/C++ toolchain is present, emitting one clear warning and always exiting 0 (mirroring the Swift/Dart/Proto probes) instead of breaking `gitnexus` install; optional-grammar/toolchain docs corrected to include Kotlin (#2110, #2107)
|
||||
- **CLI image FTS keyword search** — the full-text-search extension is now baked into the CLI Docker image so a containerized `serve` does offline keyword search instead of silently degrading to vector-only (#2108)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tree-sitter prebuild CI matrix greened and made re-run-safe** — dropped the broken `-t 22` flag from the `prebuildify` invocation that crashed every matrix job (`v.indexOf is not a function`; N-API prebuilds are Node-version-agnostic, so no target is needed) (#2121), cleared npm-bundled `prebuilds/` before prebuildify so the host tuple is detected (not a stray `win32-x64`) and source-built the `tree-sitter` runtime peer on `linux-arm64` where upstream ships no prebuild (#2122), and switched the vendor-prebuilds push to `git push --force` so re-running a workflow no longer fails with a stale-lease rejection (#2123)
|
||||
|
||||
### Performance
|
||||
|
||||
- **MCP `query` enrichment batched** — the `query` tool now batches its per-symbol enrichment lookups (3N sequential pool round-trips collapsed to 2–3 `WHERE n.id IN $nodeIds` queries), cutting N+1 round-trips with byte-identical output (#2108)
|
||||
|
||||
### Chore / Dependencies
|
||||
|
||||
- **`@ladybugdb/core` bumped 0.17.0 → 0.17.1 in /gitnexus** (#2098)
|
||||
- **Claude plugin manifests synced to the release version** — bumped `plugin.json` and the `gitnexus` `marketplace.json` entry to match the published npm version (stale `1.3.x` manifests had blocked marketplace updates), added a Vitest guard asserting all three manifests advertise one version, and documented the sync step in `CONTRIBUTING.md` (#2090)
|
||||
|
||||
## [1.6.6] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Scope-resolution (RFC #909) migrations completed across the language matrix** — Rust (#1639), JavaScript (#1640), Ruby (#1831), Swift (#937, #1948), Vue SFC (#940, #1950), Dart (#939, #1970), COBOL (#941, #1835, #1842), and Kotlin (#1727, #1746, #1782) now run on the registry-primary path; Java reached 100% scope-resolution parity and joined `MIGRATED_LANGUAGES` (#1805); per-language progress reporting added to the scope-resolution phase (#1813)
|
||||
- **HTTP route & consumer contract extraction (group mode)** — Spring interface routes attributed to controllers (#1743); named/positional Java Spring route args (#1834); Kotlin Spring HTTP route, consumer, and WebClient long-form extraction (#1849, #1855, #1884); Java HTTP consumer contracts (#1872); OpenFeign `@RequestLine` consumer contracts incl. plain interfaces without `@FeignClient` (#1904, #1917); FastAPI `include_router(prefix=...)` cross-file routes (#1877); indirect call patterns via FastAPI `Depends()` and frontend HTTP consumers (#1852); gRPC consumer FQN derivation from Java imports for client-jar consumers (#1889)
|
||||
- **C++ overload & template resolution** — operator-call resolution (#1754), template partial ordering (#1885), user-defined conversion ranking (#1829), nullptr/ellipsis pointer conversion ranks (#1708), SFINAE filter (#1623), expanded `type_traits` constraint registry (#1648), structured resolver-suppression outcomes (#1785), function-type ADL entities (#1822), and a parameter-type class sidecar (#1642)
|
||||
- **Go enhancements** — structural interface implementation inference (#1966) and a `builtInNames` set for the Go language provider (#1886)
|
||||
- **Self-healing worker pool** — automatic worker replacement plus deferred-resolution observability and verbose progress logging (#1741, #1773, #1947)
|
||||
- **`.gitnexusrc` config file and `gitnexus analyze --default-branch`** (#243, #1996)
|
||||
- **CLI / MCP impact ergonomics** — `--uid/--file/--kind` disambiguation flags (#1907, #1914), `limit/offset/summaryOnly` pagination on the impact tool (#1818), and a per-symbol `processes` field on `byDepth` items (#1867)
|
||||
- **`gitnexus analyze --repair-fts`** — enforces FTS verification with hardened repair safeguards (#1720)
|
||||
- **Web viewer** — Tree View and Circles View (#1799), GitLab repository URLs (#1565), `GITNEXUS_BACKEND_URL` env var for Docker deployments (#1286), and web + CLI internationalization (#1748)
|
||||
- **Wiki** — local Claude/Codex providers (#1769), an opencode local provider (#2039), and `gitnexus wiki --lang <lang>` for multilanguage wiki generation (#1613)
|
||||
- **`detect-changes` git-worktree support** (#1654)
|
||||
- **DeepSeek V4 API support** (#1594)
|
||||
- **Devcontainer for the Claude / Codex / Cursor CLIs** (#1875) and antigravity integration setup + hook adapter (#1730)
|
||||
- **Object-literal methods linked to exported bindings** (#1718)
|
||||
- **`eval-server --host`** for a user-configured bind IP (#1667)
|
||||
- **PR reviewer swarm agents** (#1851)
|
||||
- **tree-sitter node-type/field validation gate** — validates against the grammar and removes dead literal handling (#1937)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Parsing-layer coverage gaps closed across the language matrix** (umbrella #1919) — remaining open gaps (#2072) plus Java F35/F38/F41 (#1928, #2045), PHP F53/F54/F55 (#1931, #1989), COBOL F17–F23 (#1925, #1959), Rust F66/F68/F71/F72 (#1934, #1974), Python F57/F58/F61 (#1932, #1964), JS/TS F44/F83/F85/F86/F87 (#1929, #1968), and Ruby F62 (#1933, #1972)
|
||||
- **Fully-qualified nested-type identity for C++ and Ruby** — distinct nodes for union-, anonymous-namespace-, and same-tail-nested types (#1978, #1981, #2004, #2005); cross-namespace same-tail inheritance bases resolved (#1993, #2005); Ruby same-tail nested mixin modules qualified with `IMPLEMENTS` routed by scope (#1991, #2006); shared codec for `__heritage__`/`__property__` markers (#1994, #2007); graph nodes materialized for scoped class/module/impl declarations (#1975, #1977); generic Rust inherent-impl methods owned through the mod-qualified `Impl` node (#1992, #2003)
|
||||
- **C# resolution & memory** — global-namespace `typeBindings` O(files²) OOM eliminated (#1871, #1954) and namespace-siblings OOM with worker-path re-parse removed (#1905); qualified/alias constructor names, `:base`/`:this` initializers, and generic type-arg stripping (#2046); primary-base receiver type normalization (#2036); spurious `IMPORTS` edges from ungated `using` resolution stopped (#1881, #1908)
|
||||
- **C++ dependent-base and member lookup** — resolution across nested/inline namespaces (#1634, #1814), base-specifier qualifier threading (#1815, #1819), call-site types threaded into qualified member lookup (#1632, #1810), variadic pack dependent lookup (#1909), uninitialized multi-declarators (#1965), and typedef-enum / anonymous-struct declarations (#1941)
|
||||
- **Kotlin type resolution** — smart-cast refinement for `when/is` and `if/is` (#1758, #1774), overload target-id by parameter types (#1761, #1777), cross-file iterable return propagation (#1759, #1775), method-chain fixpoint receiver types (#1760, #1776), virtual dispatch via constructor type override (#1762, #1778), interface default-method dispatch via implements-split MRO (#1763, #1779), and default-parameter arity detection (#2034)
|
||||
- **Go declarations** — multi-name declaration capture (#2032), fixed-array parameter binding normalization (#1988), and generic composite-literal constructor inference F33 (#1976)
|
||||
- **Rust / PHP / Vue / Java parsing** — Rust `struct_expression` name pattern split (#2051); PHP import decomposition, namespace-less `.phtml` module scopes, and Blade-template exclusion (#1801, #1790, #1989); Vue JSDoc, dual-script merge, and lang plumbing F89/F90/F92 (#1936, #2050); Java inherited `RequestMapping` prefix deduplication (#2057) and same-module type resolution for duplicate FQNs (#1712)
|
||||
- **TypeScript** — HOC pattern false positives fixed with `export default` HOC support (#1943) and suffix-index reuse in the scope resolver (#1840)
|
||||
- **Inheritance on the worker path** — all languages' inheritance migrated to scope-resolution in worker mode (#1951, #1956); centralized heritage supertype matching (#1921, #1922, #1940); `File->Member` `DEFINES` edges skipped for class members (#1949); phantom `Function` defs for array-method callbacks no longer emitted (#1906)
|
||||
- **MCP** — sibling-clone repo-ID collisions prevented and generated MCP tool names corrected (#2067); orphan processes avoided by handling stdin close/end and the startup race (#2049); duplicate-name repo resolution disambiguated for worktrees (#1753); Windows setup fallback when global `gitnexus` resolves to a non-spawnable shim (#1694)
|
||||
- **Worker pool** — resilient zero-copy ingestion worker pool prevents analyze hangs on TS-root-scale loads (#1693); cache-hit native workers no longer abort (#1751, #1833); worker-pool docs drift corrected and worker-side stack surfaced on crash (#2068, #2070)
|
||||
- **LadybugDB** — FTS loaded in the Windows read pool (#2040) and probed-then-loaded on Windows (#1690, #1692); non-ASCII KuzuDB paths resolved on Windows (#1811, #1817); WAL corruption detected in schema init with recovery surfaced (#1647, #1650); WAL checkpoint-threshold control (#1772); init lock skipped for read-only opens (#1783, #1784); `serve` kept stable when sidecars are missing (#1747)
|
||||
- **Server / API** — `gitnexus serve` startup restored under Express 5 (#1749); `/api/graph`, `/api/search`, `/api/grep` opened read-only (#1686); native read-only enforcement and prepared statements for Cypher query paths (#1655); `eval-server` localhost binding left to the OS (#1722)
|
||||
- **Embeddings** — local ONNX runtime guarded on macOS Intel before the transformers.js import (#1987)
|
||||
- **Web agent** — Nexus AI agent system prompt aligned with registered tools (#1984) and the agent stopped cleanly on user Stop (#1820)
|
||||
- **Group / contracts** — HTTP graph and source contracts unioned (#1709); `httpx` `AsyncClient` alias imports detected (#1687); Node gRPC `loadPackageDefinition` gate no longer matches every member call (#1916); manifest/workspace extraction moved before `closeLbug` (#1802, #1807)
|
||||
- **Hooks / install** — `gitnexus` resolved on `PATH` via a pure-Node, all-OS scan (#1938, #1980); offline-first extension installs (#1161); actionable error and docs for the `pnpm dlx`/`pnpx` native-load crash (#307, #1967); `onnxruntime-common` declared as a runtime dependency (#2074); vendored grammars materialized to fix Windows EPERM (#1728, #1729)
|
||||
- **CLI** — missing LadybugDB native binary detected at startup with actionable guidance (#835, #1837); `--no-stats` applied to the keep-marker stats line (#1706, #1765); skipped large-file paths surfaced by default (#1659, #1661); build.js skipped when running outside the monorepo (#1795, #1816); auto-heap raised to 16 GB with tightened cross-platform OOM guidance for UE5-scale repos (#1652)
|
||||
- **Wiki** — hidden 60s default timeout removed with timeout/retry flag validation and surfaced timeout errors (#1651); budget-aware grouping to prevent context overflow on large repos (#627, #1832)
|
||||
- **`detect-changes`** — `resolveWorktreeCwd` guarded against overriding a separately-indexed worktree (#1691)
|
||||
- **Windows reliability** — `windowsHide:true` passed to every `child_process` spawn-family call (#1794)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Legacy resolution deletion (Ring 4)** — removed the legacy call-resolution DAG + heritage processor (RING4-1, #942, #2023), the legacy resolution-context + tiered-lookup plumbing (RING4-2, #943, #2033), and the shadow-mode parity harness (RING4-3, #944, #2071)
|
||||
- **CONTRIBUTING** — clarified local development setup (#2024)
|
||||
- **Tests / CI** — cli-e2e made read-only and eval-server tests hardened under load (#2000, #1786, #1838, #1688); parity shards consolidated and the cross-platform matrix narrowed (#1798); devcontainer smoke build hardened against Docker Hub flakes (#1969); gitleaks stabilized (#2027)
|
||||
|
||||
### Performance
|
||||
|
||||
- **Linux-kernel-scale analysis overhaul** — worker-pool parse, finalize O(n²), and the scope-resolution memory wall (#1983, #2038)
|
||||
- **Scope-capture linearized across all languages (O(n²)→O(n))** plus Python import-resolution linearization (#1918), the Go-specific re-walk fix (#1848, #1915), and owner-keyed lookup for Step 2 member resolution (#1657)
|
||||
- **C++ ADL candidates indexed once instead of per-site rescans** (#1990)
|
||||
- **Inert local value symbols pruned** during ingestion (#2065)
|
||||
|
||||
### Chore / Dependencies
|
||||
|
||||
- `@ladybugdb/core` bump in /gitnexus (#2056)
|
||||
- Routine dependency bumps across /gitnexus, /gitnexus-web, /eval, and GitHub Actions — incl. `hono`, `vitest`, `@vitest/coverage-v8`, `tsx`, `lru-cache`, `express`/`@types/express`, `express-rate-limit`, `qs`, `node-addon-api`, `brace-expansion`, `langchain`, `i18next`, `dompurify`, `lucide-react`, `axios`, `zod`, `@langchain/langgraph`, `@vercel/node`, `langsmith`, `aiohttp`, `idna`, and the `docker/*` / `github/codeql-action` / `release-drafter` / `dependency-review-action` actions (#2056, #2044, #2043, #2042, #2016, #2015, #2013, #2012, #2011, #2010, #2009, #2008, #2018, #2019, #2017, #2020, #1986, #1911, #1864, #1863, #1861, #1860, #1866, #1844, #1845, #1826, #1825, #1824, #1791, #1789, #1768, #1767, #1739, #1740, #1738, #1736, #1735, #1734, #1731, #1713, #1698, #1697, #1696, #1689, #1604, #1552, #1464, #872)
|
||||
- **Security** — `@vercel/node` upgraded in /gitnexus-web with transitive advisories remediated (#1705)
|
||||
|
||||
## [1.6.5] - 2026-05-16
|
||||
|
||||
### Added
|
||||
|
||||
+37
-4
@@ -34,7 +34,7 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
|
||||
|
||||
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
|
||||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`.
|
||||
|
||||
### Editor Support
|
||||
|
||||
@@ -126,7 +126,7 @@ Your AI agent gets these tools automatically:
|
||||
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ---------------- | ---------------------------------------------------------------- | ------------ |
|
||||
| `list_repos` | Discover all indexed repositories | — |
|
||||
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
|
||||
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
|
||||
@@ -158,7 +158,8 @@ Your AI agent gets these tools automatically:
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus setup # Configure MCP for detected editors (one-time; use -c to select)
|
||||
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
@@ -196,6 +197,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
|
||||
|
||||
## Remote Embeddings
|
||||
|
||||
Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint instead of the local model:
|
||||
@@ -400,7 +403,7 @@ Values above **32768 KB (32 MB)** are clamped to the tree-sitter parser ceiling;
|
||||
|
||||
### Analyze reports a worker timeout
|
||||
|
||||
Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and falls back to the sequential parser when needed. If a large repository needs more time per worker job, use either:
|
||||
Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and quarantines a file that repeatedly crashes its worker (respawning the slot so the pool keeps going). If a large repository needs more time per worker job, use either:
|
||||
|
||||
```bash
|
||||
# CLI flag, in seconds
|
||||
@@ -423,6 +426,36 @@ Three env vars expose the pool's resilience layers (respawn budget, cumulative-t
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
|
||||
### Graph cleanup tuning
|
||||
|
||||
After scope resolution, analyze prunes inert block-local value symbols (a function-local `const`/`let`/`var` that ends up with only its structural `File→DEFINES` edge) to keep the graph focused on cross-symbol relationships. Module/file-scope symbols, class members, and any local with a real edge are always kept.
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` | unset | Set to `1`/`true` to keep inert block-local value symbols instead of pruning them. |
|
||||
|
||||
Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var.
|
||||
|
||||
### Hook augmentation/notifications are silently skipped
|
||||
|
||||
The Claude Code / Antigravity hooks intentionally stay **silent** on normal skip
|
||||
paths so strict hook runners (e.g. Codex `PreToolUse`) never see unexpected
|
||||
output. A search may not be augmented — or a stale-index reminder may not appear
|
||||
on stderr — when the GitNexus MCP server owns the repo DB, when the DB-lock probe
|
||||
times out and fails closed, or when the index is already current.
|
||||
|
||||
To see why a hook skipped, set `GITNEXUS_DEBUG=1` and re-run the action — the hook
|
||||
writes the reason (e.g. `[GitNexus] augment skipped: MCP server owns DB`) and the
|
||||
stale-index hint to its stderr:
|
||||
|
||||
```bash
|
||||
GITNEXUS_DEBUG=1 <your command> # surfaces hook skip/diagnostic reasons on stderr
|
||||
```
|
||||
|
||||
Only `GITNEXUS_DEBUG=1` and `GITNEXUS_DEBUG=true` enable diagnostics; every other
|
||||
value (including `0` and `false`) is treated as off. Diagnostics go to stderr
|
||||
only — the hook's structured stdout (the JSON the agent consumes) is unaffected.
|
||||
|
||||
## Privacy
|
||||
|
||||
- All processing happens locally on your machine
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"straight-line": {
|
||||
"fingerprint": "792229965a726d2c6b527f9ee65440a2b3023839ee71cb51522fc30e2f2cb454",
|
||||
"scaling_budget": 1.5,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 2.0,
|
||||
"disk_bytes_large_max": 1309481,
|
||||
"_note": "#2081 M1 / #2082 M2: ONE function, N coalescing statements (extendBlock text accumulation + per-statement fact harvest). Runs at 2000->8000. M2 REWROTE the old 'output is constant 4 blocks' note: statement facts make disk/heap LINEAR in N (a free gate on the harvest payload); TIME still guards the concat path (array-join ~1.0; a genuine O(n^2) re-join accumulation is ~3.8). M2 adds rd_scaling_budget (measured ~0.74) and disk_bytes_large_max -- an ABSOLUTE ceiling ~1.35x the measured indexed-encoding bytes (969,986 at N=8000, ~121 B/stmt); a named-record encoding regression (~4x facts bytes) blows it. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change (the canon now includes statements+bindings)."
|
||||
},
|
||||
"many-functions": {
|
||||
"fingerprint": "d881f60e77f0262bdc1b5c7049aa4acf5071e0eabc536476be293c3a133e626e",
|
||||
"scaling_budget": 1.5,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 2.0,
|
||||
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: N small branchy functions (collect walk + per-function build + per-function solve). Time ~1.0, disk ~1.01, heap ~1.0, rd ~0.86 (solver is per-function; N functions scale linearly). M3 U1 re-fingerprinted: taint sites join StatementFacts (a()/b() call sites); disk_large 2565641->2721641 (+6.1% measured site-harvest cost at N=2000)."
|
||||
},
|
||||
"branchy": {
|
||||
"fingerprint": "936765bba5c3f8fc7058737c48351e03e4e1da7fed448467e8fcc8a0fb7786ce",
|
||||
"scaling_budget": 1.8,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 2.0,
|
||||
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (noisiest scenario; budget 1.8 absorbs noise, catches ~4.0 quadratic), disk ~1.03, heap ~1.0, rd ~0.7. M3 U1 re-fingerprinted (s{i}() call sites); disk_large 908964->993854 (+9.3%)."
|
||||
},
|
||||
"dense-bindings": {
|
||||
"fingerprint": "e4d7eb3c7e8b3772423af25cef391e0e6b68067b554819e81b543439a487403f",
|
||||
"scaling_budget": 1.8,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 10.0,
|
||||
"_note": "#2082 M2: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The overlay design measures rd ~5.2 normalized: the OUT spine copy on genning blocks is O(V) per block, which is quadratic when V scales with B (bounded in prod by maxFunctionLines; real functions have V~10-40). Budget 10 deliberately tolerates that known shape and exists to catch the repo's recurring per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16). If rd drops well below 5, tighten."
|
||||
},
|
||||
"fact-fanout": {
|
||||
"fingerprint": "83a8243a8aff117f69aeecb39d02a483e6cca70439d75f63e433f4e4ac85578f",
|
||||
"scaling_budget": 1.8,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 3.0,
|
||||
"facts_large_max": 16000,
|
||||
"_note": "#2082 M2 / #2083 M3 U1: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically. M3 U1 re-fingerprinted (u{i}(x) call sites); disk_large 996737->1107627 (+11.1%)."
|
||||
},
|
||||
"taint-dense": {
|
||||
"fingerprint": "218a1a0c7e092550c233607c67daa401543a25bf8d3f122899d30cd9c30c3a89",
|
||||
"scaling_budget": 1.5,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 2.0,
|
||||
"disk_bytes_large_max": 3150000,
|
||||
"taint_findings_per_fn_pin": 8,
|
||||
"taint_scaling_budget": 2.0,
|
||||
"taint_reason_bytes_large_max": 198000,
|
||||
"taint_zero_match_budget": 0.5,
|
||||
"_note": "#2083 M3 U7 (R10): N functions, each with 12 req.body sources + a 4-hop chain + 13 eval sinks (13 deduped findings/fn) at 125->500 fns; the zero-match control (inp.payload/evalish) keeps the identical CFG shape with zero model hits. BOUNDEDNESS pin: kept findings/function == 8 (the scenario cap) at BOTH sizes -- above means the cap was lost, below means detection regressed; total findings grow linearly with N by design. disk_bytes_large_max is the LOAD-BEARING site-harvest absolute ceiling (densest sites of the suite; measured 2335772 at N=500, ceiling ~1.35x). taint_reason_bytes_large_max caps the persisted TAINTED reason bytes (measured 146827 = ~37 B/finding, ceiling ~1.35x; blows on hop-encoding bloat or cap loss). taint_zero_match_budget 0.5 vs measured 0.15: the zero-match pass (match gate only, no solver) must stay a small fraction of the match-dense pass. taint scaling measured ~0.93 (per-function work is N-linear); time/disk/heap/rd ratios all ~1.0."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Build-free CFG-construction measurement harness (#2081 M1).
|
||||
*
|
||||
* Times `collectFunctionCfgs` (the per-function CFG builder the parse worker
|
||||
* runs on a `--pdg` run) on synthetic TS sources at two sizes, in three
|
||||
* scenarios that each stress a distinct cost dimension:
|
||||
* - `straight-line`: ONE function with N coalescing statements — stresses the
|
||||
* basic-block text accumulation (the `extendBlock` path);
|
||||
* - `many-functions`: N small branchy functions — stresses the collect walk +
|
||||
* per-function build + the tree-sitter `namedChildren` accesses;
|
||||
* - `branchy`: ONE function with N sequential `if`s — stresses block/edge
|
||||
* growth within a single CFG.
|
||||
*
|
||||
* For each scenario it reports three scaling ratios at small→large
|
||||
* (`(metric_large/metric_small)/(N_large/N_small)`: ~1.0 is linear, ~4.0 is the
|
||||
* O(n²) shape the M1 perf review flagged for `extendBlock`'s concat chain):
|
||||
* - TIME — wall-clock of `collectFunctionCfgs` (median of reps);
|
||||
* - DISK — utf8 byte size of the serialized `cfgSideChannel` (what a `--pdg`
|
||||
* run writes onto every ParsedFile shard);
|
||||
* - MEMORY — retained JS heap of the `cfgSideChannel` payload, by the
|
||||
* release-delta method (heap held minus heap after dropping it). Requires
|
||||
* `node --expose-gc`; without it the heap metric is null and its gate skips.
|
||||
* It also computes an order-independent sha256 fingerprint over the emitted
|
||||
* blocks/edges of a fixed-size source — the correctness gate that a structural
|
||||
* speedup must leave behavior-identical.
|
||||
*
|
||||
* Build-free: imports the `.ts` hotpaths through tsx
|
||||
* (`node --expose-gc --import tsx bench/cfg/measure.mjs`). Parsing happens ONCE
|
||||
* per size and the tree is reused across reps so the time measurement isolates
|
||||
* CFG build cost, not tree-sitter parse time. `maxFunctionLines` is 0 (no cap)
|
||||
* here on purpose — the bench measures the algorithm; the production default cap
|
||||
* is a separate safety net (and would otherwise skip the large straight-line fn).
|
||||
*
|
||||
* Without args: prints one JSON object per scenario.
|
||||
* With `--check`: asserts each scenario's fingerprint == its committed baseline
|
||||
* (baselines.json) AND each of the time / disk / heap ratios is below its
|
||||
* recorded budget; exits non-zero on any drift/regression.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.ts';
|
||||
import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.ts';
|
||||
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../../src/core/ingestion/cfg/emit.ts';
|
||||
import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.ts';
|
||||
import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts';
|
||||
import { buildTaintImportIndex, matchFunctionSites } from '../../src/core/ingestion/taint/match.ts';
|
||||
import { TS_JS_TAINT_MODEL } from '../../src/core/ingestion/taint/typescript-model.ts';
|
||||
import {
|
||||
computeTaintFlows,
|
||||
DEFAULT_PDG_MAX_TAINT_HOPS,
|
||||
} from '../../src/core/ingestion/taint/propagate.ts';
|
||||
import { encodeTaintPath } from '../../src/core/ingestion/taint/path-codec.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
|
||||
const visitor = createTypeScriptCfgVisitor();
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(TypeScript.typescript);
|
||||
// Large synthetic sources exceed tree-sitter's default read buffer; size it
|
||||
// from the content exactly as the parse worker does (getTreeSitterBufferSize).
|
||||
const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
|
||||
|
||||
// ---- synthetic generators (one cost dimension each) ----
|
||||
|
||||
const SCENARIOS = [
|
||||
{
|
||||
name: 'straight-line',
|
||||
// One function, N coalescing simple statements → all fold into one basic
|
||||
// block whose text is accumulated statement-by-statement (extendBlock).
|
||||
// Uses LARGER sizes than the other scenarios: this scenario's only cost
|
||||
// dimension is text accumulation (output size is constant — 4 blocks at any
|
||||
// N — so the disk/heap ratios can't see it), so the TIME ratio is the sole
|
||||
// guard against an extendBlock O(n²)-concat re-regression. At small N a
|
||||
// quadratic is masked by V8 cons-strings + the linear tree-walk and slips
|
||||
// under the budget; these larger sizes make a real quadratic separate
|
||||
// cleanly (verified: a `+=` regression here exceeds the budget, the
|
||||
// array-join impl stays ~1).
|
||||
small: 2000,
|
||||
large: 8000,
|
||||
gen: (n) => {
|
||||
let s = 'function f() {\n';
|
||||
for (let i = 0; i < n; i++) s += ` let v${i} = ${i} + 1;\n`;
|
||||
return s + ' return v0;\n}\n';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'many-functions',
|
||||
// N independent small functions with a branch + return → stresses the
|
||||
// tree walk in collectFunctionCfgs and the per-function build.
|
||||
gen: (n) => {
|
||||
let s = '';
|
||||
for (let i = 0; i < n; i++) {
|
||||
s += `function f${i}(x: number) { if (x > ${i}) { a(); } else { b(); } return x + ${i}; }\n`;
|
||||
}
|
||||
return s;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'branchy',
|
||||
// One function, N sequential `if`s → N condition blocks + 2N+ edges in a
|
||||
// single CFG; stresses block/edge growth and namedChildren on the body.
|
||||
gen: (n) => {
|
||||
let s = 'function f(x: number) {\n';
|
||||
for (let i = 0; i < n; i++) s += ` if (x > ${i}) { s${i}(); }\n`;
|
||||
return s + '}\n';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dense-bindings',
|
||||
// #2082 M2: N bindings live across ~N blocks inside one loop — bindings ×
|
||||
// blocks scale JOINTLY, the discriminator for solver-lattice quadratics.
|
||||
// The overlay design (KTD2: sets shared by reference, OUT spine-copied
|
||||
// only on gen) is expected to scale ~linearly-with-a-spine-copy here
|
||||
// (normalized ratio low single digits); the regression this scenario
|
||||
// exists to catch is the repo's recurring per-item-rescan shape — a
|
||||
// per-use scan over all defs (O(n³) here) blows the ratio past ~16.
|
||||
// rd time is the gated metric (rd_scaling_budget).
|
||||
rdMaxFacts: 0, // measure the algorithm, not the cap
|
||||
gen: (n) => {
|
||||
let s = 'function f(c: number) {\n';
|
||||
for (let i = 0; i < n; i++) s += ` let v${i} = ${i};\n`;
|
||||
s += ' while (c > 0) {\n';
|
||||
for (let i = 0; i < n; i++) s += ` if (c > ${i}) { v${i} = v${(i + 1) % n} + 1; }\n`;
|
||||
return s + ' c = c - 1;\n }\n return v0;\n}\n';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fact-fanout',
|
||||
// #2082 M2: N parallel case-arm defs of one variable + N later uses —
|
||||
// facts are O(defs×uses) BY SPEC, so a linearity ratio gate is the wrong
|
||||
// shape. The gate here is BOUNDEDNESS: with the production fact limit
|
||||
// engaged, the materialized fact count stays FLAT (== limit) as N grows
|
||||
// past it (facts_large_max), and rd time stays bounded. An unbounded
|
||||
// materialization regression (losing the maxFacts early-stop) shows as
|
||||
// facts_large exploding quadratically.
|
||||
rdMaxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
|
||||
gen: (n) => {
|
||||
let s = 'function f(c: number) {\n let x = 0;\n switch (c) {\n';
|
||||
for (let i = 0; i < n; i++) s += ` case ${i}: x = ${i}; break;\n`;
|
||||
s += ' }\n';
|
||||
for (let i = 0; i < n; i++) s += ` u${i}(x);\n`;
|
||||
return s + '}\n';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'taint-dense',
|
||||
// #2083 M3 U7 (R10): N functions, EACH source/sink-dense — 12 matched
|
||||
// `req.body` source statements + a 4-hop chained reassignment + 13 `eval`
|
||||
// sinks per function (13 deduped findings/fn, ABOVE the scenario cap of 8
|
||||
// so the cap binds). Functions scale with N, so total findings grow
|
||||
// linearly BY DESIGN; the boundedness gate is the per-function pin: kept
|
||||
// findings/function stays EXACTLY at the cap as N grows (a cap loss shows
|
||||
// as 13). This scenario's sites are the densest of the suite, so its
|
||||
// ABSOLUTE disk_bytes_large_max is the load-bearing site-harvest ceiling
|
||||
// (the M2 straight-line carrier has no call sites), and the summed
|
||||
// encoded TAINTED reason bytes get their own absolute ceiling
|
||||
// (taint_reason_bytes_large_max). The zero-match control (genZero) keeps
|
||||
// the identical statement/CFG shape with names OUTSIDE the model
|
||||
// (inp.payload / evalish) — the match-gate must make unmatched functions
|
||||
// cost ~nothing (no solver call), gated as zero-time/dense-time ratio.
|
||||
small: 125,
|
||||
large: 500, // 4x, like the global sizes — per-fn bodies are ~30 lines
|
||||
taint: { cap: 8 },
|
||||
gen: (n) => genTaintFunctions(n, false),
|
||||
genZero: (n) => genTaintFunctions(n, true),
|
||||
},
|
||||
];
|
||||
|
||||
// taint-dense generator: `zero` swaps every model-matched name for an
|
||||
// unmatched one without changing statement count, def/use shape, or CFG.
|
||||
const TAINT_SOURCES_PER_FN = 12;
|
||||
const TAINT_CHAIN_HOPS = 4;
|
||||
function genTaintFunctions(n, zero) {
|
||||
const recv = zero ? 'inp' : 'req';
|
||||
const prop = zero ? 'payload' : 'body';
|
||||
const sink = zero ? 'evalish' : 'eval';
|
||||
let s = '';
|
||||
for (let i = 0; i < n; i++) {
|
||||
s += `function f${i}(${recv}) {\n`;
|
||||
for (let j = 0; j < TAINT_SOURCES_PER_FN; j++) s += ` const s${j} = ${recv}.${prop};\n`;
|
||||
s += ` let c0 = s0 + '!';\n`;
|
||||
for (let h = 1; h < TAINT_CHAIN_HOPS; h++) s += ` const c${h} = c${h - 1} + '!';\n`;
|
||||
for (let j = 0; j < TAINT_SOURCES_PER_FN; j++) s += ` ${sink}(s${j});\n`;
|
||||
s += ` ${sink}(c${TAINT_CHAIN_HOPS - 1});\n`;
|
||||
s += '}\n';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const SMALL = 500;
|
||||
const LARGE = 2000; // 4× — O(n) ⇒ ratio ~1, O(n²) ⇒ ratio ~4
|
||||
const REPS = 15; // median over more reps → stabler time signal at small absolute ms
|
||||
const FP_SIZE = 15; // fixed size for the behavior fingerprint
|
||||
const NO_CAP = 0; // measure the algorithm, not the production safety cap
|
||||
|
||||
// ---- timing ----
|
||||
|
||||
function median(xs) {
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
const m = Math.floor(s.length / 2);
|
||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||||
}
|
||||
|
||||
function measureCollect(src, file, reps) {
|
||||
const root = parse(src).rootNode; // parse ONCE; reuse across reps
|
||||
collectFunctionCfgs(root, visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted)
|
||||
const samples = [];
|
||||
let out;
|
||||
for (let i = 0; i < reps; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
out = collectFunctionCfgs(root, visitor, file, NO_CAP);
|
||||
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
|
||||
}
|
||||
return {
|
||||
ms: median(samples),
|
||||
cfgs: out.cfgs,
|
||||
blockCount: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
|
||||
// DISK growth: utf8 byte size of the serialized cfgSideChannel — exactly
|
||||
// what a --pdg run writes onto every ParsedFile shard in the durable store
|
||||
// + parse cache (the field is plain JSON, so this is the on-disk delta).
|
||||
// Should scale linearly with source covered; a super-linear ratio means the
|
||||
// CFG duplicates text and bloats warm-cache shards at scale.
|
||||
diskBytes: Buffer.byteLength(JSON.stringify(out.cfgs), 'utf8'),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- reaching-defs solve cost (#2082 M2) ----
|
||||
|
||||
// Times computeReachingDefs over a scenario's collected CFGs (the exact work
|
||||
// the scope-resolution emit loop adds per file on a --pdg run). `maxFacts`
|
||||
// mirrors the per-scenario production posture: 0 (unlimited) measures the
|
||||
// algorithm; the production default exercises the boundedness contract.
|
||||
function measureReachingDefs(cfgs, reps, maxFacts) {
|
||||
for (const c of cfgs) computeReachingDefs(c, { maxFacts }); // warm JIT
|
||||
const samples = [];
|
||||
let facts = 0;
|
||||
for (let i = 0; i < reps; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
facts = 0;
|
||||
for (const c of cfgs) facts += computeReachingDefs(c, { maxFacts }).facts.length;
|
||||
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
|
||||
}
|
||||
return { ms: median(samples), facts };
|
||||
}
|
||||
|
||||
// ---- taint pass cost (#2083 M3 U7) ----
|
||||
|
||||
// Times the EXACT per-function sequence the in-phase emit driver runs on a
|
||||
// --pdg run for a taint-modeled language: match sites → zero-match fast path
|
||||
// → computeReachingDefs → computeTaintFlows. `cap` is the scenario's
|
||||
// maxFindingsPerFunction (deliberately small so the cap BINDS on the dense
|
||||
// generator). Also sums the encoded TAINTED `reason` bytes for the kept
|
||||
// findings — the persisted-taint disk posture (R10).
|
||||
function measureTaint(cfgs, reps, cap) {
|
||||
const importIndex = buildTaintImportIndex([]); // bench callees are globals
|
||||
const pass = () => {
|
||||
let analyzed = 0;
|
||||
let kept = 0;
|
||||
let dropped = 0;
|
||||
let reasonBytes = 0;
|
||||
for (const c of cfgs) {
|
||||
const matches = matchFunctionSites(c, TS_JS_TAINT_MODEL, importIndex);
|
||||
if (!matches.hasSource || !matches.hasSink) continue;
|
||||
const du = computeReachingDefs(c, {
|
||||
maxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
|
||||
});
|
||||
const flows = computeTaintFlows(c, du, matches, {
|
||||
maxFindingsPerFunction: cap,
|
||||
maxHops: DEFAULT_PDG_MAX_TAINT_HOPS,
|
||||
});
|
||||
if (flows.status !== 'computed') continue;
|
||||
analyzed++;
|
||||
kept += flows.findings.length;
|
||||
dropped += flows.droppedFindings;
|
||||
for (const f of flows.findings) {
|
||||
// All structural chars + identifier names are single-byte ASCII, so
|
||||
// string length IS the byte length (path-codec discipline).
|
||||
reasonBytes += encodeTaintPath(
|
||||
f.hops.map((h) => ({ name: h.name, line: h.point.line, viaCall: h.viaCall })),
|
||||
{ truncated: f.hopsTruncated === true, kind: f.sinkKind },
|
||||
).reason.length;
|
||||
}
|
||||
}
|
||||
return { analyzed, kept, dropped, reasonBytes };
|
||||
};
|
||||
pass(); // warm JIT (uncounted)
|
||||
const samples = [];
|
||||
let out;
|
||||
for (let i = 0; i < reps; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
out = pass();
|
||||
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
|
||||
}
|
||||
return { ms: median(samples), ...out };
|
||||
}
|
||||
|
||||
// ---- memory growth: retained heap of the cfgSideChannel payload ----
|
||||
|
||||
// Needs `node --expose-gc` to force collection for a clean delta; without it the
|
||||
// heap metric is reported as null and its --check gate is skipped (so a local
|
||||
// run without the flag still works).
|
||||
const GC = typeof global.gc === 'function' ? () => (global.gc(), global.gc()) : null;
|
||||
|
||||
function retainedHeapBytes(src, file) {
|
||||
if (!GC) return null;
|
||||
// Retained-size-by-RELEASE: measure the heap with the CFGs held, drop them,
|
||||
// GC, measure again. The drop isolates exactly the JS heap the cfgSideChannel
|
||||
// payload retains (the extra RAM a --pdg run carries per file until the shard
|
||||
// is flushed) — robust to pre-existing garbage, which is constant across both
|
||||
// measurements. The parse tree is a temporary (its native memory isn't on the
|
||||
// JS heap); block text strings are fresh copies, so they count here.
|
||||
let cfgs = collectFunctionCfgs(parse(src).rootNode, visitor, file, NO_CAP).cfgs;
|
||||
GC();
|
||||
const withCfgs = process.memoryUsage().heapUsed;
|
||||
if (cfgs.length < 0) throw new Error('unreachable'); // keep cfgs live past withCfgs
|
||||
cfgs = null;
|
||||
GC();
|
||||
const withoutCfgs = process.memoryUsage().heapUsed;
|
||||
return Math.max(0, withCfgs - withoutCfgs);
|
||||
}
|
||||
|
||||
// ---- correctness fingerprint (order-independent over blocks + edges) ----
|
||||
|
||||
function canonicalizeCfg(cfg) {
|
||||
const blocks = cfg.blocks
|
||||
.map(
|
||||
(b) =>
|
||||
`B|${b.index}|${b.startLine}-${b.endLine}|${b.kind}|${b.text}|` +
|
||||
// #2082 M2: statement facts join the canon so harvest drift (lost
|
||||
// defs/uses, changed binding resolution) trips the fingerprint gate.
|
||||
JSON.stringify(b.statements ?? null),
|
||||
)
|
||||
.sort();
|
||||
const edges = cfg.edges.map((e) => `E|${e.from}->${e.to}|${e.kind}`).sort();
|
||||
const bindings = JSON.stringify(cfg.bindings ?? null);
|
||||
return `${cfg.functionStartLine}:${cfg.functionStartColumn}\n${bindings}\n${blocks.join('\n')}\n${edges.join('\n')}`;
|
||||
}
|
||||
|
||||
function fingerprint(scenario) {
|
||||
const out = collectFunctionCfgs(parse(scenario.gen(FP_SIZE)).rootNode, visitor, 'fp.ts', NO_CAP);
|
||||
const canon = out.cfgs.map(canonicalizeCfg).sort().join('\n====\n');
|
||||
return {
|
||||
fingerprint: crypto.createHash('sha256').update(canon).digest('hex'),
|
||||
fp_cfgs: out.cfgs.length,
|
||||
fp_blocks: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
|
||||
fp_edges: out.cfgs.reduce((a, c) => a + c.edges.length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function measureScenario(scenario) {
|
||||
// Per-scenario sizes (straight-line needs larger N to separate a concat
|
||||
// quadratic from noise — see its comment); the rest default to the globals.
|
||||
const nSmall = scenario.small ?? SMALL;
|
||||
const nLarge = scenario.large ?? LARGE;
|
||||
const small = measureCollect(scenario.gen(nSmall), `${scenario.name}.ts`, REPS);
|
||||
const large = measureCollect(scenario.gen(nLarge), `${scenario.name}.ts`, REPS);
|
||||
const sizeRatio = nLarge / nSmall;
|
||||
const scalingRatio = small.ms > 0 ? large.ms / small.ms / sizeRatio : 0;
|
||||
const diskRatio = small.diskBytes > 0 ? large.diskBytes / small.diskBytes / sizeRatio : 0;
|
||||
|
||||
// Memory growth (only when --expose-gc gave us a forced GC).
|
||||
const heapSmall = retainedHeapBytes(scenario.gen(nSmall), `${scenario.name}.ts`);
|
||||
const heapLarge = retainedHeapBytes(scenario.gen(nLarge), `${scenario.name}.ts`);
|
||||
const heapRatio =
|
||||
heapSmall !== null && heapLarge !== null && heapSmall > 0
|
||||
? heapLarge / heapSmall / sizeRatio
|
||||
: null;
|
||||
|
||||
// #2082 M2: reaching-defs solve cost over the same CFGs.
|
||||
const rdMaxFacts = scenario.rdMaxFacts ?? 0;
|
||||
const rdSmall = measureReachingDefs(small.cfgs, REPS, rdMaxFacts);
|
||||
const rdLarge = measureReachingDefs(large.cfgs, REPS, rdMaxFacts);
|
||||
// Clamp the denominator: a 0.000ms small-N median would otherwise yield
|
||||
// ratio 0 and the gate would self-disable exactly when the solver is fast.
|
||||
const rdRatio = rdLarge.ms / Math.max(rdSmall.ms, 0.001) / sizeRatio;
|
||||
|
||||
// #2083 M3 U7: taint pass cost + boundedness on taint-bearing scenarios.
|
||||
let taintMetrics = {};
|
||||
if (scenario.taint !== undefined) {
|
||||
const cap = scenario.taint.cap;
|
||||
const tSmall = measureTaint(small.cfgs, REPS, cap);
|
||||
const tLarge = measureTaint(large.cfgs, REPS, cap);
|
||||
const tRatio = tLarge.ms / Math.max(tSmall.ms, 0.001) / sizeRatio;
|
||||
// Zero-match control: identical CFG shape, no model hits — measures the
|
||||
// match-gate overhead unmatched functions pay on a real --pdg repo.
|
||||
const zeroCfgs = collectFunctionCfgs(
|
||||
parse(scenario.genZero(nLarge)).rootNode,
|
||||
visitor,
|
||||
`${scenario.name}-zero.ts`,
|
||||
NO_CAP,
|
||||
).cfgs;
|
||||
const tZero = measureTaint(zeroCfgs, REPS, cap);
|
||||
taintMetrics = {
|
||||
taint_ms_small: Number(tSmall.ms.toFixed(3)),
|
||||
taint_ms_large: Number(tLarge.ms.toFixed(3)),
|
||||
taint_scaling_ratio: Number(tRatio.toFixed(3)),
|
||||
// Boundedness: kept findings PER ANALYZED FUNCTION (total findings grow
|
||||
// linearly with N by design — the per-function pin is the cap gate).
|
||||
taint_findings_per_fn_small: tSmall.analyzed > 0 ? tSmall.kept / tSmall.analyzed : 0,
|
||||
taint_findings_per_fn_large: tLarge.analyzed > 0 ? tLarge.kept / tLarge.analyzed : 0,
|
||||
taint_dropped_large: tLarge.dropped,
|
||||
taint_reason_bytes_large: tLarge.reasonBytes,
|
||||
taint_zero_ms_large: Number(tZero.ms.toFixed(3)),
|
||||
taint_zero_findings: tZero.kept + tZero.dropped,
|
||||
taint_zero_match_ratio: Number((tZero.ms / Math.max(tLarge.ms, 0.001)).toFixed(3)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...taintMetrics,
|
||||
scenario: scenario.name,
|
||||
elapsed_ms_small: Number(small.ms.toFixed(3)),
|
||||
elapsed_ms_large: Number(large.ms.toFixed(3)),
|
||||
scaling_ratio: Number(scalingRatio.toFixed(3)),
|
||||
disk_bytes_small: small.diskBytes,
|
||||
disk_bytes_large: large.diskBytes,
|
||||
disk_bytes_ratio: Number(diskRatio.toFixed(3)),
|
||||
heap_bytes_small: heapSmall,
|
||||
heap_bytes_large: heapLarge,
|
||||
heap_ratio: heapRatio === null ? null : Number(heapRatio.toFixed(3)),
|
||||
blocks_small: small.blockCount,
|
||||
blocks_large: large.blockCount,
|
||||
rd_ms_small: Number(rdSmall.ms.toFixed(3)),
|
||||
rd_ms_large: Number(rdLarge.ms.toFixed(3)),
|
||||
rd_scaling_ratio: Number(rdRatio.toFixed(3)),
|
||||
facts_small: rdSmall.facts,
|
||||
facts_large: rdLarge.facts,
|
||||
...fingerprint(scenario),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- run ----
|
||||
|
||||
const CHECK = process.argv.includes('--check');
|
||||
|
||||
// The retained-heap budget is a primary regression detector, but it can only be
|
||||
// measured with a forced GC. Rather than let `--check` silently PASS with the
|
||||
// heap gate skipped (a green no-op if someone drops --expose-gc), fail loudly.
|
||||
if (CHECK && !GC) {
|
||||
process.stderr.write(
|
||||
'[cfg --check] FAIL: retained-heap gate requires --expose-gc. ' +
|
||||
'Run: node --expose-gc --import tsx bench/cfg/measure.mjs --check\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = SCENARIOS.map(measureScenario);
|
||||
|
||||
if (!CHECK) {
|
||||
for (const r of results) process.stdout.write(JSON.stringify(r) + '\n');
|
||||
} else {
|
||||
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
|
||||
const failures = [];
|
||||
for (const r of results) {
|
||||
const base = baselines[r.scenario];
|
||||
if (base === undefined) {
|
||||
failures.push(`${r.scenario}: no baseline recorded`);
|
||||
continue;
|
||||
}
|
||||
if (r.fingerprint !== base.fingerprint) {
|
||||
failures.push(
|
||||
`${r.scenario}: CFG fingerprint drift (got ${r.fingerprint}, expected ${base.fingerprint})`,
|
||||
);
|
||||
}
|
||||
if (r.scaling_ratio >= base.scaling_budget) {
|
||||
failures.push(
|
||||
`${r.scenario}: scaling ratio ${r.scaling_ratio} >= budget ${base.scaling_budget} ` +
|
||||
`(${SMALL}->${LARGE} stmts/fns, ms ${r.elapsed_ms_small}->${r.elapsed_ms_large})`,
|
||||
);
|
||||
}
|
||||
if (base.disk_bytes_budget !== undefined && r.disk_bytes_ratio >= base.disk_bytes_budget) {
|
||||
failures.push(
|
||||
`${r.scenario}: cfgSideChannel disk-bytes ratio ${r.disk_bytes_ratio} >= budget ` +
|
||||
`${base.disk_bytes_budget} (bytes ${r.disk_bytes_small}->${r.disk_bytes_large})`,
|
||||
);
|
||||
}
|
||||
// #2082 M2 gates — rd solve-time scaling, fact-count boundedness, and an
|
||||
// ABSOLUTE side-channel size ceiling (a ratio gate is blind to a
|
||||
// constant-factor encoding bloat like named records vs indexed facts).
|
||||
if (base.rd_scaling_budget !== undefined && r.rd_scaling_ratio >= base.rd_scaling_budget) {
|
||||
failures.push(
|
||||
`${r.scenario}: reaching-defs scaling ratio ${r.rd_scaling_ratio} >= budget ` +
|
||||
`${base.rd_scaling_budget} (ms ${r.rd_ms_small}->${r.rd_ms_large})`,
|
||||
);
|
||||
}
|
||||
if (base.facts_large_max !== undefined && r.facts_large > base.facts_large_max) {
|
||||
failures.push(
|
||||
`${r.scenario}: fact materialization ${r.facts_large} > bound ${base.facts_large_max} ` +
|
||||
`(the maxFacts early-stop is the boundedness contract)`,
|
||||
);
|
||||
}
|
||||
if (base.disk_bytes_large_max !== undefined && r.disk_bytes_large > base.disk_bytes_large_max) {
|
||||
failures.push(
|
||||
`${r.scenario}: cfgSideChannel absolute size ${r.disk_bytes_large} > ceiling ` +
|
||||
`${base.disk_bytes_large_max} bytes (constant-factor encoding bloat)`,
|
||||
);
|
||||
}
|
||||
// #2083 M3 U7 gates — taint boundedness (per-function findings pinned at
|
||||
// the cap as N grows), an ABSOLUTE ceiling on persisted TAINTED reason
|
||||
// bytes, taint solve-time scaling, and the zero-match fast path staying
|
||||
// ~free relative to the match-dense pass.
|
||||
if (base.taint_findings_per_fn_pin !== undefined) {
|
||||
for (const side of ['small', 'large']) {
|
||||
const perFn = r[`taint_findings_per_fn_${side}`];
|
||||
if (perFn !== base.taint_findings_per_fn_pin) {
|
||||
failures.push(
|
||||
`${r.scenario}: taint findings/function (${side}) ${perFn} != pin ` +
|
||||
`${base.taint_findings_per_fn_pin} (cap must BIND exactly: above = cap lost, ` +
|
||||
`below = detection regressed)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (r.taint_zero_findings !== 0) {
|
||||
failures.push(
|
||||
`${r.scenario}: zero-match control produced ${r.taint_zero_findings} findings ` +
|
||||
`(the control must not match the model — generator drift)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
base.taint_reason_bytes_large_max !== undefined &&
|
||||
r.taint_reason_bytes_large > base.taint_reason_bytes_large_max
|
||||
) {
|
||||
failures.push(
|
||||
`${r.scenario}: persisted TAINTED reason bytes ${r.taint_reason_bytes_large} > ceiling ` +
|
||||
`${base.taint_reason_bytes_large_max} (hop-encoding bloat or cap loss)`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
base.taint_scaling_budget !== undefined &&
|
||||
r.taint_scaling_ratio >= base.taint_scaling_budget
|
||||
) {
|
||||
failures.push(
|
||||
`${r.scenario}: taint scaling ratio ${r.taint_scaling_ratio} >= budget ` +
|
||||
`${base.taint_scaling_budget} (ms ${r.taint_ms_small}->${r.taint_ms_large})`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
base.taint_zero_match_budget !== undefined &&
|
||||
r.taint_zero_match_ratio >= base.taint_zero_match_budget
|
||||
) {
|
||||
failures.push(
|
||||
`${r.scenario}: zero-match taint time is ${r.taint_zero_match_ratio} of the match-dense ` +
|
||||
`pass, >= budget ${base.taint_zero_match_budget} (the match gate must keep unmatched ` +
|
||||
`functions ~free — no solver call)`,
|
||||
);
|
||||
}
|
||||
// Heap gate only when measured (--expose-gc present) AND a budget exists.
|
||||
if (
|
||||
base.heap_budget !== undefined &&
|
||||
r.heap_ratio !== null &&
|
||||
r.heap_ratio >= base.heap_budget
|
||||
) {
|
||||
failures.push(
|
||||
`${r.scenario}: retained-heap ratio ${r.heap_ratio} >= budget ${base.heap_budget} ` +
|
||||
`(heap ${r.heap_bytes_small}->${r.heap_bytes_large})`,
|
||||
);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(r) + '\n');
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
for (const f of failures) process.stderr.write(`[cfg --check] FAIL: ${f}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stderr.write(`[cfg --check] PASS (${results.length} scenarios)\n`);
|
||||
}
|
||||
@@ -70,10 +70,17 @@ this doc, run it under instrumentation:
|
||||
```bash
|
||||
# From the gitnexus/ subdir:
|
||||
cd gitnexus
|
||||
# Single-threaded baseline (sequential fallback):
|
||||
npx vitest run test/integration/parse-impl-large-fixture.test.ts --reporter=verbose
|
||||
# The worker pool is the sole parse path, so every run needs the dist worker
|
||||
# (`npm run build`) and a pool size pinned via GITNEXUS_WORKER_POOL_SIZE.
|
||||
|
||||
# Worker-pool path (requires built dist/ — pre-built by `npm run build`):
|
||||
# Single-worker-pool baseline (closest analog to the old single-threaded run —
|
||||
# sequential parsing was removed, so a 1-worker pool is the floor):
|
||||
npm run build && \
|
||||
GITNEXUS_WORKER_POOL_SIZE=1 \
|
||||
GITNEXUS_VERBOSE=1 \
|
||||
npx vitest run test/integration/parse-impl-large-fixture.test.ts --reporter=verbose
|
||||
|
||||
# Multi-worker path:
|
||||
npm run build && \
|
||||
GITNEXUS_WORKER_POOL_SIZE=4 \
|
||||
GITNEXUS_PARSE_CHUNK_CONCURRENCY=2 \
|
||||
@@ -97,22 +104,26 @@ node --inspect=0 \
|
||||
## Latest measurement
|
||||
|
||||
> _No measurement data has been collected yet — this file is the
|
||||
> methodology + harness scaffold. The single recorded data point is the
|
||||
> U6 wall-clock smoke baseline below; the worker-pool rows are
|
||||
> placeholders for future bench-pass output._
|
||||
> methodology + harness scaffold. The U6 smoke test confirms the
|
||||
> worker-pool path stays well within its wall-clock budget, but every
|
||||
> throughput/heap cell below is a `_TBD_` placeholder for a future
|
||||
> bench-pass._
|
||||
|
||||
The U6 integration test (`gitnexus/test/integration/parse-impl-large-fixture.test.ts`)
|
||||
was observed completing the synthetic fixture in **~6 seconds** under
|
||||
the sequential path (`skipWorkers: true`) on the development machine,
|
||||
well under the 30 s `Promise.race` wall-clock budget. That number is a
|
||||
smoke baseline only — recorded here for reference, not as a regression
|
||||
target.
|
||||
runs the worker pool — the sole parse path now that sequential parsing
|
||||
has been removed (disabling the pool on a repo with parseable files
|
||||
raises a hard `WorkerPoolDisabledError`). It completes the synthetic
|
||||
fixture well within the 30 s `Promise.race` wall-clock budget on the
|
||||
development machine, but no worker-pool throughput/heap numbers have been
|
||||
captured yet, so the rows below are all `_TBD_`. (An earlier ~6 s figure
|
||||
recorded here was measured on the now-removed sequential path; it has
|
||||
been dropped rather than relabelled as a worker-pool baseline, since the
|
||||
two paths are not comparable.)
|
||||
|
||||
| Path | files/s | wall-clock | peak heap | chunks | quarantined |
|
||||
| ------------------------------------------ | ------- | -------------------- | --------- | ------ | ----------- |
|
||||
| Sequential (`skipWorkers: true`, U6 smoke) | _TBD_ | ~6 s _(observation)_ | _TBD_ | 17 | 0 |
|
||||
| Worker pool, `--workers 4`, concurrency 2 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
|
||||
| Worker pool, `--workers 1`, concurrency 1 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
|
||||
| Path | files/s | wall-clock | peak heap | chunks | quarantined |
|
||||
| ------------------------------------------------------------------------- | ------- | ---------- | --------- | ------ | ----------- |
|
||||
| Worker pool, `--workers 1` (`GITNEXUS_WORKER_POOL_SIZE=1`), concurrency 1 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
|
||||
| Worker pool, `--workers 4`, concurrency 2 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
|
||||
|
||||
**Hardware:** _TBD — record OS, CPU, RAM, Node version, gitnexus SHA at
|
||||
the time of the bench-pass that populates the table above._
|
||||
|
||||
@@ -1 +1 @@
|
||||
06687dff942d531c4d453b5906a8666c90db4867eb43ed18304aa59a8a93ef9d
|
||||
c03f87cd8cd1ee716dea93cceb27109ee5b4584bc9fe426eea3f18fd6f9854cc
|
||||
|
||||
@@ -11,71 +11,76 @@
|
||||
"_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959."
|
||||
},
|
||||
"c": {
|
||||
"fingerprint": "0de009bdbfe095f530fa87eb32bce6ab83092c904f26b3c8fe8d8ab587cf6dc9",
|
||||
"fingerprint": "12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96."
|
||||
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
|
||||
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
|
||||
},
|
||||
"cpp": {
|
||||
"fingerprint": "538e8beebf0a69f6170dff452da3f98046a08cbe8b098b3c9943c4a8a79d2e22",
|
||||
"fingerprint": "9b5b4393d158d76dcf1ef9807e0326462c45a5266310f0ae7894d017f3858219",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
|
||||
"_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267."
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5)."
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.)",
|
||||
"fingerprint": "68ef32c126d5c6de5d8184c6ad0a6104043036daf9805947db8b21741b883f43",
|
||||
"scaling_budget": 1.5
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
|
||||
"fingerprint": "2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9",
|
||||
"scaling_budget": 1.5,
|
||||
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11)."
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "56ffc1c069af10cac3c82a32f3d148322ea570e116ebaae67315445f05407fef",
|
||||
"fingerprint": "ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126)."
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "f9c8eaf6d1084f9b95a9fb97ccce5e618a24d936c85fb8af4b96c73a560f7a7f",
|
||||
"fingerprint": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)."
|
||||
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04).",
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected."
|
||||
},
|
||||
"ruby": {
|
||||
"fingerprint": "bf6b13a366e4116da3772f9a9fdd50517eb11da73918451392e014a2c905b2dd",
|
||||
"fingerprint": "b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.)",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82."
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
|
||||
},
|
||||
"swift": {
|
||||
"fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0",
|
||||
"fingerprint": "180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956: swift-qualified-base fixture + heritage-bearing scale source (class: Base, Serviceable \u2014 extends + protocol conformance); linear (~1.03)."
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
|
||||
},
|
||||
"dart": {
|
||||
"fingerprint": "a9e882b537765e8fd0ddfcd33b38b253dd86fc5ddffa6e4bf5a85ed8ee615eaa",
|
||||
"fingerprint": "94bf2c26e1ba96f4211634aa572c0a989b503e717e75dfc5df04f66c417de80f",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#939: dart added to the scope-capture bench with the registry-primary migration. Heritage-bearing scale source (Entity extends Base implements Marker) gates the @reference.inherits synth + the postfix-chain reference walk at scale. emitDartScopeCaptures threads tree-sitter captured nodes (no findNodeAtRange root-walk), so it is linear (~1.0).",
|
||||
"_rebaselined": "#1970 review + tri-review follow-ups: constructor-call retag, cascade calls, built-in suppression, enum scope, #1926 F24/F25, named-ctor dedup (crash fix), container-name binding suppression; heritage file-affinity resolution. Fixtures: member-call-contexts, constructor-body, named-constructor-body, heritage-name-collision, construct-cascade."
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "b63f9be458f7ece854e7b007159d7bf65b4b66a86e83a6c0656fc93ebd5d83da",
|
||||
"fingerprint": "9b29cafe32873b4902bda311bd089ffc04efe08f13557b966d29544be514080a",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.)"
|
||||
"_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06)."
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.",
|
||||
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected."
|
||||
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.",
|
||||
"_note": "#1968: F44, F85, F87 — fingerprint drift expected."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "a8ddfb15620ae55e50651fc21ab14c4a1f874d9b19e208cc6cbf0a8daac8ec5b",
|
||||
"fingerprint": "d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (extends Base); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
|
||||
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05)."
|
||||
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "5121a11855cd9cc44a357ae3ff50953de80cdd743f00e8924c31503b132bcd84",
|
||||
"fingerprint": "90aa832978d9744e50058e77a04748390a7e34e36b309f6c1d178eb07280b7ea",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
|
||||
"_rebaselined": "#1956 synth-widening: + kotlin-qualified-base fixture; synthesizeKotlinInheritanceReferences now handles the explicit_delegation form (class F : Iface by d -> Iface), matching the #1940 legacy leg, at parity. Linear (~0.87)."
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
|
||||
const {
|
||||
hasGitNexusDbLockedByGitNexusServer,
|
||||
resolveUnixGuardTimeout,
|
||||
} = require('./hook-db-lock-probe.cjs');
|
||||
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
|
||||
|
||||
function readInput() {
|
||||
@@ -91,10 +94,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
|
||||
* hook runners validate hook output, so normal, non-error skip paths must stay
|
||||
* silent unless the operator explicitly asks for diagnostics via GITNEXUS_DEBUG.
|
||||
* See issue #1913.
|
||||
*/
|
||||
function isDebugEnabled() {
|
||||
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
const debug = isDebugEnabled();
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
|
||||
@@ -188,10 +201,73 @@ function resolveCliPath() {
|
||||
return cliPath;
|
||||
}
|
||||
|
||||
// Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
|
||||
// at most one line per (short-lived) hook process, even if a future change
|
||||
// runs the CLI more than once.
|
||||
let unguardedCliWarned = false;
|
||||
|
||||
/**
|
||||
* Unix orphan containment (#2163 follow-up): the augment CLI is the
|
||||
* longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
|
||||
* npx), so on Unix it gets the same SIGKILL-surviving coreutils `timeout`
|
||||
* wrapper as the probe's lsof/ps. The wrapper budget is ceil(inner/1000)+1
|
||||
* seconds — STRICTLY greater than the inner spawnSync timeout, so on the
|
||||
* supervised path Node's SIGTERM always fires first and the existing
|
||||
* error/status contract is untouched. Once the hook itself has been
|
||||
* SIGKILLed (exactly the orphan case the wrapper exists for), the guard
|
||||
* semantics differ per branch:
|
||||
* - direct exec (the CLI is the guard's CHILD): `-k 1` TERM-first — a
|
||||
* SIGTERM-immune CLI can hold the guard ~1s past the inner timeout
|
||||
* before the `-k` SIGKILL escalation reaps it.
|
||||
* - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
|
||||
* budget expiry SIGKILLs the whole process group outright. TERM-first
|
||||
* would kill only the obedient npx parent, making `timeout` reap it and
|
||||
* return before the `-k` escalation ever fires, stranding a
|
||||
* SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
|
||||
* 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
|
||||
* `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
|
||||
* on this branch, both bounded by "no worse than pre-fix" (where the
|
||||
* grandchild received no signal at all): the group-wide SIGKILL is
|
||||
* coreutils semantics — a busybox `timeout` passes the self-test (it
|
||||
* has `-k` and propagates exit status) but signals only its direct
|
||||
* child, so a busybox guard cannot reach the grandchild; and on the
|
||||
* SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
|
||||
* guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
|
||||
* and the guard exits before any KILL fires — so a SIGTERM-immune CLI
|
||||
* grandchild still escapes in those two cases.
|
||||
* If the sibling probe predates the resolveUnixGuardTimeout export (version
|
||||
* skew), the adapter degrades to the unwrapped invocation instead of
|
||||
* throwing. Windows is deliberately NOT wrapped — there is no coreutils
|
||||
* timeout to resolve there and the resolver's self-test spawns /bin/sh — so
|
||||
* on win32 (the npx.cmd path) and whenever the guard resolves to null (e.g.
|
||||
* macOS without Homebrew coreutils — reported once under GITNEXUS_DEBUG)
|
||||
* the argv stays byte-identical to the pre-wrap invocation.
|
||||
*/
|
||||
function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
const isWin = process.platform === 'win32';
|
||||
// Version-skew guard (#2163 follow-up review): an older sibling probe
|
||||
// without the resolveUnixGuardTimeout export must degrade to the unwrapped
|
||||
// invocation — a TypeError here would be swallowed by the caller's catch
|
||||
// and silently kill the augment.
|
||||
const guard =
|
||||
isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
|
||||
if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
|
||||
// Diagnose the "stays unwrapped" Unix paths once per hook process: no
|
||||
// usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
|
||||
// coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
|
||||
unguardedCliWarned = true;
|
||||
process.stderr.write(
|
||||
'[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
|
||||
);
|
||||
}
|
||||
if (cliPath) {
|
||||
return spawnSync(process.execPath, [cliPath, ...args], {
|
||||
const [cmd, cmdArgs] = guard
|
||||
? [
|
||||
guard,
|
||||
['-k', '1', String(Math.ceil(timeout / 1000) + 1), process.execPath, cliPath, ...args],
|
||||
]
|
||||
: [process.execPath, [cliPath, ...args]];
|
||||
return spawnSync(cmd, cmdArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
@@ -199,7 +275,27 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
|
||||
// A non-null guard implies non-Windows, so the wrapped arm can hardcode
|
||||
// plain `npx`. The wrapped arm leads with `-s KILL` (NOT TERM-first like
|
||||
// the direct branch above): the CLI here is a grandchild behind npx — see
|
||||
// the docblock.
|
||||
const [cmd, cmdArgs] = guard
|
||||
? [
|
||||
guard,
|
||||
[
|
||||
'-s',
|
||||
'KILL',
|
||||
'-k',
|
||||
'1',
|
||||
String(Math.ceil((timeout + 5000) / 1000) + 1),
|
||||
'npx',
|
||||
'-y',
|
||||
'gitnexus',
|
||||
...args,
|
||||
],
|
||||
]
|
||||
: [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
|
||||
return spawnSync(cmd, cmdArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
@@ -258,8 +354,14 @@ function buildAfterToolContext(input) {
|
||||
if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) {
|
||||
const hint = buildStaleIndexHint(gitNexusDir, cwd);
|
||||
if (hint) {
|
||||
process.stderr.write(`${hint}\n`);
|
||||
// The hint always reaches the agent via additionalContext (parts). Mirror
|
||||
// it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict
|
||||
// hook runners see no unexpected output on this normal path (#1913). The
|
||||
// claude hook never mirrored this to stderr — this aligns the two adapters.
|
||||
parts.push(hint);
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write(`${hint}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,14 +370,32 @@ function buildAfterToolContext(input) {
|
||||
}
|
||||
|
||||
function runAugment(gitNexusDir, cwd, pattern) {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
||||
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
||||
// as the augment, or concurrent sessions fan out unbounded probe
|
||||
// subprocesses. The cheap guards (extractPattern, gitNexusDir lookup) run in
|
||||
// buildAfterToolContext before this — moving the acquire any earlier would
|
||||
// churn slot files on tool calls that never probe.
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
||||
// the reason only under GITNEXUS_DEBUG.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return '';
|
||||
const cliPath = resolveCliPath();
|
||||
try {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
// Normal skip path: the MCP server owns the DB. Stay silent for strict
|
||||
// hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const cliPath = resolveCliPath();
|
||||
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
return extractAugmentContext(child.stderr || '');
|
||||
@@ -338,7 +458,7 @@ function main() {
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
if (isDebugEnabled()) {
|
||||
console.error('GitNexus antigravity hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
|
||||
const {
|
||||
hasGitNexusDbLockedByGitNexusServer,
|
||||
resolveUnixGuardTimeout,
|
||||
} = require('./hook-db-lock-probe.cjs');
|
||||
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
|
||||
|
||||
/**
|
||||
@@ -110,10 +113,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
|
||||
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
|
||||
* non-error skip paths must stay silent unless the operator explicitly asks
|
||||
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
|
||||
*/
|
||||
function isDebugEnabled() {
|
||||
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
const debug = isDebugEnabled();
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — KuzuDB lock
|
||||
@@ -208,14 +221,76 @@ function resolveCliPath() {
|
||||
return cliPath;
|
||||
}
|
||||
|
||||
// Debounce for the unguarded-CLI diagnostic below (#2163 follow-up review):
|
||||
// at most one line per (short-lived) hook process, even if a future change
|
||||
// runs the CLI more than once.
|
||||
let unguardedCliWarned = false;
|
||||
|
||||
/**
|
||||
* Spawn a gitnexus CLI command synchronously.
|
||||
* Returns the stderr output (KuzuDB captures stdout at OS level).
|
||||
*
|
||||
* Unix orphan containment (#2163 follow-up): the augment CLI is the
|
||||
* longest-lived hook child (inner spawnSync timeout 7s locally, 12s via
|
||||
* npx), so on Unix it gets the same SIGKILL-surviving coreutils `timeout`
|
||||
* wrapper as the probe's lsof/ps. The wrapper budget is ceil(inner/1000)+1
|
||||
* seconds — STRICTLY greater than the inner spawnSync timeout, so on the
|
||||
* supervised path Node's SIGTERM always fires first and the existing
|
||||
* error/status contract is untouched. Once the hook itself has been
|
||||
* SIGKILLed (exactly the orphan case the wrapper exists for), the guard
|
||||
* semantics differ per branch:
|
||||
* - direct exec (the CLI is the guard's CHILD): `-k 1` TERM-first — a
|
||||
* SIGTERM-immune CLI can hold the guard ~1s past the inner timeout
|
||||
* before the `-k` SIGKILL escalation reaps it.
|
||||
* - npx (the CLI is a GRANDCHILD: guard → npx → CLI): `-s KILL` — the
|
||||
* budget expiry SIGKILLs the whole process group outright. TERM-first
|
||||
* would kill only the obedient npx parent, making `timeout` reap it and
|
||||
* return before the `-k` escalation ever fires, stranding a
|
||||
* SIGTERM-immune CLI grandchild unbounded (reproduced on coreutils
|
||||
* 9.x). `-k 1` is retained alongside `-s KILL` as a harmless belt: with
|
||||
* `-s KILL` the `-k` escalation signal is also KILL. Two residual gaps
|
||||
* on this branch, both bounded by "no worse than pre-fix" (where the
|
||||
* grandchild received no signal at all): the group-wide SIGKILL is
|
||||
* coreutils semantics — a busybox `timeout` passes the self-test (it
|
||||
* has `-k` and propagates exit status) but signals only its direct
|
||||
* child, so a busybox guard cannot reach the grandchild; and on the
|
||||
* SUPERVISED path (hook alive, inner spawnSync timeout SIGTERMs the
|
||||
* guard) coreutils forwards TERM rather than the `-s` signal, npx dies,
|
||||
* and the guard exits before any KILL fires — so a SIGTERM-immune CLI
|
||||
* grandchild still escapes in those two cases.
|
||||
* If the sibling probe predates the resolveUnixGuardTimeout export (version
|
||||
* skew), the adapter degrades to the unwrapped invocation instead of
|
||||
* throwing. Windows is deliberately NOT wrapped — there is no coreutils
|
||||
* timeout to resolve there and the resolver's self-test spawns /bin/sh — so
|
||||
* on win32 (the npx.cmd path) and whenever the guard resolves to null (e.g.
|
||||
* macOS without Homebrew coreutils — reported once under GITNEXUS_DEBUG)
|
||||
* the argv stays byte-identical to the pre-wrap invocation.
|
||||
*/
|
||||
function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
const isWin = process.platform === 'win32';
|
||||
// Version-skew guard (#2163 follow-up review): an older sibling probe
|
||||
// without the resolveUnixGuardTimeout export must degrade to the unwrapped
|
||||
// invocation — a TypeError here would be swallowed by the caller's catch
|
||||
// and silently kill the augment.
|
||||
const guard =
|
||||
isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
|
||||
if (!isWin && !guard && !unguardedCliWarned && isDebugEnabled()) {
|
||||
// Diagnose the "stays unwrapped" Unix paths once per hook process: no
|
||||
// usable coreutils timeout/gtimeout (e.g. macOS without Homebrew
|
||||
// coreutils), GITNEXUS_HOOK_TIMEOUT_PATH=disabled, or probe skew above.
|
||||
unguardedCliWarned = true;
|
||||
process.stderr.write(
|
||||
'[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
|
||||
);
|
||||
}
|
||||
if (cliPath) {
|
||||
return spawnSync(process.execPath, [cliPath, ...args], {
|
||||
const [cmd, cmdArgs] = guard
|
||||
? [
|
||||
guard,
|
||||
['-k', '1', String(Math.ceil(timeout / 1000) + 1), process.execPath, cliPath, ...args],
|
||||
]
|
||||
: [process.execPath, [cliPath, ...args]];
|
||||
return spawnSync(cmd, cmdArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
@@ -223,8 +298,27 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
// On Windows, invoke npx.cmd directly (no shell needed)
|
||||
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
|
||||
// On Windows, invoke npx.cmd directly (no shell needed). A non-null guard
|
||||
// implies non-Windows, so the wrapped arm can hardcode plain `npx`. The
|
||||
// wrapped arm leads with `-s KILL` (NOT TERM-first like the direct branch
|
||||
// above): the CLI here is a grandchild behind npx — see the docblock.
|
||||
const [cmd, cmdArgs] = guard
|
||||
? [
|
||||
guard,
|
||||
[
|
||||
'-s',
|
||||
'KILL',
|
||||
'-k',
|
||||
'1',
|
||||
String(Math.ceil((timeout + 5000) / 1000) + 1),
|
||||
'npx',
|
||||
'-y',
|
||||
'gitnexus',
|
||||
...args,
|
||||
],
|
||||
]
|
||||
: [isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args]];
|
||||
return spawnSync(cmd, cmdArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
@@ -249,17 +343,35 @@ function handlePreToolUse(input) {
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
|
||||
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
||||
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
||||
// as the augment, or concurrent sessions fan out unbounded probe
|
||||
// subprocesses. Keep the acquire right after the cheap guards above —
|
||||
// moving it earlier would churn slot files on tool calls that never probe.
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
||||
// the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
try {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
// Normal skip path: the MCP server owns the DB, so the CLI augment would
|
||||
// contend on the lock. Stay silent for strict hook runners (issue #1913);
|
||||
// surface the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cliPath = resolveCliPath();
|
||||
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = extractAugmentContext(child.stderr || '');
|
||||
@@ -361,7 +473,7 @@ function main() {
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
if (isDebugEnabled()) {
|
||||
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,27 @@
|
||||
*
|
||||
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
|
||||
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
|
||||
*
|
||||
* Unix subprocess containment contract (#2163):
|
||||
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
|
||||
* wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
|
||||
* is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
|
||||
* survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
|
||||
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
|
||||
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
|
||||
* wrapper off deterministically; any other value is adopted only when it
|
||||
* exists AND passes a one-shot `-k` exit-propagation self-test — otherwise
|
||||
* resolution FALLS THROUGH to the built-in candidate list (first self-test
|
||||
* pass wins), so no malformed value of any shape can silently disable
|
||||
* orphan containment.
|
||||
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
|
||||
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
|
||||
* A probe before that first query is therefore always false — a known,
|
||||
* pre-existing race, not a bug in this probe.
|
||||
* - resolveUnixGuardTimeout is exported so the hook adapters can wrap the
|
||||
* `gitnexus augment` CLI child — the longest-lived hook subprocess (7s
|
||||
* local / 12s npx inner budgets) — in the same guard; see runGitNexusCli
|
||||
* in the adapters (#2163 follow-up).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -46,6 +67,95 @@ function resolveHookBinary(tool) {
|
||||
return tool;
|
||||
}
|
||||
|
||||
// Sentinel:
|
||||
// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
|
||||
// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
|
||||
// null = no usable wrapper (disabled, none found, or self-test failed)
|
||||
let unixGuardTimeoutCache;
|
||||
|
||||
/**
|
||||
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
|
||||
* (#2163). Unix-only by contract: the probe's win32 dispatch returns before
|
||||
* reaching it, and the exported callers (the adapters' runGitNexusCli,
|
||||
* #2163 follow-up) must check the platform first — the self-test below
|
||||
* spawns /bin/sh. The memoized result is module-wide, so probe and adapter
|
||||
* share one lazy self-test per hook process.
|
||||
*
|
||||
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
|
||||
* wrapper off; any other value is only a CANDIDATE — an existing file path
|
||||
* is tried first, but it must pass the `-k` exit-propagation self-test to
|
||||
* be adopted. On any failure (non-existent path, directory, non-executable
|
||||
* file, wrapper without `-k` support, always-exit-0 stub, …) resolution
|
||||
* falls through to the built-in candidates below, tried in order, first
|
||||
* self-test pass wins. This is strictly stronger than the sibling
|
||||
* GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only
|
||||
* check existence): no bad env value of ANY shape can silently disable
|
||||
* orphan containment.
|
||||
*
|
||||
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
|
||||
* first reached, and the result is memoized. A candidate is adopted only
|
||||
* when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 — i.e. it must RUN
|
||||
* the wrapped command AND PROPAGATE its exit status. This rejects two
|
||||
* failure shapes: wrappers without the coreutils `-k` flag — busybox <1.34,
|
||||
* toybox, broken symlinks — which would exit with a usage error without
|
||||
* ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed
|
||||
* contract into fail-open (#1492 regression); and always-exit-0 stubs
|
||||
* (/bin/true shapes), which would otherwise be adopted and "succeed" every
|
||||
* wrapped spawn instantly without running it — a constant no-owner probe
|
||||
* answer and, worse, a silently dead augment (status 0, empty stderr passes
|
||||
* the adapters' success check with no context; #2163 follow-up review).
|
||||
* Only when EVERY candidate fails does the probe fall back to the unwrapped
|
||||
* status quo (memoized null). busybox ≥1.34 passes the test and is fully
|
||||
* usable for everything THIS file spawns (lsof/ps are the guard's direct
|
||||
* children) and for the adapters' direct-exec arm. The adapters' npx arm
|
||||
* additionally relies on coreutils' process-GROUP signalling for its
|
||||
* `-s KILL` grandchild reaping; busybox signals only its direct child, and
|
||||
* this self-test deliberately does not probe that capability — see the
|
||||
* adapter docblocks for the residual-gap statement.
|
||||
*/
|
||||
function passesGuardSelfTest(guard) {
|
||||
try {
|
||||
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return !selfTest.error && selfTest.status === 42;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUnixGuardTimeout() {
|
||||
if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
|
||||
unixGuardTimeoutCache = null;
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
|
||||
const trimmed = fromEnv ? String(fromEnv).trim() : '';
|
||||
if (trimmed === 'disabled') return unixGuardTimeoutCache;
|
||||
const candidates = [];
|
||||
if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
|
||||
for (const builtin of [
|
||||
'/usr/bin/timeout',
|
||||
'/bin/timeout',
|
||||
'/opt/homebrew/bin/gtimeout',
|
||||
'/usr/local/bin/gtimeout',
|
||||
]) {
|
||||
try {
|
||||
if (fs.existsSync(builtin)) candidates.push(builtin);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (passesGuardSelfTest(candidate)) {
|
||||
unixGuardTimeoutCache = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return unixGuardTimeoutCache;
|
||||
}
|
||||
|
||||
function resolveWindowsPowerShellPath() {
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
|
||||
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
|
||||
@@ -188,20 +298,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
|
||||
}
|
||||
|
||||
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
const guard = resolveUnixGuardTimeout();
|
||||
const lsofPath = resolveHookBinary('lsof');
|
||||
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
|
||||
// The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
|
||||
// SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
|
||||
// SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
|
||||
// fail-closed contract are untouched. The wrapper only matters once this
|
||||
// hook process has been SIGKILLed and can no longer deliver that SIGTERM.
|
||||
const [lsofCmd, lsofArgs] = guard
|
||||
? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
|
||||
: [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
|
||||
const lsof = spawnSync(lsofCmd, lsofArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
|
||||
// Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
|
||||
// result shapes, verified against coreutils 9.1:
|
||||
// - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
|
||||
// SELF-RAISES the signal, so spawnSync reports {status: null, signal}
|
||||
// with no .error (spawnSync's own ETIMEDOUT was handled above). The
|
||||
// same shape appears when this hook is frozen >2s (SIGSTOP, laptop
|
||||
// suspend) and the guard expires while it sleeps. By construction, a
|
||||
// guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
|
||||
// is a budget/kill outcome.
|
||||
// - 124: budget expired and the child exited after the plain SIGTERM.
|
||||
// - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
|
||||
// or a child SIGKILLed externally (e.g. the OOM killer).
|
||||
if (guard && lsof.status === null && lsof.signal) return true;
|
||||
if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
|
||||
|
||||
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
|
||||
const psPath = resolveHookBinary('ps');
|
||||
for (const pid of pids) {
|
||||
if (Number(pid) === myPid) continue;
|
||||
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
|
||||
const [psCmd, psArgs] = guard
|
||||
? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
|
||||
: [psPath, ['-p', pid, '-o', 'command=']];
|
||||
const ps = spawnSync(psCmd, psArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 500,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
@@ -211,6 +347,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
if (ps.error.code === 'ETIMEDOUT') return true;
|
||||
continue;
|
||||
}
|
||||
// Same guard-mediated-death mapping as the lsof call above (signal-death
|
||||
// from the -k escalation or a frozen hook; 124 budget expiry; 137 only
|
||||
// for exit-code-propagating wrappers / external SIGKILL).
|
||||
if (guard && ps.status === null && ps.signal) return true;
|
||||
if (guard && (ps.status === 124 || ps.status === 137)) return true;
|
||||
if (isGitNexusServerCommand(ps.stdout || '')) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -238,4 +379,16 @@ function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
|
||||
|
||||
module.exports = {
|
||||
hasGitNexusDbLockedByGitNexusServer,
|
||||
// #2163 follow-up: the hook adapters wrap the augment CLI in the same
|
||||
// guard. Returns a self-tested wrapper path — the built-in candidates are
|
||||
// always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
|
||||
// exact string that passed the self-test. Same string is also the same
|
||||
// RESOLUTION for absolute paths and for slashless names (PATH lookup is
|
||||
// cwd-independent); a slash-containing RELATIVE override, however, is
|
||||
// existsSync-checked and self-tested against this process's cwd while the
|
||||
// adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
|
||||
// a value can pass here yet ENOENT at the augment call site — set the
|
||||
// override to an absolute path. Returns null when the wrapper is
|
||||
// disabled/unavailable. Never call on win32 (see its JSDoc).
|
||||
resolveUnixGuardTimeout,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user