mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-15 09:11:33 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
722fd8c700
2926 changed files with 1681376 additions and 1374692 deletions
|
|
@ -87,6 +87,7 @@ Generates repository documentation from the knowledge graph using an LLM. HTTP p
|
|||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
|
||||
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
|
||||
| `--max-completion-tokens <n>` | Max completion tokens per LLM request (default: 16384). `GITNEXUS_MAX_COMPLETION_TOKENS` overrides `maxCompletionTokens` in `~/.gitnexus/config.json`; the flag overrides both. |
|
||||
| `--lang <lang>` | Output language for generated documentation (e.g. english, chinese, spanish, japanese) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
|
||||
A root cause traced in the wrong repository is a wrong root cause.
|
||||
|
||||
Call `list_repos {}` before the first tool call. With one indexed repository,
|
||||
Bind once using a confirmed registry name/path or configured MCP default. Call
|
||||
`list_repos {}` if the target is not yet known; reuse that binding across calls. With one indexed repository,
|
||||
use the examples below as written. With more than one, pass `repo` on every
|
||||
call: an omitted `repo` normally errors, but under an MCP policy with a
|
||||
configured default it resolves to that default silently. If you cannot tell
|
||||
|
|
@ -34,10 +35,10 @@ diagnosis.
|
|||
## Workflow
|
||||
|
||||
```
|
||||
0. list_repos {} → Bind repo
|
||||
1. query({search_query: "<error or symptom>"}) → Find related execution flows
|
||||
0. Bind the repo once (list_repos only if not already known)
|
||||
1. If the suspect is unknown, query for the error/symptom with small result limits
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
3. Read a process resource only if its trace resolves an open question
|
||||
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
|
|
@ -47,7 +48,7 @@ diagnosis.
|
|||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] Once per session: bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
|
|
@ -58,6 +59,28 @@ diagnosis.
|
|||
- [ ] State the repository and index freshness with the diagnosis
|
||||
```
|
||||
|
||||
## Keep investigations focused
|
||||
|
||||
Start with a known symbol's `context`, or a small `query` (`limit: 3`,
|
||||
`max_symbols: 3`, `include_content: false`, `chain_depth: 0`) to discover one.
|
||||
When the server advertises these single-repository filters, request only the
|
||||
needed evidence:
|
||||
|
||||
```text
|
||||
context({uid: "<suspect id>", repo: "my-app", include_content: true,
|
||||
response_sections: ["outgoing"], relationTypes: ["CALLS"]})
|
||||
trace({from_uid: "<suspect id>", to_uid: "<failure id>", repo: "my-app"})
|
||||
```
|
||||
|
||||
`symbol` and completeness/staleness diagnostics remain in a filtered context.
|
||||
`relationTypes` filters the returned incoming/outgoing categories only; it does
|
||||
not expand their existing bounded window or filter an explicitly requested chain.
|
||||
For callback registration, include `USES`; for imports or inheritance, request
|
||||
those relation types or omit the filter. Omitted references are not absent
|
||||
references. Broaden when the hypothesis needs it, and preserve UNKNOWN/partial
|
||||
warnings. Confirm source and tests before claiming a root cause. A useful 2–3
|
||||
call investigation is a goal, not a reason to skip verification or pre-edit impact.
|
||||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ description: "Use when the user asks how code works, wants to understand archite
|
|||
|
||||
## Bind the repository first
|
||||
|
||||
Step 1 discovers what is indexed; every call after it must say which of those
|
||||
it means. With one indexed repository, use the examples below as written. With
|
||||
more than one, pass `repo` on every call: an omitted `repo` normally errors,
|
||||
Bind once per session using a confirmed registry name/path or configured MCP
|
||||
default; reuse it. Call `list_repos {}` when the target is not yet known, not before
|
||||
every question. Never guess the registry name from the checkout directory. With
|
||||
one indexed repository, use the examples below as written. With more than one, pass `repo` on every call: an omitted `repo` normally errors,
|
||||
but under an MCP policy with a configured default it resolves to that default
|
||||
silently. If you cannot tell which repository is meant, stop and ask. Report
|
||||
the bound repository and index freshness alongside your explanation.
|
||||
|
|
@ -28,26 +29,64 @@ the bound repository and index freshness alongside your explanation.
|
|||
## Workflow
|
||||
|
||||
```
|
||||
1. list_repos {} or READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
1. Bind the repository once; use hot-tool staleness on subsequent calls
|
||||
2. If no symbol is known: query({search_query: "<concept>", limit: 3, max_symbols: 3})
|
||||
3. context({uid: "<returned id>"}) → Inspect the selected symbol
|
||||
4. Read a process resource only when its full trace answers an unresolved question
|
||||
```
|
||||
|
||||
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
> If the index is stale → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
|
||||
|
||||
## Ask for only the evidence needed
|
||||
|
||||
Use options advertised by the connected server. `response_sections` and context
|
||||
`relationTypes` are opt-in views for a single repository, not `@group` routing.
|
||||
On an older server, use the existing limits and content/chain options instead.
|
||||
Use filters when unwanted sections are bulky; their omission metadata can make
|
||||
an already-small response slightly larger.
|
||||
Aim for 2–3 focused calls after repository binding; stop when the question is
|
||||
answered, but expand if evidence or a required safety check is still missing.
|
||||
|
||||
```text
|
||||
query({search_query: "payment retry", repo: "my-app", limit: 3, max_symbols: 3,
|
||||
include_content: false, chain_depth: 0,
|
||||
response_sections: ["process_symbols", "definitions"]})
|
||||
context({uid: "<id from query>", repo: "my-app", include_content: true,
|
||||
response_sections: ["incoming", "outgoing"], relationTypes: ["CALLS"]})
|
||||
// Before editing, assess impact using the normal traversal and risk calculation:
|
||||
impact({target: "<same id>", repo: "my-app", direction: "upstream", summaryOnly: true})
|
||||
```
|
||||
|
||||
If the symbol is already known, start with `context`; use `uid`, or `name` plus
|
||||
`file_path`/`kind`, to avoid ambiguous results. To answer only “who calls it?”,
|
||||
request `response_sections: ["incoming"]`, `relationTypes: ["CALLS"]` and leave
|
||||
`include_content` false. Keep `processes` in a query when the question is about
|
||||
flows, rather than requesting a symbols-only view.
|
||||
|
||||
Do not request source and multi-hop chains for every search hit. Fetch source
|
||||
for the selected symbol only; for a large class/file, prefer `read_file` with
|
||||
a small 0-based `startLine`/`endLine` window (subtract 1 from graph line numbers),
|
||||
or `maxLines` for a whole-file read, if that tool is available. Use
|
||||
`trace` for a known A→B path instead of repeatedly expanding `context`.
|
||||
|
||||
Filters retain requested sections/categories and hide the others; they do not prove other references
|
||||
are absent or make the existing bounded search exhaustive. Read `response_filter`,
|
||||
`warning`, `partial`, `truncated`, `epistemic` and `staleness`. Remove filters when
|
||||
broader evidence is needed. `maxTokens` is a last-resort byte cutoff that can cut
|
||||
JSON: narrow the request rather than treating a cut response as complete. Never
|
||||
use a filtered caller view to waive the required impact check or an UNKNOWN risk.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
- [ ] Once per session: bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] Check hot-tool staleness; read the overview only if needed
|
||||
- [ ] Query for the concept only if the relevant symbol is not already known
|
||||
- [ ] Review returned symbols; include processes when execution flow matters
|
||||
- [ ] context on selected symbols; request only relevant sections/relations
|
||||
- [ ] Read a process resource only if its full trace is needed
|
||||
- [ ] Read targeted source only when implementation details are needed
|
||||
- [ ] State the repository and index freshness with the explanation
|
||||
```
|
||||
|
||||
|
|
@ -84,18 +123,16 @@ with a single one.
|
|||
|
||||
## Example: "How does payment processing work?"
|
||||
|
||||
```
|
||||
1. list_repos {} → total: 1 (my-app) — bind it
|
||||
READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. query({search_query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
5. Answer, noting: Repository my-app, index current
|
||||
With `my-app` already bound and the index current:
|
||||
|
||||
```text
|
||||
1. query({search_query: "payment processing", repo: "my-app", limit: 3, max_symbols: 3})
|
||||
→ CheckoutFlow and RefundFlow, with symbol IDs and locations
|
||||
2. context({uid: "<processPayment id>", repo: "my-app", include_content: true,
|
||||
response_sections: ["outgoing", "processes"]})
|
||||
→ Selected source, callees, process participation and completeness diagnostics
|
||||
3. Answer from that evidence; read one process resource only if its trace is still needed
|
||||
```
|
||||
|
||||
Had step 1 returned two repositories, every call above would carry
|
||||
`repo: "my-app"`.
|
||||
Report the repository and index freshness with the explanation. If `my-app`
|
||||
had not yet been bound, discover it once with `list_repos` first.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Quick reference for all GitNexus MCP tools, resources, and the knowledge graph s
|
|||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
||||
1. **Bind the repository once** — use a confirmed registry name/path or configured default; call `list_repos` if unknown. Read hot-tool `staleness`; use `gitnexus://repo/{name}/context` when an overview is needed.
|
||||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
|||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `trace` | Shortest path between two symbols — "how does A reach B?" in one call |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `rename` | Semantic rename: previews by default (`dry_run` omitted/true), applies with `dry_run: false` and the preview's `plan_token`; inspect application status and failures |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
|
||||
| `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` |
|
||||
|
|
@ -51,6 +51,39 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
|||
| `group_sync` | Rebuild a group's Contract Registry (cross-repo HTTP contract links); run after `group.yaml` changes or member re-index |
|
||||
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
|
||||
|
||||
### Choose a focused response
|
||||
|
||||
Use the connected server's schema; older servers may not advertise the new
|
||||
filters. All options below are explicit: omitting them preserves existing output.
|
||||
Use filters for bulky unwanted sections; on an already-small response, omission
|
||||
metadata can outweigh the bytes saved.
|
||||
|
||||
| Question | Options |
|
||||
| --- | --- |
|
||||
| Which symbols implement this? | `query`: `limit: 3`, `max_symbols: 3`, `include_content: false`, `chain_depth: 0`, `response_sections: ["process_symbols", "definitions"]` |
|
||||
| Which execution flows match? | `query`: keep `processes` in `response_sections`; join its `id` to `process_symbols[].process_id` |
|
||||
| Who calls this exact symbol? | `context`: `uid`, `response_sections: ["incoming"]`, `relationTypes: ["CALLS"]` |
|
||||
| What does it call, with source? | `context`: `uid`, `include_content: true`, `response_sections: ["outgoing"]`, `relationTypes: ["CALLS"]` |
|
||||
| What is the impact summary? | `impact`: `direction: "upstream"`, `summaryOnly: true`; risk calculation and totals are retained |
|
||||
| Which affected symbols need inspection? | `impact`: `limit: 10`, `offset: 0`; page as indicated by the response |
|
||||
| How does A reach B? | `trace`: `from_uid`, `to_uid` |
|
||||
|
||||
`response_sections` selects data sections for single-repository MCP `query` or
|
||||
`context` (not `@group` routes). Query choices: `processes`, `process_symbols`,
|
||||
`definitions`. Context choices: `incoming`, `outgoing`, `processes`, `chain`,
|
||||
`routes`, `typed_properties`; the selected symbol always remains. Context
|
||||
`relationTypes` accepts uppercase relationship names advertised by its schema
|
||||
and filters only the existing incoming/outgoing categories. It does not change
|
||||
ranking, traversal, source retention or the bounded candidate/reference windows.
|
||||
|
||||
Filters add `response_filter` with the selection and omitted fields/categories.
|
||||
Warnings, ambiguity, staleness and completeness metadata always remain. An empty
|
||||
filtered view is not proof of absence. Omit filters to expand, and never waive
|
||||
impact checks because a selected category is empty. `maxTokens` is a byte cutoff,
|
||||
not pagination: it can cut JSON. Prefer these filters, small result counts, and
|
||||
source/chain expansion for a selected symbol rather than a large search result.
|
||||
Stop once enough evidence answers the question; do not run every tool by default.
|
||||
|
||||
### 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:
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ checkout and reports nothing changed, which reads as a verified refactor.
|
|||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview semantic edits (omitting dry_run also previews)
|
||||
- [ ] Confirm the previewed file paths are in the bound repository/worktree
|
||||
- [ ] Review graph edits (high confidence) and text_search edits (review carefully)
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] Review exact graph-backed semantic spans, coverage, and limitations
|
||||
- [ ] If satisfied: rename({..., dry_run: false, plan_token: "<token from preview>"}) — apply exactly the reviewed edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
|
@ -89,15 +89,74 @@ checkout and reports nothing changed, which reads as a verified refactor.
|
|||
|
||||
## Tools
|
||||
|
||||
**rename** — automated multi-file rename:
|
||||
**rename** — semantic rename; preview first, then apply:
|
||||
|
||||
```
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 text_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
→ semantic_edits: 12, coverage: {name: "gitnexus-semantic", scope: "indexed-repository", limitations: [...]}
|
||||
→ changes: [{file_path, edits: [{start, length, line, old_text, new_text, confidence: "semantic"}]}]
|
||||
```
|
||||
|
||||
`dry_run` omitted or `true` previews and never writes; `dry_run: false` requires
|
||||
the successful preview's `plan_token`. Keep the same repository, selected symbol,
|
||||
and destination name. Tokens are single-use, expire after 15 minutes or a server
|
||||
restart, and may be evicted when retained previews reach 16 plans or 64 MiB.
|
||||
Any apply attempt consumes the token; request and review a fresh preview to retry.
|
||||
An MCP server started with `GITNEXUS_MCP_READ_ONLY=1` still lists `rename`, but
|
||||
as preview-only: it rejects any `dry_run` other than omitted or `true`, so apply
|
||||
through a server that permits writes. Request and review a fresh preview there;
|
||||
tokens belong to the process that issued them.
|
||||
|
||||
Rename uses the indexed graph and GitNexus's own semantic layer. Language
|
||||
providers resolve current source through scopes and bindings; no compiler or
|
||||
language service is loaded. Unsupported syntax, ambiguous bindings, stale
|
||||
declarations, and incomplete occurrence coverage fail closed. Coverage describes
|
||||
indexed files accepted by the selected provider, including supported embedded sources,
|
||||
and excludes unindexed files and external consumers. All registered source-language
|
||||
providers supply rename rules. Declaration and override families require complete
|
||||
semantic evidence. Dynamic or computed references block a rename when the provider
|
||||
cannot resolve them or exclude them from the selected family. Provider-specific
|
||||
limits, including class renames requiring a file move, remain explicit in results.
|
||||
There is no regex fallback: legacy `graph_edits` and `text_search_edits` are zero.
|
||||
Comments, strings, and unrelated same-spelled symbols are not replacements.
|
||||
|
||||
Preview captures the exact semantic plan, source hashes and physical file targets.
|
||||
Apply validates that plan and executes its edits without replanning. Changes to
|
||||
indexed targets, source bytes or paths (including newly added untracked source
|
||||
files) reject the plan before writing. Unindexed files remain outside semantic
|
||||
coverage; analyze first when they need refactoring. Results use `result_version: 2`;
|
||||
`total_edits` counts occurrences, not matching lines. Exact replacements are shown in `new_text`.
|
||||
Check `planning_status`, `application_status`, and `applied`. A completed apply
|
||||
has `status: "success"`, `application_status: "applied"`, and `applied: true`.
|
||||
A successful preview has not written files. `rename` does not accept `branch`:
|
||||
it inspects the current checkout.
|
||||
|
||||
Recover from a blocked or incomplete rename before applying again. A failed
|
||||
result carries `code` and `error`; nothing was written unless the last row applies.
|
||||
|
||||
| Result | Next step |
|
||||
| --- | --- |
|
||||
| `stale_graph` | Re-analyze the bound repository, then preview again with `rename` (`dry_run` omitted or `true`). |
|
||||
| `source_changed` | Preview again against the current source and review it. If it appears during an apply, see the `partial` row. |
|
||||
| `plan_required`, `invalid_plan`, `plan_mismatch` | Apply needs the successful preview's token and identical repository, symbol and new name. Expired, consumed or evicted plans require a fresh reviewed preview. |
|
||||
| `source_set_changed` | Source paths changed, including untracked files. Re-analyze and review a fresh preview. |
|
||||
| `plan_too_large` | The captured plan exceeds the 64 MiB retention budget. Narrow the indexed repository before previewing again. |
|
||||
| `unsupported_*` | Stop automated rename and inspect the reported limitation. Use a language-aware manual refactor if needed. |
|
||||
| Ambiguous target (`status: "ambiguous"` with candidates) | Inspect the returned candidates or `context`, then preview with the chosen `symbol_uid`. |
|
||||
| `ambiguous_reference`, `incomplete_semantics` | The planner could not prove a reference, import, or declaration is covered. Stop automated rename; resolve the named location by hand or pick a different symbol. |
|
||||
| `name_collision` | `new_name` would collide with, or change the identity of, another declaration or binding. Choose a different name and preview again. |
|
||||
| `invalid_name` | `new_name` is empty, equals the current name, or is not valid in the target language. Choose a valid, different identifier. |
|
||||
| `invalid_argument` | `dry_run` was sent but is not a boolean. Omit it or send `true` or `false`. |
|
||||
| `no_locations` | The planner produced no edits. Do not apply; re-check the target with `context` and pass `symbol_uid`. |
|
||||
| `overlapping_edits`, `invalid_edit` | The planned spans conflict or do not match the source text. The plan is rejected before any write; do not apply. Refactor manually or report the case. |
|
||||
| `duplicate_file`, `missing_snapshot`, `outside_repository` | A planner safety check failed (two paths resolve to one real file, a file has no source snapshot, or a path leaves the repository). Re-analyze and preview again; if it repeats, stop and report it. |
|
||||
| `source-unavailable` (`reason: "content-retention"` or `"checkout-missing"`) | The index was built without full source retention (`GITNEXUS_CONTENT_RETENTION`), or the checkout directory is missing. Re-analyze with full retention, or run against the checkout. |
|
||||
| `rename_failed` | An unexpected error while planning, before any write (`application_status: "not_started"`). Re-analyze and preview once more; if it repeats, stop and report the `error` text. |
|
||||
| `application_status: "partial"` or `"failed"` (`write_failed`, or `source_changed` mid-apply) | Writes are not transactional. Inspect landed `changes` (already written, with their counts), `failed_files` (the one file whose write or validation failed), and `unattempted_files` (later files never touched). Reconcile the current files, re-index, and review a fresh preview; never blindly retry the apply call. |
|
||||
|
||||
Run the project validation suite after applying.
|
||||
|
||||
**impact** — map all dependents first:
|
||||
|
||||
```
|
||||
|
|
@ -147,12 +206,12 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
|||
→ total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly
|
||||
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 text_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
→ 12 semantic occurrences in the indexed repository's supported provider scope
|
||||
→ Files: validator.ts, login.ts, middleware.ts...
|
||||
|
||||
2. Review text_search edits (config.json: dynamic reference!)
|
||||
2. Review semantic spans and provider coverage; inspect dynamic references separately
|
||||
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false})
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false, plan_token: "<token from step 1>"})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. detect_changes({scope: "all", repo: "my-app"})
|
||||
|
|
|
|||
|
|
@ -125,12 +125,14 @@ and do not claim a complete graph-backed review.
|
|||
from `NODE_SCHEMA_QUERIES` + `REL_SCHEMA_QUERIES` and moves on its own;
|
||||
the check there is whether the diff changed any string in those arrays,
|
||||
and, if it added a new DDL array, whether that array was folded into the
|
||||
fingerprint. The hand-maintained ritual still applies where no
|
||||
declarative artifact describes the invalidated set: the parse-store
|
||||
`SCHEMA_BUMP` and both bench fingerprint sets still need an explicit
|
||||
bump, re-checked against the base branch right before merge. Semantic
|
||||
changes that leave the DDL untouched are outside the fingerprint; they
|
||||
rely on the analyzer runner-identity receipt in the index metadata.
|
||||
fingerprint. Parser compatibility is also automatic: review the base/PDG
|
||||
ownership boundaries in `gitnexus/src/core/parse-cache-identity.ts`, the
|
||||
import/asset coverage audit, and real cold → warm replay tests. New parser
|
||||
dependencies outside those boundaries need explicit coverage; contributors
|
||||
do not allocate a semantic counter. Both benchmark fingerprint sets still
|
||||
need deliberate review before re-baselining: automatic invalidation does
|
||||
not establish semantic correctness. Other semantic changes that leave DDL
|
||||
untouched rely on the analyzer runner-identity receipt in index metadata.
|
||||
|
||||
## Expert lenses
|
||||
|
||||
|
|
|
|||
|
|
@ -141,10 +141,12 @@ finding is NOT proof of safety.
|
|||
`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`.
|
||||
- **Cache compatibility.** Worker-harvest changes invalidate automatically
|
||||
through the PDG artifact identity. Keep extraction-only dependencies in the
|
||||
PDG ownership boundary and verify that ordinary parse keys remain stable.
|
||||
Shared parser changes belong to the base identity. Persisted-graph/config
|
||||
changes ride `RepoMeta.pdg`'s key-union mismatch → full writeback.
|
||||
Model content rides `taintModelVersion`.
|
||||
|
||||
## Adding a source / sink / sanitizer
|
||||
|
||||
|
|
|
|||
|
|
@ -14,21 +14,28 @@
|
|||
# `@devcontainers/cli` / the VS Code Dev Containers resolver. That resolver's
|
||||
# image-name parser rejects the combined `name:tag@sha256:...` form.
|
||||
#
|
||||
# The digest below is for the `1-22-bookworm` tag. It is the multi-arch
|
||||
# manifest-list digest, so it still picks the right platform. To refresh it when
|
||||
# bumping the readable tag, run:
|
||||
# The digest below is for `5.2.3-22-trixie` (Node 22.23.3). Workspace
|
||||
# dependencies require at least Node 22.22.2 on the Node 22 line. It is the
|
||||
# multi-arch manifest-list digest, so it still picks the right platform.
|
||||
# Debian 13's libstdc++ supplies GLIBCXX_3.4.31, required by the packaged
|
||||
# tree-sitter runtime. Debian 12's runtime is too old even with g++ installed.
|
||||
# To refresh it when bumping the readable tag, run:
|
||||
# docker buildx imagetools inspect \
|
||||
# mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm \
|
||||
# mcr.microsoft.com/devcontainers/typescript-node:5.2.3-22-trixie \
|
||||
# --format '{{json .Manifest.Digest}}'
|
||||
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:7c2e711a4f7b02f32d2da16192d5e05aa7c95279be4ce889cff5df316f251c1d
|
||||
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:e17cfac65d32af8e488fb00ee7b51b1e508b21958c05c8b652224a1f739deb08
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Bun is installed via the official remote script (bun.sh/install), pinned by
|
||||
# version. Claude Code and Cursor also use official install scripts (no version
|
||||
# to pin). To harden Bun: switch to a pinned tarball + per-arch sha256
|
||||
# version. Claude Code, Codex, and Cursor also use official install scripts.
|
||||
# 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
|
||||
ARG INSTALL_BROWSER_DEPS=false
|
||||
ARG PLAYWRIGHT_VERSION
|
||||
|
||||
# Copy the build-only ARGs into runtime ENV so shells and lifecycle scripts can
|
||||
# read them. We deliberately do not set CLAUDE_CONFIG_DIR here. Its one true
|
||||
|
|
@ -38,25 +45,36 @@ ENV BUN_VERSION=${BUN_VERSION} \
|
|||
BUN_INSTALL=/home/${USERNAME}/.bun \
|
||||
TZ=${TZ} \
|
||||
DEVCONTAINER=true \
|
||||
NODE_OPTIONS=--max-old-space-size=4096 \
|
||||
GITNEXUS_AUTO_HEAP=0 \
|
||||
POWERLEVEL9K_DISABLE_GITSTATUS=true
|
||||
|
||||
# Native build toolchain that gitnexus/postinstall needs. It compiles
|
||||
# tree-sitter native bindings, the vendored Dart/Proto/Swift grammars, and the
|
||||
# @ladybugdb/core N-API addon (a native Node add-on). python3, make, and g++ are
|
||||
# required. This mirrors the apt block in the existing Dockerfile.cli /
|
||||
# required; libstdc++6 provides the C++ runtime for shipped native prebuilds.
|
||||
# util-linux supplies findmnt/mount for the 9p helper; the remaining diagnostic
|
||||
# tools support investigating process, memory and filesystem delays.
|
||||
# This mirrors the apt block in the existing Dockerfile.cli /
|
||||
# gitnexus/Dockerfile.test images.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
python3 make g++ git curl ca-certificates bash unzip \
|
||||
python3 make g++ libstdc++6 git curl ca-certificates bash unzip util-linux procps lsof strace time jq ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# UI profile: install OS dependencies at image-build time as root. Runtime
|
||||
# browser downloads use the project's locked Playwright and a persistent volume.
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ "${INSTALL_BROWSER_DEPS}" = true ]; then \
|
||||
test -n "${PLAYWRIGHT_VERSION}" \
|
||||
&& npm exec --yes --package="@playwright/test@${PLAYWRIGHT_VERSION}" -- playwright install-deps chromium \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
COPY bootstrap-native.cjs /usr/local/share/gitnexus-devcontainer/bootstrap-native.cjs
|
||||
|
||||
# Create and chown the named-volume mount points (~/.npm, ~/.local,
|
||||
# /commandhistory) up front. That way an empty volume inherits `node:node`
|
||||
# ownership the first time it is mounted. The three CLI config dirs (~/.claude,
|
||||
# ~/.codex, ~/.cursor) are bind-mounted from the host instead. A bind mount
|
||||
# completely hides the image-side ownership, so those paths need no chown here.
|
||||
# ownership the first time it is mounted. post-create.sh handles ownership on
|
||||
# the CLI config volumes (~/.claude, ~/.codex, ~/.cursor), including reused ones.
|
||||
RUN mkdir -p \
|
||||
/home/${USERNAME}/.npm \
|
||||
/home/${USERNAME}/.local/bin \
|
||||
|
|
@ -68,36 +86,38 @@ RUN mkdir -p \
|
|||
|
||||
USER ${USERNAME}
|
||||
|
||||
# Native CLI installers use this directory for their launchers.
|
||||
ENV PATH=/home/${USERNAME}/.local/bin:${BUN_INSTALL}/bin:${PATH}
|
||||
|
||||
# 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
|
||||
RUN curl -fsSL --retry 3 --max-time 120 https://claude.ai/install.sh | bash \
|
||||
&& /home/${USERNAME}/.local/bin/claude --version
|
||||
|
||||
# Install the Codex CLI globally via npm. No version pinned — @latest at build
|
||||
# time. (Codex has no native binary installer; npm is the canonical method.)
|
||||
RUN npm install -g @openai/codex
|
||||
# Install standalone Codex without npm or interactive onboarding. Keep a seed
|
||||
# outside ~/.codex: its runtime config volume can hide image-installed packages
|
||||
# on rebuild. post-create.sh restores the seed only if Codex cannot run.
|
||||
RUN curl -fsSL --retry 3 --max-time 120 https://chatgpt.com/codex/install.sh -o /tmp/install-codex.sh \
|
||||
&& CODEX_NON_INTERACTIVE=1 sh /tmp/install-codex.sh \
|
||||
&& /home/${USERNAME}/.local/bin/codex --version \
|
||||
&& mkdir -p /home/${USERNAME}/.local/share \
|
||||
&& mv /home/${USERNAME}/.codex/packages/standalone /home/${USERNAME}/.local/share/codex-standalone \
|
||||
&& rm /tmp/install-codex.sh
|
||||
|
||||
# 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
|
||||
RUN curl -fsSL --retry 3 --max-time 120 https://cursor.com/install | bash \
|
||||
&& /home/${USERNAME}/.local/bin/cursor-agent --version
|
||||
|
||||
# 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
|
||||
# version is fetched even though the install script itself is downloaded fresh
|
||||
# on every build. NOTE: this is the ONE remote script we run unverified in
|
||||
# this image — Cursor and the base image are pinned by sha256/digest. Hardening
|
||||
# path: switch to a pinned tarball + per-arch sha256 in the Cursor style
|
||||
# (artifacts at github.com/oven-sh/bun/releases). `BUN_INSTALL` is set in ENV
|
||||
# above so the binary lands at a known path regardless of any rc-file edits
|
||||
# on every build. The installer script itself is not pinned. `BUN_INSTALL` is
|
||||
# set in ENV above so the binary lands at a known path regardless of rc-file edits
|
||||
# the installer makes (which we ignore — we own the shell rc files).
|
||||
RUN set -eux; \
|
||||
curl -fsSL --retry 3 --max-time 120 https://bun.sh/install \
|
||||
| bash -s "bun-v${BUN_VERSION}"; \
|
||||
test -x "${BUN_INSTALL}/bin/bun"
|
||||
|
||||
# Put ~/.local/bin and Bun's bin dir on PATH for interactive shells and
|
||||
# lifecycle scripts. ~/.local/bin is where Cursor's installer drops the `agent`
|
||||
# and `cursor-agent` symlinks; ${BUN_INSTALL}/bin is where the Bun installer
|
||||
# drops `bun` / `bunx`.
|
||||
ENV PATH=/home/${USERNAME}/.local/bin:${BUN_INSTALL}/bin:${PATH}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI,
|
|||
|
||||
> ### ⚠️ Read this before using it on a work machine
|
||||
>
|
||||
> This devcontainer **does not write to your host AI-CLI config.** Your skills, agents, commands, plugins, memory, prompts, and rules are **copied once** from a read-only host stage into a per-container volume on first create; the container edits its own copy and can never write back. So a compromised workspace dependency running in the container **cannot** drop a malicious agent, command, skill, or plugin onto your host for your next host CLI session to load — the write-through vector earlier versions had is closed. Your **credentials** (Claude/Codex/Cursor logins, plus `gh`) likewise stay in per-container volumes and are never written back, and `~/.ssh`, `~/.aws`, `~/.azure`, and `~/.docker` are mounted **read-only**.
|
||||
> **The default 9p setup grants `SYS_ADMIN`, which can weaken read-only mount protections.** Remove that capability and disable tuning as described below to restore the boundary assumed in the following security notes.
|
||||
>
|
||||
> **With `SYS_ADMIN` removed**, this devcontainer **does not write to your host AI-CLI config.** Your skills, agents, commands, plugins, memory, prompts, and rules are **copied once** from a read-only host stage into a per-container volume on first create; the container edits its own copy and can never write back. So a compromised workspace dependency running in the container **cannot** drop a malicious agent, command, skill, or plugin onto your host for your next host CLI session to load — the write-through vector earlier versions had is closed. Your **credentials** (Claude/Codex/Cursor logins, plus `gh`) likewise stay in per-container volumes and are never written back, and `~/.ssh`, `~/.aws`, `~/.azure`, and `~/.docker` are mounted **read-only**.
|
||||
>
|
||||
> What is **still** exposed: the read-only host stages (`/host/.claude`, `/host/.codex`, `/host/.cursor`, `/host/.claude-mem`) and the read-only credential mounts are all **readable** inside the container. A compromised dependency can therefore READ your host CLI config, memory, SSH/cloud credentials, and GitHub token — and there is **no egress firewall yet**, so it has the network to exfiltrate what it reads. Read-only protects you from tampering and write-back, not from disclosure.
|
||||
>
|
||||
|
|
@ -18,7 +20,7 @@ A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI,
|
|||
2. Install [VS Code](https://code.visualstudio.com/) with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
|
||||
3. Install [Node.js](https://nodejs.org/) on the **host** (Node 18+). This is the only host-side toolchain dependency beyond Docker and VS Code — the devcontainer's `initializeCommand` runs `node .devcontainer/ensure-host-config-dirs.cjs` to set up the bind-mount source directories before container create. If you already use Claude Code or another Node-based CLI on the host, you're already set.
|
||||
4. Open the repo in VS Code → Command Palette → **Dev Containers: Reopen in Container**.
|
||||
5. Wait for the first build (~3–6 minutes) and `postCreateCommand` to finish installing workspace dependencies.
|
||||
5. Wait for the image build and lifecycle commands to finish preparing dependencies and AI CLI configuration.
|
||||
6. Authenticate the three CLIs once — see [First-time CLI authentication](#first-time-cli-authentication) below.
|
||||
|
||||
## Windows 11 setup
|
||||
|
|
@ -48,7 +50,7 @@ Windows-native works, but Docker Desktop's Windows bind-mount layer has rough ed
|
|||
|
||||
- **File watchers can miss events.** Vite / jest `--watch` running inside the container watching workspace files mounted from `D:\...` may miss changes — chokidar polling (`CHOKIDAR_USEPOLLING=true`) is the usual workaround.
|
||||
- **`npm install` is 3-5× slower** through the Windows-to-Linux bind-mount translation than on a WSL2-native filesystem.
|
||||
- **Permission edge cases.** The husky `.husky/_/h` EPERM class we hit earlier in this PR is specific to Windows-side bind mounts changing UID ownership between container runs. `post-create.sh` clears the cache defensively to keep this from being fatal, but it's still a real source of friction.
|
||||
- **Permission edge cases.** The husky `.husky/_/h` EPERM class we hit earlier in this PR is specific to Windows-side bind mounts changing UID ownership between container runs. `install-deps.sh` rebuilds the generated cache and verifies the pre-commit hook, but it's still a real source of friction.
|
||||
|
||||
If you hit any of those and want to migrate to WSL2 later, the steps are below.
|
||||
|
||||
|
|
@ -84,11 +86,92 @@ Open the repo folder in VS Code → **Reopen in Container**. The image is multi-
|
|||
|
||||
Same as macOS — open in VS Code and reopen in container. `updateRemoteUserUID: true` (default) shifts the container's `node` user UID/GID to match your host user, so bind-mounted files stay writable without extra setup.
|
||||
|
||||
## Workspace and image profiles
|
||||
|
||||
The main configuration keeps your existing host checkout bind-mounted at `/workspace`. For Windows hosts, the WSL2 filesystem setup above avoids the Windows filesystem translation. Additional profiles are generated from the main configuration, using host Node.js with no npm installation required:
|
||||
|
||||
```bash
|
||||
# A separate checkout in a Docker-managed native filesystem volume.
|
||||
node .devcontainer/profile.cjs native --ref main
|
||||
|
||||
# Bind-mounted checkout with Chromium system dependencies and browser cache.
|
||||
node .devcontainer/profile.cjs ui
|
||||
|
||||
# Combine the native workspace and UI support.
|
||||
node .devcontainer/profile.cjs native --ui --ref main
|
||||
|
||||
# Work on a branch pushed to your own fork (HTTPS or Git SSH).
|
||||
node .devcontainer/profile.cjs native --url https://github.com/YOUR-NAME/GitNexus.git --ref your-branch
|
||||
|
||||
# Pull a published toolchain image instead of building locally.
|
||||
node .devcontainer/profile.cjs prebuilt
|
||||
node .devcontainer/profile.cjs prebuilt --ui
|
||||
```
|
||||
|
||||
These commands overwrite the ignored `.devcontainer/generated/devcontainer.json`. Select that configuration when reopening the folder in Dev Containers, or pass `--config .devcontainer/generated/devcontainer.json` to the Dev Container CLI. Regenerate after changing the main configuration. Existing containers need recreation when switching workspace mounts.
|
||||
|
||||
The native profile clones the selected **remote branch** of GitNexus into its volume before dependency setup. It does not copy uncommitted host edits. Push your branch first and use `--ref your-branch` to work on it; contributors using a fork must also pass `--url` for that fork. HTTPS, `git@host:owner/repo.git`, and `ssh://git@host/owner/repo.git` URLs are supported without embedded HTTPS credentials. Fresh checkouts track their remote branch, so ordinary `git pull` and `git push` work. Reused volumes keep their current branch and local changes; the bootstrap neither resets nor automatically pulls an existing checkout. The volume is the authoritative checkout: use Git to export changes before removing it. Host credentials/configuration still use the existing read-only staging mounts.
|
||||
|
||||
`--ref` and `--url` apply only to `native`. `--image` selects an existing image for consumer profiles and preserves the configured Features, including GitHub CLI, so custom images need not bundle `gh`. Dev Containers applies those Features to the selected image. `image-build` accepts only `--ui`, since its build output is named by the publishing command.
|
||||
|
||||
UI profiles install OS dependencies during the image build using the Playwright version in `gitnexus-web/package-lock.json`. Setup installs matching Chromium binaries into a persistent browser-cache volume. They also allocate 1 GiB of private shared memory. Prebuilt UI profiles require the `main-ui` image variant; explicit images passed with `--image` must include those OS dependencies.
|
||||
|
||||
## Dependency reuse, storage and diagnostics
|
||||
|
||||
Setup records completed installations inside each dependency volume. Fingerprints cover package manifests, lockfiles, local file-dependency manifests, Node version/ABI, npm version, architecture, and tracked native grammar/build inputs. The CLI receipt also includes the effective `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` setting. A matching receipt with all direct dependencies present skips `npm ci`. Missing dependencies or changed inputs cause a clean reinstall, with lifecycle scripts enabled. Failed installs leave no valid receipt.
|
||||
|
||||
Husky preparation and verification **always run**, including cached setup. Shared and CLI source builds run independently of dependency reuse, so pulling source changes still refreshes compiled output. After pulling changes in a running container, run:
|
||||
|
||||
```bash
|
||||
bash .devcontainer/install-deps.sh
|
||||
|
||||
# Repair nested dependency damage, new untracked native build inputs, or
|
||||
# changed npm installation settings that are outside the fingerprint.
|
||||
GITNEXUS_FORCE_DEPS=1 bash .devcontainer/install-deps.sh
|
||||
```
|
||||
|
||||
The shared GitNexus store and registry persist under `GITNEXUS_HOME=/home/node/.gitnexus` in a native named volume, retaining worktree sharing. Existing checkout-local `.gitnexus` directories stay in place; a new mount does not hide or replace them. The native workspace profile also places checkout-local indexes on its native filesystem. If moving an existing container whose shared store lives only in its writable layer, export that store before deleting the old container.
|
||||
|
||||
Setup prints timings for ownership repair, dependency installation/reuse, builds, and browser provisioning. Inspect the environment at any time with:
|
||||
|
||||
```bash
|
||||
node .devcontainer/doctor.cjs
|
||||
node .devcontainer/dev-test.cjs core test/unit/resolve-invocation.test.ts
|
||||
node .devcontainer/dev-test.cjs web
|
||||
GITNEXUS_DEV_TEST_WORKERS=2 node .devcontainer/dev-test.cjs core --watch
|
||||
```
|
||||
|
||||
The diagnostic command reports CPU/memory allowance, cgroup memory events, memory/I/O pressure, disk space/inodes, file-descriptor and watcher limits, filesystem options, and hook status. The development test runner chooses up to four workers, respecting cgroup v2 CPU/memory limits and leaving interactive headroom. Its explicit override accepts 1–99 workers. The ordinary package test commands and CI test configuration remain available. Ownership repair still checks nested entries on each setup; timings can establish whether a more aggressive cache is worthwhile.
|
||||
|
||||
## Prebuilt images
|
||||
|
||||
`.github/workflows/devcontainer-publish.yml` verifies the tooling and publishes amd64/arm64 core and UI images to GHCR on relevant `main` pushes or manual dispatch on `main`. Pull requests do not publish images. Registry build caches are separate for the two variants. Consumers supply runtime mounts and lifecycle commands; published toolchain metadata excludes those settings to avoid duplicate lifecycle execution.
|
||||
|
||||
The rolling tags are `ghcr.io/abhigyanpatwari/gitnexus-devcontainer:main` and `:main-ui`, available **after the first successful publication**. A fork publishes under its own repository-derived name. Each publication also records a unique `sha-COMMIT-run-RUN-ATTEMPT` tag in the workflow summary (with `-ui` for UI images). Pass that full reference or an image digest with `--image` to select a fixed build. Combine `native --image IMAGE` to use a prebuilt image with a native workspace. Local builds remain the default while the publishing workflow is reviewed and merged. New GHCR packages are [private by default](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry); a maintainer must enable public visibility for anonymous pulls, or developers must authenticate to GHCR. The workflow links the package to this repository using its OCI source label.
|
||||
|
||||
## 9p workspace tuning
|
||||
|
||||
`.devcontainer/tune-9p.sh` can attempt a larger 9p `msize` on each container start. It is **enabled by default**, with `SYS_ADMIN` granted to permit the remount attempt, and applies only when `/workspace` itself is a 9p mount. **Rebuild** an existing container to apply the capability. Check inside the container with:
|
||||
|
||||
```bash
|
||||
findmnt -rn --mountpoint /workspace -o FSTYPE,OPTIONS
|
||||
```
|
||||
|
||||
To disable the remount attempts, set `containerEnv.WORKSPACE_9P_TUNE` to `"0"` in `.devcontainer/devcontainer.json`. To also remove the broad mount privileges, remove `"SYS_ADMIN"` from `capAdd` and **rebuild**. Setting the flag alone does not remove the capability. The default target is 1 MiB (`1048576` bytes); change `containerEnv.WORKSPACE_9P_MSIZE` to choose another size. The existing defaults also accept these two variables from the host environment when creating the container.
|
||||
|
||||
The helper uses `sudo -n` with a five-second timeout, skips other filesystems and mounts already at or above the target, and never fails startup. It reads the mount options again after a remount and distinguishes a verified target value from an unchanged or unknown value. To retry manually:
|
||||
|
||||
```bash
|
||||
WORKSPACE_9P_TUNE=1 bash .devcontainer/tune-9p.sh /workspace
|
||||
```
|
||||
|
||||
This is an experiment, with no measured speedup claimed. [`msize` sets the 9p packet payload size](https://www.kernel.org/doc/html/latest/filesystems/9p.html), but [upstream Linux's 9p implementation](https://github.com/torvalds/linux/blob/master/fs/9p/vfs_super.c) has no reconfigure handler to apply a new value. A successful remount command can therefore leave `msize` unchanged; Docker Desktop's kernel and server may also limit it. Increasing privileges alone does not guarantee tuning will work. For Windows development, keeping the checkout on the WSL2 filesystem remains the documented setup above.
|
||||
|
||||
## How CLI state flows from your host
|
||||
|
||||
### AI CLIs (Claude Code, Codex, Cursor): copy-once from a read-only host stage + per-container credentials
|
||||
|
||||
The three AI CLIs use a **copy-from-read-only-stage topology**: the host's `~/.<cli>` folders (and `~/.claude-mem`) are mounted **read-only** at `/host/.<cli>`, and `post-create.sh` copies out of them into per-container named volumes. Credentials, identity, and single config files are copied on **every** create; the shareable subdirs (plugins, skills, agents, memory, commands, prompts, rules) are copied **once** on first create and then owned by the container. Nothing is bind-mounted read-write into the host's CLI config, so the container can never modify your host setup. Session sub-paths overlay the config volume via their own named volumes (Docker mount precedence — more specific path wins).
|
||||
The three AI CLIs use a **copy-from-read-only-stage topology**: the host's `~/.<cli>` folders (and `~/.claude-mem`) are mounted **read-only** at `/host/.<cli>`, and `post-create.sh` copies out of them into per-container named volumes. Credentials, identity, and single config files are copied on **every** create; the shareable subdirs (plugins, skills, agents, memory, commands, prompts, rules) are copied **once** on first create and then owned by the container. Lifecycle scripts and CLI tools edit the container copies. The default `SYS_ADMIN` capability means the read-only mounts are not a security boundary against privileged container code. Session sub-paths overlay the config volume via their own named volumes (Docker mount precedence — more specific path wins).
|
||||
|
||||
| Mount | Source | Target | Mode | Purpose |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -133,7 +216,7 @@ On the **first** container-create, `post-create.sh` copies each of these out of
|
|||
|
||||
**Why CLAUDE_CONFIG_DIR is intentionally NOT set:** Claude's default `~/.claude` matches the named-volume mount target, so the env var added no behavior — but setting it changed which file Claude reads `hasCompletedOnboarding` from. With it set, Claude reads `$CLAUDE_CONFIG_DIR/.claude.json` (the small identity-only file) and re-onboards every container; without it, Claude reads `$HOME/.claude.json` (copied from the read-only `/host/.claude.json` stage on container-create via `seed-claude-config.cjs`, with `hasCompletedOnboarding: true`).
|
||||
|
||||
**Host CLI config is protected from write-through.** The shareable dirs are copied out of a **read-only** stage into the container's own volume, so a compromised npm package in the workspace dep tree — running inside the container — **cannot** write a malicious agent, command, skill, or plugin back to `~/.claude/`, `~/.codex/`, or `~/.cursor/` on the host. The earlier design bind-mounted these read-write and accepted that write-through as the cost of live sync; this design closes it. An even earlier alternative (read-only stage + symlinks) made `/plugin marketplace add` inside the container fail with EROFS; copying into a writable volume avoids that, because the container writes to its own copy rather than a read-only mount. What a compromised dependency can still do is **read** the read-only host stages (`/host/.<cli>`, `/host/.claude-mem`) and the read-only credential mounts and exfiltrate them — there is [no egress firewall yet](#whats-not-included-yet). The cost of the copy model is **divergence**: host edits made after first create don't reach the container until you wipe the config volume and rebuild.
|
||||
**With `SYS_ADMIN` removed, host CLI config is protected from write-through.** The shareable dirs are copied out of a **read-only** stage into the container's own volume, so a compromised npm package in the workspace dep tree — running inside the container — **cannot** write a malicious agent, command, skill, or plugin back to `~/.claude/`, `~/.codex/`, or `~/.cursor/` on the host. The earlier design bind-mounted these read-write and accepted that write-through as the cost of live sync; this design closes it. An even earlier alternative (read-only stage + symlinks) made `/plugin marketplace add` inside the container fail with EROFS; copying into a writable volume avoids that, because the container writes to its own copy rather than a read-only mount. What a compromised dependency can still do is **read** the read-only host stages (`/host/.<cli>`, `/host/.claude-mem`) and the read-only credential mounts and exfiltrate them — there is [no egress firewall yet](#whats-not-included-yet). The cost of the copy model is **divergence**: host edits made after first create don't reach the container until you wipe the config volume and rebuild.
|
||||
|
||||
**Refresh-token divergence between rebuilds.** Container's credentials match host's at container-create time; after that, container manages its own refresh until the next rebuild. Anthropic rotates refresh tokens on every use, so an unattended container that hasn't talked to the API in weeks can hit a silent 401 if the host has refreshed since. Re-run `claude login` inside the container, or rebuild, to recover.
|
||||
|
||||
|
|
@ -214,7 +297,7 @@ The bind mount source directories are guaranteed to exist by the `initializeComm
|
|||
|
||||
Host and container share a single trust boundary by design — fine for personal-dev, but the consequence is concrete. Any malicious npm package or `postinstall` script in the workspace dep tree, running inside the container, has direct **read** access to:
|
||||
|
||||
- **Host AI CLI state** — the read-only stage at `/host/.claude`, `/host/.codex`, `/host/.cursor`, `/host/.claude-mem`, which exposes your **entire** host `~/.<cli>` tree (credentials, identity, AND the shareable skills/agents/plugins/memory/commands) for _reading_. The container copies what it needs out of this stage; a compromised dep can read all of it. It is read-only, so none of it can be written back
|
||||
- **Host AI CLI state** — the read-only stage at `/host/.claude`, `/host/.codex`, `/host/.cursor`, `/host/.claude-mem`, which exposes your **entire** host `~/.<cli>` tree (credentials, identity, AND the shareable skills/agents/plugins/memory/commands) for _reading_. The container copies what it needs out of this stage; a compromised dep can read all of it. It is mounted read-only; default `SYS_ADMIN` can weaken that protection
|
||||
- The **container's own credential snapshots** at `/home/node/.claude/.credentials.json` etc. (copied from host on container-create)
|
||||
- `~/.claude/memory/` / per-project memory (which may contain user-stored secrets if you've used the `/remember` skill)
|
||||
- The **current container's own session transcripts** (`~/.claude/projects`, `~/.codex/sessions`, `~/.cursor/chats`/`projects` — the group-6 volumes), which can hold anything pasted into or read during a session. These are container-private (see one-way note below), so this is read access to _this_ container's sessions only, not the host's or other projects'
|
||||
|
|
@ -223,9 +306,9 @@ Host and container share a single trust boundary by design — fine for personal
|
|||
- Docker registry tokens in **`~/.docker/config.json`** (if you've `docker login`-ed)
|
||||
- AWS/Azure CLI credentials if you've populated `~/.aws/` or `~/.azure/`
|
||||
|
||||
It does **not** have write-through to the host's CLI config. The shareable dirs are copied out of the read-only stage into the container's own volume, so a compromised in-container dep **cannot** write into your host `~/.claude/{plugins,agents,skills,commands,memory}/`, `~/.codex/{plugins,prompts,memories,skills}/`, or `~/.cursor/{plugins,rules,commands,agents,skills}/`. The persistence vector earlier versions had — drop a malicious auto-loaded agent/command/skill/rule onto the host, have it run in your next **host** session — is closed: there is no writable path from the container to those host folders. (Cursor's `hooks.json` is still additionally withheld from even the _container's_ copy, because hooks fire without an agent invoking them.) The boundary is now one-way for **all** of the host CLI config, not just credentials.
|
||||
**With `SYS_ADMIN` removed**, it does **not** have write-through to the host's CLI config. The shareable dirs are copied out of the read-only stage into the container's own volume, so a compromised in-container dep **cannot** write into your host `~/.claude/{plugins,agents,skills,commands,memory}/`, `~/.codex/{plugins,prompts,memories,skills}/`, or `~/.cursor/{plugins,rules,commands,agents,skills}/`. The persistence vector earlier versions had — drop a malicious auto-loaded agent/command/skill/rule onto the host, have it run in your next **host** session — is closed: there is no writable path from the container to those host folders. (Cursor's `hooks.json` is still additionally withheld from even the _container's_ copy, because hooks fire without an agent invoking them.) The boundary is now one-way for **all** of the host CLI config, not just credentials.
|
||||
|
||||
**What stays one-way (genuinely protected):** everything. Credentials never flow back to host — `.credentials.json` / `auth.json` / `cli-config.json` live only in the per-container named volumes, and the `/host/.<cli>` stage they're copied from is mounted **read-only**, so the snapshot can't be overwritten back. The shareable AI-CLI dirs (skills/agents/plugins/memory/commands/prompts/rules) are now copy-on-create from that same read-only stage, so they have the one-way property too — readable for the copy, never writable back. `~/.ssh`, `~/.config/git`, `~/.aws`, `~/.azure`, and **`~/.docker`** are read-only binds with the same property — a compromised dep can _read_ your registry tokens but cannot _rewrite_ them to hijack your future host auth. **`~/.config/gh`** is now a read-only _stage_ copied into a per-container volume, so it keeps that same one-way property: the container reads it once to seed its own writable copy, and the read-only stage means an in-container `gh auth login` can never overwrite your host token. **Session transcripts** live in per-workspace named volumes (mount group 6) and are never seeded from or written back to the host, and the container can't see any _other_ project's transcripts. The opt-in host-bind block in `devcontainer.json` reverses that for sessions only — enable it only if you accept transcripts on host disk; see [Session resume across container recreation](#session-resume-across-container-recreation).
|
||||
**With `SYS_ADMIN` removed, what stays one-way:** everything. Credentials never flow back to host — `.credentials.json` / `auth.json` / `cli-config.json` live only in the per-container named volumes, and the `/host/.<cli>` stage they're copied from is mounted **read-only**, so the snapshot can't be overwritten back. The shareable AI-CLI dirs (skills/agents/plugins/memory/commands/prompts/rules) are now copy-on-create from that same read-only stage, so they have the one-way property too — readable for the copy, never writable back. `~/.ssh`, `~/.config/git`, `~/.aws`, `~/.azure`, and **`~/.docker`** are read-only binds with the same property — a compromised dep can _read_ your registry tokens but cannot _rewrite_ them to hijack your future host auth. **`~/.config/gh`** is now a read-only _stage_ copied into a per-container volume, so it keeps that same one-way property: the container reads it once to seed its own writable copy, and the read-only stage means an in-container `gh auth login` can never overwrite your host token. **Session transcripts** live in per-workspace named volumes (mount group 6) and are never seeded from or written back to the host, and the container can't see any _other_ project's transcripts. The opt-in host-bind block in `devcontainer.json` reverses that for sessions only — enable it only if you accept transcripts on host disk; see [Session resume across container recreation](#session-resume-across-container-recreation).
|
||||
|
||||
**The egress firewall is the key compensating control that is still missing.** It's deferred (see "What's not included (yet)" below), so a compromised package currently has unrestricted outbound network to exfiltrate anything in the read list above. Until it lands, treat that read surface as exposed to any code you run in the container — don't use this devcontainer on a machine whose host credentials you couldn't afford to rotate. The isolated-volume setup below removes host AI-CLI config/credentials from that surface entirely.
|
||||
|
||||
|
|
@ -310,12 +393,15 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
|
|||
|
||||
## Known gotchas
|
||||
|
||||
- **Warm installs reuse the npm cache.** Lifecycle installs prefer cached packages and skip audit requests and funding notices; cache misses still download normally, and native addon/prepare scripts still run. Run `npm audit` in each package when checking vulnerabilities. Volume ownership repair changes only entries whose owner or group differs from `node`.
|
||||
- **VS Code skips generated indexes, build output, dependencies, and nested worktrees when watching/searching.** Open a sibling worktree as its own window when developing there. These exclusions do not change Vite or Vitest watchers.
|
||||
- **Memory follows the running workload.** The image leaves `NODE_OPTIONS` unset, so Node uses its defaults and `gitnexus analyze` uses its RAM-aware sizing. For a specific indexing run, use `gitnexus analyze --memory-budget 4096` to set a 4 GiB heap budget. Docker Desktop's VM memory allocation still bounds the container.
|
||||
- **LadybugDB integration tests may fail in containers** (file-locking, `AGENTS.md` § Testing). Default to `npm run test:unit` inside the container; run integration tests on the host. Tracking issue: documented as a known limitation.
|
||||
- **Single-writer LadybugDB constraint** (`GUARDRAILS.md` § LadybugDB lock). Don't run `gitnexus analyze` on the host and inside the container against the same `.gitnexus/` directory simultaneously — the second writer will get `database busy`.
|
||||
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift/Kotlin are all vendored uniformly: `node-gyp-build` picks a committed GitNexus-built prebuilt `.node` at install time (no compile), and only falls back to compiling from the vendored source during `postinstall` if no prebuild matches the host (then a toolchain is needed). Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` (in your shell or `remoteEnv`, then rebuild) to skip all four; each loses parsing for the affected language(s), and the install still succeeds.
|
||||
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift/Kotlin/Zig 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 these optional grammars; each loses parsing for the affected language(s), and the install still succeeds. Changing this setting invalidates the CLI dependency receipt, including when it is removed to restore parsing.
|
||||
- **`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`.
|
||||
- **Husky pre-commit is verified during setup.** Root preparation always installs the hook, including cached dependency runs. Only its generated `.husky/_` cache is cleared for stale Windows permissions; the tracked `.husky/pre-commit` stays intact. Setup fails if the hook is missing, Git points elsewhere, or the environment or Husky's `init.sh` disables or skips it. Verification runs the installed Husky helper against a temporary sentinel hook without running the real formatter or changing staged files. Run `npm run prepare` at the repository root to restore the hook, and keep Husky enabled in its startup configuration.
|
||||
|
||||
## Rebuild / reset
|
||||
|
||||
|
|
@ -341,22 +427,35 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
|
|||
|
||||
## Bumping CLI versions
|
||||
|
||||
Bump the version pins in `.devcontainer/devcontainer.json` `build.args` and rebuild — all three are real, fail-loud pins. Claude Code installs via `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}` and Codex via `npm install -g @openai/codex@${CODEX_VERSION}`. **Cursor is pinned too:** bump `CURSOR_VERSION` **and** both `CURSOR_SHA256_X64` / `CURSOR_SHA256_ARM64` together — the Dockerfile downloads the pinned `downloads.cursor.com/lab/<version>/linux/<arch>/agent-cli-package.tar.gz` artifact directly (no remote install script) and fails the build on a sha256 mismatch. Re-hash each arch with `curl -fSL <url> | sha256sum`. To stop Cursor from auto-updating in the running container, don't call `cursor-agent update`.
|
||||
Claude Code, Codex, and Cursor install the latest available release at image build time using their official shell installers. Rebuild without the Docker cache to refresh those image layers. Bun's version is pinned by `BUN_VERSION` in `.devcontainer/devcontainer.json` `build.args`; the Node base image is pinned by digest in the Dockerfile.
|
||||
|
||||
Codex uses the [official standalone installer](https://developers.openai.com/codex/cli/) (`curl -fsSL https://chatgpt.com/codex/install.sh | sh`) rather than npm. The build runs it non-interactively and checks `codex --version`. Its native packages normally live under `~/.codex/packages/standalone`, inside the persistent config volume. The image keeps a package seed outside that mount; `post-create.sh` restores it when Codex cannot run, including when rebuilding with a config volume created by the previous npm-based image. A working standalone release already in the volume is kept. This avoids another download during setup and leaves credentials, configuration, and sessions intact.
|
||||
|
||||
## What's not included (yet)
|
||||
|
||||
- **Egress firewall — the most important hardening still outstanding.** The original plan included an opt-in iptables/ipset firewall adapted from Anthropic's reference devcontainer. It was deferred to a follow-up PR — `runArgs` is static in `devcontainer.json`, so toggling NET_ADMIN/NET_RAW capabilities cleanly requires either a separate `devcontainer-firewall.json` profile or an `initializeCommand`-generated overlay. Until it lands, the read surface in [§ Trust boundary](#trust-boundary-concretely) has no network containment — anything readable can be exfiltrated. Track at the project's issue tracker if you need this.
|
||||
- **Codespaces tuning.** The current config works in Codespaces incidentally (no privileged capabilities, no host-mount assumptions), but isn't actively tested there.
|
||||
- **Playwright e2e support.** `gitnexus-web`'s `npm run test:e2e` needs Chromium libs that the base image doesn't ship. Use the host for e2e until a Playwright layer is added.
|
||||
- **Codespaces tuning.** Host configuration mounts and `SYS_ADMIN` support are not actively tested there.
|
||||
- **Non-Chromium browsers.** The UI profile provisions Chromium. Firefox/WebKit need their own browser binaries and system dependencies.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If a native addon fails with `GLIBCXX_3.4.31 not found`, run **Dev Containers:
|
||||
Rebuild Container** after updating this checkout. The image uses Debian 13
|
||||
(Trixie) with a C++ runtime that supports the packaged tree-sitter binary, while
|
||||
keeping Node 22.23.3. Reinstalling npm packages or `g++` in an older Debian 12
|
||||
(Bookworm) container does not upgrade that system runtime. The default, native
|
||||
workspace, and UI build profiles all use this Dockerfile. If you selected a
|
||||
prebuilt image, pull an image published from this change or a later commit and
|
||||
recreate the container; a pinned older image keeps its older runtime.
|
||||
|
||||
If `[install-deps]` prints `npm warn EBADENGINE` with Node 22.16.0, run **Dev Containers: Rebuild Container** from the Command Palette after updating this checkout. The image now pins Node 22.23.3; workspace dependencies require at least 22.22.2 on the Node 22 line. Re-running `npm install` in the old container does not update its Node runtime.
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GitNexus devcontainer one-time Windows setup` banner from `initializeCommand` | First-time Windows-native Reopen-in-Container; `HOME` env var was missing | The script just ran `setx HOME "%USERPROFILE%"` for you. Close ALL VS Code windows (File → Exit) and reopen — see [Windows 11 setup](#windows-11-setup) |
|
||||
| `bind source path does not exist: /.claude` (or similar) from Docker | Windows-native `HOME` env var is still missing even after one rebuild — `setx` may have failed or VS Code wasn't fully restarted | Run `setx HOME "%USERPROFILE%"` in a Windows shell manually, fully exit VS Code (check Task Manager that no `Code.exe` remains), reopen |
|
||||
| `EACCES` / `EPERM` writing into `~/.claude`, `~/.codex`, or `~/.cursor` inside the container | Stale state from a previous container with a different effective UID | Move the affected dir aside and let the CLI rebuild it (`mv ~/.claude ~/.claude.bak` and log in again). Long-term: WSL2 setup, which doesn't hit this class of issue |
|
||||
| `EPERM: operation not permitted, copyfile ... '.husky/_/h'` in `postCreateCommand` | Leftover `.husky/_/` from a previous container run on a Windows-side bind mount | `post-create.sh` already runs `rm -rf .husky/_` defensively. If you hit this on an older config, delete `.husky/_/` on the host and rebuild. Long-term: clone in WSL2 |
|
||||
| `EPERM: operation not permitted, copyfile ... '.husky/_/h'` in `postCreateCommand` | Leftover `.husky/_/` from a previous container run on a Windows-side bind mount | `install-deps.sh` regenerates `.husky/_` and verifies the active hook. If you hit this on an older config, delete `.husky/_/` on the host and rebuild. Long-term: clone in WSL2 |
|
||||
| Vite never hot-reloads | Repo cloned on Windows side, not WSL2 | Re-clone inside WSL2 |
|
||||
| `gitnexus-web` can't reach the backend | `4747` was remapped or backend isn't running | Verify the Ports panel shows `4747` forwarded with no remap; start the backend with `cd gitnexus && npx gitnexus serve` |
|
||||
| `npm install` fails on tree-sitter-swift / proto / dart | Native build toolchain missing | This shouldn't happen in the devcontainer — verify the apt layer installed `python3 make g++`. If iterating, set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` to skip the vendored grammars |
|
||||
|
|
|
|||
71
.devcontainer/bootstrap-native.cjs
Normal file
71
.devcontainer/bootstrap-native.cjs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
'use strict';
|
||||
// Runs from the image before any workspace lifecycle script exists in the
|
||||
// fresh volume. Never reset or overwrite a populated checkout.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
function bootstrap(root, url, ref) {
|
||||
if (!url || !ref || ref.startsWith('-')) throw new Error('A clone URL and branch are required');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, {
|
||||
cwd: root,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
git('check-ref-format', '--branch', ref);
|
||||
if (!fs.existsSync(path.join(root, '.git'))) git('init');
|
||||
let origin;
|
||||
try {
|
||||
origin = git('remote', 'get-url', 'origin');
|
||||
} catch {
|
||||
git('remote', 'add', 'origin', url);
|
||||
origin = url;
|
||||
}
|
||||
if (origin !== url) throw new Error('Workspace volume has another origin; refusing to modify it');
|
||||
try {
|
||||
git('rev-parse', '--verify', 'HEAD');
|
||||
return;
|
||||
} catch {
|
||||
/* Resume an interrupted initial fetch. */
|
||||
}
|
||||
const upstream = `refs/remotes/origin/${ref}`;
|
||||
git('fetch', 'origin', `refs/heads/${ref}:${upstream}`);
|
||||
git('checkout', '--no-overwrite-ignore', '--track', '-b', ref, upstream);
|
||||
}
|
||||
|
||||
module.exports = { bootstrap };
|
||||
if (require.main === module) {
|
||||
const root = path.resolve(process.argv[2] || '/workspace');
|
||||
// Repair UID changes on a reused workspace volume without following symlinks
|
||||
// or descending into the separately mounted dependency volumes.
|
||||
const uid = String(process.getuid());
|
||||
const gid = String(process.getgid());
|
||||
execFileSync(
|
||||
'sudo',
|
||||
[
|
||||
'-n',
|
||||
'find',
|
||||
root,
|
||||
'-xdev',
|
||||
'(',
|
||||
'!',
|
||||
'-uid',
|
||||
uid,
|
||||
'-o',
|
||||
'!',
|
||||
'-gid',
|
||||
gid,
|
||||
')',
|
||||
'-exec',
|
||||
'chown',
|
||||
'-h',
|
||||
`${uid}:${gid}`,
|
||||
'{}',
|
||||
'+',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
bootstrap(root, process.env.GITNEXUS_CLONE_URL, process.env.GITNEXUS_CLONE_REF);
|
||||
}
|
||||
139
.devcontainer/dependencies.cjs
Normal file
139
.devcontainer/dependencies.cjs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const markerName = '.gitnexus-devcontainer-install.json';
|
||||
const nativeInputs = [
|
||||
'vendor',
|
||||
'prebuilds/bundled',
|
||||
'scripts/build-tree-sitter-grammars.cjs',
|
||||
'scripts/prepare-tree-sitter-bundle.cjs',
|
||||
'scripts/arm64-prebuild.cjs',
|
||||
];
|
||||
|
||||
function fingerprint(directory, runtime = process) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(
|
||||
JSON.stringify([
|
||||
3,
|
||||
runtime.version,
|
||||
runtime.versions.modules,
|
||||
runtime.platform,
|
||||
runtime.arch,
|
||||
runtime.npmVersion,
|
||||
]),
|
||||
);
|
||||
for (const file of ['package.json', 'package-lock.json']) {
|
||||
hash.update(file);
|
||||
hash.update(fs.readFileSync(path.join(directory, file)));
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json')));
|
||||
for (const dependency of Object.values({
|
||||
...manifest.dependencies,
|
||||
...manifest.devDependencies,
|
||||
...manifest.optionalDependencies,
|
||||
})) {
|
||||
if (typeof dependency !== 'string' || !dependency.startsWith('file:')) continue;
|
||||
const sibling = path.resolve(directory, dependency.slice(5), 'package.json');
|
||||
hash.update(fs.readFileSync(sibling));
|
||||
}
|
||||
if (fs.existsSync(path.join(directory, 'scripts/build-tree-sitter-grammars.cjs'))) {
|
||||
hash.update(
|
||||
JSON.stringify({ skipOptionalGrammars: process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1' }),
|
||||
);
|
||||
// Blob IDs cover committed native inputs; the diff covers local tracked
|
||||
// edits. Do not read npm credential files or hash large unchanged prebuilds.
|
||||
for (const args of [
|
||||
['ls-files', '-s', '--', ...nativeInputs],
|
||||
['diff', 'HEAD', '--', ...nativeInputs],
|
||||
]) {
|
||||
hash.update(execFileSync('git', args, { cwd: directory, maxBuffer: 32 * 1024 * 1024 }));
|
||||
}
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function ensureDependencies(directory, { run = runNpm, force = false, runtime = process } = {}) {
|
||||
const expected = fingerprint(directory, runtime);
|
||||
const marker = path.join(directory, 'node_modules', markerName);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json')));
|
||||
const packages = Object.keys({ ...manifest.dependencies, ...manifest.devDependencies });
|
||||
let saved;
|
||||
try {
|
||||
saved = JSON.parse(fs.readFileSync(marker, 'utf8'));
|
||||
} catch {
|
||||
/* First or interrupted install. */
|
||||
}
|
||||
const present = packages.every((name) =>
|
||||
fs.existsSync(path.join(directory, 'node_modules', name, 'package.json')),
|
||||
);
|
||||
if (!force && present && saved?.fingerprint === expected) {
|
||||
console.log(
|
||||
`[install-deps] ${path.basename(directory)} dependencies unchanged; reusing volume`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Invalidate before installing: a failed attempt must never leave a reusable receipt.
|
||||
fs.rmSync(marker, { force: true });
|
||||
run(directory, ['ci']);
|
||||
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
||||
const temporary = `${marker}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify({ fingerprint: fingerprint(directory, runtime) }));
|
||||
fs.renameSync(temporary, marker);
|
||||
return true;
|
||||
}
|
||||
|
||||
function runNpm(directory, args) {
|
||||
execFileSync('npm', args, { cwd: directory, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
function timed(label, action) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
return action();
|
||||
} finally {
|
||||
console.log(`[install-deps] ${label}: ${((performance.now() - start) / 1000).toFixed(2)}s`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fingerprint, ensureDependencies, markerName };
|
||||
|
||||
if (require.main === module) {
|
||||
const root = path.resolve(process.argv[2] || '/workspace');
|
||||
const runtime = {
|
||||
version: process.version,
|
||||
versions: process.versions,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
npmVersion: execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim(),
|
||||
};
|
||||
const ensure = (directory) =>
|
||||
timed(`dependencies ${path.basename(directory)}`, () =>
|
||||
ensureDependencies(directory, { force: process.env.GITNEXUS_FORCE_DEPS === '1', runtime }),
|
||||
);
|
||||
ensure(root);
|
||||
// Always restore and verify hooks, including the cached-install path.
|
||||
runNpm(root, ['run', 'prepare']);
|
||||
require('./verify-git-hooks.cjs').verifyGitHooks(root);
|
||||
console.log('[install-deps] Husky hooks verified');
|
||||
const shared = path.join(root, 'gitnexus-shared');
|
||||
ensure(shared);
|
||||
timed('shared build', () => runNpm(shared, ['run', 'build']));
|
||||
const web = path.join(root, 'gitnexus-web');
|
||||
ensure(web);
|
||||
const core = path.join(root, 'gitnexus');
|
||||
// A fresh npm ci already builds via prepare. A reused install still needs
|
||||
// source compilation, even when manifests and lockfiles have not changed.
|
||||
if (!ensure(core)) timed('CLI source build', () => runNpm(core, ['run', 'build']));
|
||||
if (process.env.GITNEXUS_DEV_UI === '1') {
|
||||
timed('Chromium provisioning', () =>
|
||||
execFileSync(path.join(web, 'node_modules/.bin/playwright'), ['install', 'chromium'], {
|
||||
cwd: web,
|
||||
stdio: 'inherit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
252
.devcontainer/dependencies.test.cjs
Normal file
252
.devcontainer/dependencies.test.cjs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { ensureDependencies, markerName } = require('./dependencies.cjs');
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-dependencies-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'package.json'),
|
||||
JSON.stringify({ dependencies: { example: '1.0.0' } }),
|
||||
);
|
||||
fs.writeFileSync(path.join(root, 'package-lock.json'), '{}');
|
||||
let installs = 0;
|
||||
const run = () => {
|
||||
installs++;
|
||||
fs.mkdirSync(path.join(root, 'node_modules/example'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'node_modules/example/package.json'), '{}');
|
||||
};
|
||||
return { root, run, count: () => installs };
|
||||
}
|
||||
|
||||
test('reuses completed dependency volumes and reinstalls after lockfile changes', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), false);
|
||||
fs.writeFileSync(path.join(f.root, 'package-lock.json'), '{"lockfileVersion":3}');
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
|
||||
assert.equal(f.count(), 2);
|
||||
});
|
||||
|
||||
test('repairs a missing direct dependency even if the receipt matches', (t) => {
|
||||
const f = fixture(t);
|
||||
ensureDependencies(f.root, { run: f.run });
|
||||
fs.rmSync(path.join(f.root, 'node_modules/example'), { recursive: true });
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
|
||||
});
|
||||
|
||||
test('runtime ABI changes and explicit repair invalidate reuse', (t) => {
|
||||
const f = fixture(t);
|
||||
ensureDependencies(f.root, { run: f.run });
|
||||
const runtime = {
|
||||
version: 'v22.23.3',
|
||||
versions: { modules: 'different' },
|
||||
platform: 'linux',
|
||||
arch: 'arm64',
|
||||
};
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run, runtime }), true);
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run, runtime, force: true }), true);
|
||||
});
|
||||
|
||||
for (const group of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
||||
test(`local file-dependency manifest changes in ${group} invalidate reuse`, (t) => {
|
||||
const f = fixture(t);
|
||||
const local = path.join(f.root, 'local');
|
||||
fs.mkdirSync(local);
|
||||
fs.writeFileSync(path.join(local, 'package.json'), '{"version":"1.0.0"}');
|
||||
const manifest = { dependencies: { example: '1.0.0' } };
|
||||
manifest[group] ??= {};
|
||||
manifest[group].local = 'file:./local';
|
||||
fs.writeFileSync(path.join(f.root, 'package.json'), JSON.stringify(manifest));
|
||||
const run = () => {
|
||||
f.run();
|
||||
fs.mkdirSync(path.join(f.root, 'node_modules/local'), { recursive: true });
|
||||
fs.copyFileSync(
|
||||
path.join(local, 'package.json'),
|
||||
path.join(f.root, 'node_modules/local/package.json'),
|
||||
);
|
||||
};
|
||||
ensureDependencies(f.root, { run });
|
||||
assert.equal(ensureDependencies(f.root, { run }), false);
|
||||
fs.writeFileSync(path.join(local, 'package.json'), '{"version":"1.0.1"}');
|
||||
assert.equal(ensureDependencies(f.root, { run }), true);
|
||||
assert.equal(ensureDependencies(f.root, { run }), false);
|
||||
assert.equal(f.count(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
test('tracked native source and installer edits invalidate reuse', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(path.join(f.root, 'scripts'));
|
||||
fs.mkdirSync(path.join(f.root, 'vendor'));
|
||||
fs.mkdirSync(path.join(f.root, 'prebuilds/bundled'), { recursive: true });
|
||||
fs.writeFileSync(path.join(f.root, 'scripts/build-tree-sitter-grammars.cjs'), '// original');
|
||||
fs.writeFileSync(path.join(f.root, 'scripts/arm64-prebuild.cjs'), '// original validator');
|
||||
fs.writeFileSync(path.join(f.root, 'prebuilds/bundled/manifest.json'), '{"schemaVersion":1}');
|
||||
fs.writeFileSync(path.join(f.root, 'prebuilds/bundled/grammar.node'), Buffer.from([1, 2, 3]));
|
||||
fs.writeFileSync(path.join(f.root, 'vendor/grammar.c'), '// original');
|
||||
const git = (...args) =>
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'core.hooksPath=/dev/null',
|
||||
'-c',
|
||||
'commit.gpgsign=false',
|
||||
'-c',
|
||||
'user.name=Fixture',
|
||||
'-c',
|
||||
'user.email=fixture@example.invalid',
|
||||
...args,
|
||||
],
|
||||
{ cwd: f.root, stdio: 'pipe' },
|
||||
);
|
||||
git('init');
|
||||
git(
|
||||
'add',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'scripts/build-tree-sitter-grammars.cjs',
|
||||
'scripts/arm64-prebuild.cjs',
|
||||
'prebuilds/bundled',
|
||||
'vendor/grammar.c',
|
||||
);
|
||||
git('commit', '-m', 'fixture');
|
||||
ensureDependencies(f.root, { run: f.run });
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), false);
|
||||
fs.writeFileSync(path.join(f.root, 'vendor/grammar.c'), '// changed grammar');
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
|
||||
fs.writeFileSync(
|
||||
path.join(f.root, 'scripts/build-tree-sitter-grammars.cjs'),
|
||||
'// changed installer',
|
||||
);
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
|
||||
for (const [file, content] of [
|
||||
['scripts/arm64-prebuild.cjs', '// changed validator'],
|
||||
['prebuilds/bundled/manifest.json', '{"schemaVersion":2}'],
|
||||
['prebuilds/bundled/grammar.node', Buffer.from([4, 5, 6])],
|
||||
]) {
|
||||
fs.writeFileSync(path.join(f.root, file), content);
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), true, file);
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), false, file);
|
||||
}
|
||||
});
|
||||
|
||||
test('failed reinstall removes its receipt and is retried', (t) => {
|
||||
const f = fixture(t);
|
||||
ensureDependencies(f.root, { run: f.run });
|
||||
assert.throws(
|
||||
() =>
|
||||
ensureDependencies(f.root, {
|
||||
force: true,
|
||||
run: () => {
|
||||
throw new Error('install failed');
|
||||
},
|
||||
}),
|
||||
/install failed/,
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'node_modules', markerName)), false);
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
|
||||
});
|
||||
|
||||
test('changing optional-grammar policy invalidates the core install receipt', (t) => {
|
||||
const f = fixture(t);
|
||||
const previous = process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS;
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS;
|
||||
else process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS = previous;
|
||||
});
|
||||
fs.mkdirSync(path.join(f.root, 'scripts'));
|
||||
fs.writeFileSync(path.join(f.root, 'scripts/build-tree-sitter-grammars.cjs'), '// fixture');
|
||||
execFileSync('git', ['init', '-q'], { cwd: f.root });
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'core.hooksPath=/dev/null',
|
||||
'-c',
|
||||
'commit.gpgsign=false',
|
||||
'-c',
|
||||
'user.name=Fixture',
|
||||
'-c',
|
||||
'user.email=fixture@example.invalid',
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
'fixture',
|
||||
],
|
||||
{ cwd: f.root, stdio: 'pipe' },
|
||||
);
|
||||
process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS = '1';
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), false);
|
||||
delete process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS;
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
|
||||
process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS = '0';
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), false);
|
||||
process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS = '1';
|
||||
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
|
||||
assert.equal(f.count(), 3);
|
||||
});
|
||||
|
||||
test('cached lifecycle restores real Husky and rebuilds changed sources', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(path.join(f.root, 'package.json'), '{}');
|
||||
for (const name of ['gitnexus', 'gitnexus-web', 'gitnexus-shared']) {
|
||||
fs.mkdirSync(path.join(f.root, name));
|
||||
fs.writeFileSync(path.join(f.root, name, 'package.json'), '{}');
|
||||
fs.writeFileSync(path.join(f.root, name, 'package-lock.json'), '{}');
|
||||
}
|
||||
fs.mkdirSync(path.join(f.root, '.husky'));
|
||||
fs.writeFileSync(path.join(f.root, '.husky/pre-commit'), 'exit 0\n');
|
||||
const global = path.join(f.root, 'gitconfig');
|
||||
fs.writeFileSync(global, '');
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${f.root}:${process.env.PATH}`,
|
||||
HUSKY: '',
|
||||
GIT_CONFIG_GLOBAL: global,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_COUNT: '0',
|
||||
GITNEXUS_FORCE_DEPS: '0',
|
||||
GITNEXUS_DEV_UI: '0',
|
||||
};
|
||||
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'])
|
||||
delete env[key];
|
||||
execFileSync('git', ['init', '-q'], { cwd: f.root, env });
|
||||
const husky = path.resolve(__dirname, '../node_modules/husky/bin.js');
|
||||
fs.writeFileSync(
|
||||
path.join(f.root, 'npm'),
|
||||
`#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--version') { console.log('10.9.2'); process.exit(0); }
|
||||
fs.appendFileSync(${JSON.stringify(path.join(f.root, 'calls'))}, path.basename(process.cwd()) + ':' + args.join(' ') + '\\n');
|
||||
if (args[0] === 'ci') fs.mkdirSync('node_modules', { recursive: true });
|
||||
if (args.join(' ') === 'run prepare') require('node:child_process').execFileSync(process.execPath, [${JSON.stringify(husky)}], { stdio: 'inherit' });
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const invoke = () => {
|
||||
const result = spawnSync(process.execPath, [path.join(__dirname, 'dependencies.cjs'), f.root], {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
};
|
||||
invoke();
|
||||
fs.rmSync(path.join(f.root, '.husky/_'), { recursive: true });
|
||||
fs.writeFileSync(path.join(f.root, 'calls'), '');
|
||||
invoke();
|
||||
const calls = fs.readFileSync(path.join(f.root, 'calls'), 'utf8');
|
||||
assert.doesNotMatch(calls, /:ci/);
|
||||
assert.match(calls, /:run prepare/);
|
||||
assert.match(calls, /gitnexus:run build/);
|
||||
assert.ok(fs.existsSync(path.join(f.root, '.husky/_/pre-commit')));
|
||||
});
|
||||
23
.devcontainer/dev-test.cjs
Normal file
23
.devcontainer/dev-test.cjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use strict';
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { workerBudget } = require('./resources.cjs');
|
||||
|
||||
const [target, ...args] = process.argv.slice(2);
|
||||
if (!['core', 'web'].includes(target))
|
||||
throw new Error('Usage: node .devcontainer/dev-test.cjs core|web [Vitest arguments]');
|
||||
const root = path.resolve(__dirname, '..', target === 'core' ? 'gitnexus' : 'gitnexus-web');
|
||||
const workers = workerBudget();
|
||||
console.log(`[dev-test] ${target}: ${workers} workers (override with GITNEXUS_DEV_TEST_WORKERS)`);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(root, 'node_modules/vitest/vitest.mjs'),
|
||||
...(args.includes('--watch') ? [] : ['run']),
|
||||
...args,
|
||||
`--maxWorkers=${workers}`,
|
||||
],
|
||||
{ cwd: root, stdio: 'inherit' },
|
||||
);
|
||||
if (result.error) console.error(result.error.message);
|
||||
process.exitCode = result.status ?? 1;
|
||||
|
|
@ -43,6 +43,11 @@
|
|||
|
||||
"remoteUser": "node",
|
||||
"updateRemoteUserUID": true,
|
||||
// Reap orphaned subprocesses from CLI workers and test runs.
|
||||
"init": true,
|
||||
// Allow the startup helper to attempt a 9p remount. See README for the
|
||||
// effect on read-only mount protections and how to disable it.
|
||||
"capAdd": ["SYS_ADMIN"],
|
||||
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
|
||||
"workspaceFolder": "/workspace",
|
||||
|
|
@ -54,13 +59,11 @@
|
|||
// single config files (always), plus the shareable subfolders (Claude
|
||||
// plugins/skills/agents/memory/commands; Codex plugins/prompts/memories/
|
||||
// skills; Cursor plugins/rules/commands/agents/skills) ONCE on first create.
|
||||
// Everything copied lands in the per-container named volume (role 2). It is
|
||||
// read-only so a container process can NEVER write back to the host — there
|
||||
// is no read-write bind into the host's CLI config at all. This protects the
|
||||
// host's on-disk setup: a compromised in-container dependency cannot drop a
|
||||
// skill, agent, command, or plugin onto the host for the next host session
|
||||
// to load. The cost is that host and container DIVERGE after the first
|
||||
// create — host edits don't reach the container until you wipe the config
|
||||
// Everything copied lands in the per-container named volume (role 2).
|
||||
// The host stage is read-only; lifecycle scripts write to container copies.
|
||||
// SYS_ADMIN is enabled for 9p tuning, so read-only flags are not a security
|
||||
// boundary against privileged container code. Host and container DIVERGE
|
||||
// after first create — host edits don't reach it until you wipe the config
|
||||
// volume and rebuild. See README § "Trust boundary, concretely".
|
||||
//
|
||||
// 2. AI CLI container config — one named volume per devcontainer. CODEX_HOME
|
||||
|
|
@ -202,19 +205,19 @@
|
|||
// "source=${localEnv:HOME}/.cursor/projects,target=/home/node/.cursor/projects,type=bind",
|
||||
|
||||
// Read-only host stage that post-create.sh copies FROM on container-create.
|
||||
// It is read-only so a container process can never write back to host CLI
|
||||
// state — that write-back is the attack vector we block. post-create.sh
|
||||
// Lifecycle scripts do not write back to host CLI state. The default
|
||||
// SYS_ADMIN capability can weaken read-only mount protections. post-create.sh
|
||||
// reads two kinds of thing from here: (a) the credential + identity files
|
||||
// (copied into the volume always), and (b) the shareable dirs — skills,
|
||||
// agents, plugins, memory, commands, prompts, rules — which it copies into
|
||||
// the volume ONCE on first create (see step 3/4). Nothing here is bound
|
||||
// read-write into the container, so the host's on-disk setup is protected.
|
||||
// read-write into the container by this configuration.
|
||||
"source=${localEnv:HOME}/.claude,target=/host/.claude,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.codex,target=/host/.codex,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.cursor,target=/host/.cursor,type=bind,readonly",
|
||||
// Read-only host stage for the claude-mem store. post-create.sh COPIES it
|
||||
// into the claude-mem named volume on first create (seed-once). Read-only so
|
||||
// the container can never write back to the host's live DB — the seed is a
|
||||
// the seed script does not write back to the host's live DB — the seed is a
|
||||
// one-way snapshot. ensure-host-config-dirs.cjs creates ~/.claude-mem on the
|
||||
// host so this bind resolves even when claude-mem was never installed there.
|
||||
"source=${localEnv:HOME}/.claude-mem,target=/host/.claude-mem,type=bind,readonly",
|
||||
|
|
@ -229,8 +232,8 @@
|
|||
// now COPIED once from the read-only /host/.<cli> stage into the per-container
|
||||
// named volume by post-create.sh (step 3/4), exactly like claude-mem and the
|
||||
// session volumes. Trade-offs of the copy model:
|
||||
// - The container gets its OWN writable copy and can never write back to
|
||||
// the host. Host setup is protected.
|
||||
// - CLI edits use the container's OWN writable copy. SYS_ADMIN can bypass
|
||||
// read-only protections; see README for the trust boundary.
|
||||
// - It is seed-ONCE: host edits made after first create don't reach the
|
||||
// container until you remove the config volume and rebuild. Container
|
||||
// edits persist across rebuilds. (See README § Rebuild/reset to re-seed.)
|
||||
|
|
@ -277,6 +280,7 @@
|
|||
"source=${localEnv:HOME}/.azure,target=/home/node/.azure,type=bind,readonly",
|
||||
"source=commandhistory-${devcontainerId},target=/commandhistory,type=volume",
|
||||
"source=npm-cache-${devcontainerId},target=/home/node/.npm,type=volume",
|
||||
"source=gitnexus-store-${devcontainerId},target=/home/node/.gitnexus,type=volume",
|
||||
"source=${localWorkspaceFolderBasename}-root-node-modules-${devcontainerId},target=/workspace/node_modules,type=volume",
|
||||
"source=${localWorkspaceFolderBasename}-gitnexus-node-modules-${devcontainerId},target=/workspace/gitnexus/node_modules,type=volume",
|
||||
"source=${localWorkspaceFolderBasename}-gitnexus-web-node-modules-${devcontainerId},target=/workspace/gitnexus-web/node_modules,type=volume",
|
||||
|
|
@ -314,7 +318,10 @@
|
|||
// dependency explicit instead of silently following the default.
|
||||
"containerEnv": {
|
||||
"CODEX_HOME": "/home/node/.codex",
|
||||
"HISTFILE": "/commandhistory/.zsh_history"
|
||||
"HISTFILE": "/commandhistory/.zsh_history",
|
||||
"GITNEXUS_HOME": "/home/node/.gitnexus",
|
||||
"WORKSPACE_9P_TUNE": "${localEnv:WORKSPACE_9P_TUNE:1}",
|
||||
"WORKSPACE_9P_MSIZE": "${localEnv:WORKSPACE_9P_MSIZE:1048576}"
|
||||
},
|
||||
|
||||
"customizations": {
|
||||
|
|
@ -332,6 +339,26 @@
|
|||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
// Generated indexes and sibling worktrees can dwarf the source tree.
|
||||
// These exclusions affect VS Code only; Vite and test watchers keep
|
||||
// their own configuration.
|
||||
"files.watcherExclude": {
|
||||
"**/node_modules/**": true,
|
||||
"**/.gitnexus/**": true,
|
||||
"**/.worktrees/**": true,
|
||||
"**/.claude/worktrees/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/coverage/**": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/.gitnexus": true,
|
||||
"**/.worktrees": true,
|
||||
"**/.claude/worktrees": true,
|
||||
"**/dist": true,
|
||||
"**/coverage": true
|
||||
},
|
||||
"git.autoRepositoryDetection": "openEditors",
|
||||
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||
"terminal.integrated.profiles.linux": {
|
||||
"bash": { "path": "bash", "icon": "terminal-bash" },
|
||||
|
|
@ -361,15 +388,18 @@
|
|||
},
|
||||
|
||||
// Lifecycle split (from the Dev Container spec):
|
||||
// - `updateContentCommand` runs on container-create AND whenever the
|
||||
// workspace content changes, such as a lockfile update. It owns installing
|
||||
// the workspace dependencies. Re-installing on every container-create
|
||||
// wastes time when nothing changed, but it must re-run when deps change.
|
||||
// - `updateContentCommand` prepares dependencies after workspace content is
|
||||
// available during creation/prebuild refresh. It reuses successful installs
|
||||
// when inputs match. In a running container, explicitly run install-deps.sh
|
||||
// after pulling dependency changes; this hook is not a lockfile watcher.
|
||||
// - `postCreateCommand` runs once on container-create. It owns syncing the
|
||||
// AI CLI credentials and identity from the host. That work should happen
|
||||
// exactly once per container instance, not on every content update.
|
||||
// Run both with an explicit `bash` so they don't depend on the script's
|
||||
// - `postStartCommand` attempts 9p tuning on every start unless disabled; failure
|
||||
// never blocks startup.
|
||||
// Run lifecycle scripts with an explicit `bash` so they don't depend on the script's
|
||||
// executable bit surviving the workspace bind mount.
|
||||
"updateContentCommand": "bash .devcontainer/install-deps.sh",
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh"
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh",
|
||||
"postStartCommand": "bash .devcontainer/tune-9p.sh || true"
|
||||
}
|
||||
|
|
|
|||
39
.devcontainer/doctor.cjs
Normal file
39
.devcontainer/doctor.cjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
'use strict';
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { read, workerBudget } = require('./resources.cjs');
|
||||
|
||||
const root = path.resolve(process.argv[2] || '/workspace');
|
||||
console.log(
|
||||
`[doctor] Node ${process.version}; ${os.availableParallelism()} available CPUs; suggested dev test workers ${workerBudget()}`,
|
||||
);
|
||||
for (const file of [
|
||||
'/sys/fs/cgroup/memory.max',
|
||||
'/sys/fs/cgroup/memory.current',
|
||||
'/sys/fs/cgroup/memory.events',
|
||||
'/sys/fs/cgroup/cpu.max',
|
||||
'/proc/pressure/memory',
|
||||
'/proc/pressure/io',
|
||||
'/proc/sys/fs/inotify/max_user_watches',
|
||||
'/proc/sys/fs/inotify/max_user_instances',
|
||||
])
|
||||
console.log(`[doctor] ${file}: ${read(file)}`);
|
||||
for (const [command, args] of [
|
||||
['findmnt', ['-rn', '-T', root, '-o', 'FSTYPE,OPTIONS']],
|
||||
['df', ['-h', root]],
|
||||
['df', ['-i', root]],
|
||||
['bash', ['-c', 'ulimit -n']],
|
||||
]) {
|
||||
try {
|
||||
console.log(execFileSync(command, args, { encoding: 'utf8', timeout: 5000 }).trim());
|
||||
} catch {
|
||||
console.log(`[doctor] ${command}: unavailable`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
require('./verify-git-hooks.cjs').verifyGitHooks(root);
|
||||
console.log('[doctor] Husky hooks verified');
|
||||
} catch (error) {
|
||||
console.log(`[doctor] Husky: ${error.message}`);
|
||||
}
|
||||
|
|
@ -1,25 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
# Devcontainer updateContentCommand. The Dev Container spec runs this when the
|
||||
# container is created AND whenever workspace content changes (for example a
|
||||
# lockfile update). This script installs workspace dependencies only. Syncing AI
|
||||
# Devcontainer updateContentCommand. Runs during container creation after
|
||||
# workspace content is available. Run this script explicitly after pulling
|
||||
# dependency changes into an already-running container. Syncing AI
|
||||
# CLI state lives in post-create.sh, which runs once right after this.
|
||||
#
|
||||
# Why the split: updateContentCommand re-runs on content changes, but
|
||||
# postCreateCommand runs only at container-create. Keeping `npm install` here
|
||||
# means a rebuild after pulling new dependencies refreshes them. The AI CLI
|
||||
# Why the split: updateContentCommand handles dependency readiness, while
|
||||
# postCreateCommand handles AI CLI state. Installation receipts reuse unchanged
|
||||
# dependency volumes and refresh them when inputs change. The AI CLI
|
||||
# credential and path-translation work does not re-run each time.
|
||||
|
||||
set -euo pipefail
|
||||
cd /workspace
|
||||
|
||||
echo "[install-deps] 1/4: chown workspace node_modules + npm cache mount points"
|
||||
ownership_start=$SECONDS
|
||||
# The named volumes (workspace/*/node_modules and ~/.npm) are created at first
|
||||
# mount. They inherit ownership from the image's UID before realignment. Then
|
||||
# `updateRemoteUserUID: true` shifts the `node` user's UID. Now the volumes are
|
||||
# owned by the old, stale UID and npm install cannot write to them. So we chown
|
||||
# again here, after realignment. Running it again later changes nothing.
|
||||
#
|
||||
# We use `find -xdev -exec chown -h` (the same idiom as post-create.sh) instead
|
||||
# We use `find -xdev` with `chown -h` (the same idiom as post-create.sh) instead
|
||||
# of a plain `chown -R`. There are two separate guards. First, `-xdev` stops
|
||||
# find from descending past each volume's own filesystem, so it won't recurse
|
||||
# into a host folder mounted underneath. Second, `-h` makes chown change the
|
||||
|
|
@ -28,41 +29,41 @@ echo "[install-deps] 1/4: chown workspace node_modules + npm cache mount points"
|
|||
# node_modules/.bin link) would either send the chown onto a target on another
|
||||
# filesystem, or fail to follow and abort the whole script under `set -e`. For
|
||||
# regular files and directories `-h` does nothing, so the ownership fix is the
|
||||
# same.
|
||||
# same. Only change entries with the wrong owner or group: carried-over volumes
|
||||
# can contain hundreds of thousands of files that already have correct ownership.
|
||||
for d in /workspace/node_modules \
|
||||
/workspace/gitnexus/node_modules \
|
||||
/workspace/gitnexus-web/node_modules \
|
||||
/workspace/gitnexus-shared/node_modules \
|
||||
/home/node/.npm; do
|
||||
sudo find "$d" -xdev -exec chown -h node:node {} +
|
||||
/home/node/.npm \
|
||||
/home/node/.gitnexus \
|
||||
/home/node/.cache/ms-playwright; do
|
||||
[ -d "$d" ] || continue
|
||||
sudo find "$d" -xdev \( ! -user node -o ! -group node \) -exec chown -h node:node {} +
|
||||
done
|
||||
echo "[install-deps] ownership repair: $((SECONDS - ownership_start))s"
|
||||
|
||||
# Reuse the persistent npm cache without waiting for metadata freshness checks.
|
||||
# Cache misses still use the registry. Keep install lifecycle scripts enabled for
|
||||
# native addons and prepare builds; audits remain available via `npm audit`.
|
||||
export npm_config_prefer_offline=true
|
||||
export npm_config_audit=false
|
||||
export npm_config_fund=false
|
||||
|
||||
echo "[install-deps] 2/4: clear stale .husky/_ runtime cache"
|
||||
# On Docker Desktop for Windows, the bind-mount permission translation won't let
|
||||
# the new container's `node` user overwrite a `.husky/_/h` file that an earlier
|
||||
# container wrote under a different UID. So we delete it. `.husky/_` is a
|
||||
# gitignored runtime cache, and husky rebuilds it during the root `npm install`.
|
||||
# The tracked .husky/pre-commit is preserved. Verification below fails setup if
|
||||
# regeneration was skipped or failed, so this cleanup cannot silently disable it.
|
||||
# Husky upstream has no fix for this UID clash.
|
||||
rm -rf .husky/_
|
||||
|
||||
echo "[install-deps] 3/4: npm install at root, then gitnexus-shared (build required)"
|
||||
# Install order matters. Root goes first, for lint-staged, husky, and prettier.
|
||||
# Then gitnexus-shared, which must be built before installing gitnexus-web or
|
||||
# gitnexus. Both of those depend on it via `file:../gitnexus-shared`.
|
||||
npm install
|
||||
cd /workspace/gitnexus-shared
|
||||
npm install
|
||||
npm run build
|
||||
echo "[install-deps] 3/4: restore dependencies, verify Husky, and build sources"
|
||||
node .devcontainer/dependencies.cjs /workspace
|
||||
|
||||
echo "[install-deps] 4/4: npm install gitnexus-web, then gitnexus"
|
||||
# gitnexus-web goes before gitnexus. The gitnexus `prepare` script runs
|
||||
# scripts/build.js, which compiles gitnexus-web when that directory is present.
|
||||
# In the devcontainer the whole workspace is bind-mounted, so gitnexus-web/ is
|
||||
# present when gitnexus installs. The production Dockerfiles COPY only selected
|
||||
# files, so the directory is not present there.
|
||||
cd /workspace/gitnexus-web
|
||||
npm install
|
||||
cd /workspace/gitnexus
|
||||
npm install
|
||||
echo "[install-deps] 4/4: environment summary"
|
||||
node .devcontainer/doctor.cjs /workspace
|
||||
|
||||
echo "[install-deps] done"
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
# created. devcontainer.json wires it up via `postCreateCommand`. Workspace
|
||||
# dependencies are installed elsewhere, in install-deps.sh (`updateContentCommand`).
|
||||
# That script runs BEFORE this one — that is the order the devcontainer spec
|
||||
# defines. This script does one job: sync the AI CLI credentials and identity
|
||||
# from the host.
|
||||
# defines. This script prepares AI CLI binaries and syncs configuration from
|
||||
# the host into the container's own volumes.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -67,9 +67,15 @@ for d in "${DIRS[@]}"; do
|
|||
# devcontainer.json without its matching DIRS entry being removed too — then
|
||||
# provisioning skips it instead of failing before credentials ever sync.
|
||||
[ -d "$d" ] || continue
|
||||
sudo find "$d" -xdev -exec chown -h node:node {} +
|
||||
# Avoid chown syscalls for the already-correct entries on reused volumes.
|
||||
sudo find "$d" -xdev \( ! -user node -o ! -group node \) -exec chown -h node:node {} +
|
||||
done
|
||||
|
||||
# The standalone installer stores Codex under ~/.codex, which a reused config
|
||||
# volume hides. Restore the image's native package without downloading it again
|
||||
# or overwriting a working standalone installation already in that volume.
|
||||
bash "$SCRIPT_DIR/seed-codex.sh"
|
||||
|
||||
echo "[post-create] 2/4: sync AI CLI credentials + identity from host"
|
||||
# Clean up after an older devcontainer design (Option B). Back then these paths
|
||||
# were symlinks pointing into the read-only host stage
|
||||
|
|
|
|||
163
.devcontainer/profile.cjs
Normal file
163
.devcontainer/profile.cjs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
'use strict';
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function replaceOnce(text, pattern, replacement) {
|
||||
const matches = text.match(new RegExp(pattern.source, 'g'));
|
||||
if (matches?.length !== 1) throw new Error(`Canonical devcontainer layout changed: ${pattern}`);
|
||||
return text.replace(pattern, () => replacement);
|
||||
}
|
||||
|
||||
function profile(
|
||||
source,
|
||||
{
|
||||
native = false,
|
||||
ui = false,
|
||||
image,
|
||||
imageOnly = false,
|
||||
ref = 'main',
|
||||
playwright,
|
||||
url = 'https://github.com/abhigyanpatwari/GitNexus.git',
|
||||
} = {},
|
||||
) {
|
||||
if (imageOnly && image) throw new Error('--image cannot be used with image-build');
|
||||
if (
|
||||
typeof ref !== 'string' ||
|
||||
!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(ref) ||
|
||||
ref === 'HEAD' ||
|
||||
ref.includes('..') ||
|
||||
ref.endsWith('.') ||
|
||||
ref.split('/').some((part) => !part || part.startsWith('.') || part.endsWith('.lock'))
|
||||
)
|
||||
throw new Error('Invalid clone branch');
|
||||
// Character safety check only; Docker validates the image reference grammar.
|
||||
if (image && !/^[a-zA-Z0-9][a-zA-Z0-9./:_@-]*$/.test(image))
|
||||
throw new Error('Invalid image reference');
|
||||
if (
|
||||
native &&
|
||||
!/^(?:https:\/\/[a-zA-Z0-9.-]+(?::[0-9]+)?\/|ssh:\/\/git@[a-zA-Z0-9.-]+(?::[0-9]+)?\/|git@[a-zA-Z0-9.-]+:)[a-zA-Z0-9._/-]+$/.test(
|
||||
url,
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid clone URL; use HTTPS or Git SSH without embedded credentials');
|
||||
let result = source;
|
||||
if (image) {
|
||||
const start = result.indexOf(' "build": {');
|
||||
const end = result.indexOf('\n // Runs on the HOST', start);
|
||||
if (start < 0 || end < 0) throw new Error('Canonical build block changed');
|
||||
result = result.slice(0, start) + ` "image": ${JSON.stringify(image)},\n` + result.slice(end);
|
||||
} else {
|
||||
result = replaceOnce(result, /"dockerfile": "Dockerfile"/, '"dockerfile": "../Dockerfile"');
|
||||
result = replaceOnce(result, /"context": "\."/, '"context": ".."');
|
||||
if (ui) {
|
||||
if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(playwright))
|
||||
throw new Error('Locked Playwright version required');
|
||||
result = replaceOnce(
|
||||
result,
|
||||
/"BUN_VERSION":/,
|
||||
`"INSTALL_BROWSER_DEPS": "true",\n "PLAYWRIGHT_VERSION": ${JSON.stringify(playwright)},\n "BUN_VERSION":`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (native) {
|
||||
result = replaceOnce(
|
||||
result,
|
||||
/"workspaceMount": "[^"\n]+"/,
|
||||
'"workspaceMount": "source=gitnexus-workspace-${devcontainerId},target=/workspace,type=volume"',
|
||||
);
|
||||
result = replaceOnce(
|
||||
result,
|
||||
/"CODEX_HOME":/,
|
||||
`"GITNEXUS_CLONE_URL": ${JSON.stringify(url)},\n ` +
|
||||
`"GITNEXUS_CLONE_REF": ${JSON.stringify(ref)},\n "CODEX_HOME":`,
|
||||
);
|
||||
result = replaceOnce(
|
||||
result,
|
||||
/"updateContentCommand":/,
|
||||
'"onCreateCommand": "node /usr/local/share/gitnexus-devcontainer/bootstrap-native.cjs",\n "updateContentCommand":',
|
||||
);
|
||||
}
|
||||
if (imageOnly) {
|
||||
const start = result.indexOf(' "build": {');
|
||||
const end = result.indexOf('\n // Runs on the HOST', start);
|
||||
const features = result.match(/"features": \{[\s\S]*?\n \},/);
|
||||
if (start < 0 || end < 0 || !features)
|
||||
throw new Error('Image build requires the canonical build and feature blocks');
|
||||
// Publish toolchain metadata only. Lifecycle hooks and host mounts belong
|
||||
// to the consuming profile, avoiding duplicate hook execution on attach.
|
||||
return '{\n' + result.slice(start, end) + '\n ' + features[0].slice(0, -1) + '\n}\n';
|
||||
}
|
||||
if (ui) {
|
||||
result = replaceOnce(
|
||||
result,
|
||||
/"CODEX_HOME":/,
|
||||
'"GITNEXUS_DEV_UI": "1",\n "PLAYWRIGHT_BROWSERS_PATH": "/home/node/.cache/ms-playwright",\n "CODEX_HOME":',
|
||||
);
|
||||
result = replaceOnce(
|
||||
result,
|
||||
/"source=npm-cache-/,
|
||||
'"source=playwright-cache-${devcontainerId},target=/home/node/.cache/ms-playwright,type=volume",\n "source=npm-cache-',
|
||||
);
|
||||
result = replaceOnce(
|
||||
result,
|
||||
/"workspaceMount":/,
|
||||
'"runArgs": ["--shm-size=1g"],\n "workspaceMount":',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { profile };
|
||||
if (require.main === module) {
|
||||
const [mode, ...args] = process.argv.slice(2);
|
||||
if (!['native', 'ui', 'prebuilt', 'image-build'].includes(mode))
|
||||
throw new Error(
|
||||
'Usage: node .devcontainer/profile.cjs native [--ui] [--image IMAGE] [--ref BRANCH] [--url URL]\n' +
|
||||
' node .devcontainer/profile.cjs ui [--image IMAGE]\n' +
|
||||
' node .devcontainer/profile.cjs prebuilt [--ui] [--image IMAGE]\n' +
|
||||
' node .devcontainer/profile.cjs image-build [--ui]',
|
||||
);
|
||||
const options = new Map();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const flag = args[i];
|
||||
if (!['--ui', '--image', '--ref', '--url'].includes(flag))
|
||||
throw new Error(`Unknown profile argument: ${flag}`);
|
||||
if (options.has(flag)) throw new Error(`Duplicate profile argument: ${flag}`);
|
||||
if (flag === '--ui') options.set(flag, true);
|
||||
else {
|
||||
const value = args[++i];
|
||||
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
|
||||
options.set(flag, value);
|
||||
}
|
||||
}
|
||||
if (mode === 'image-build' && options.has('--image'))
|
||||
throw new Error('--image cannot be used with image-build');
|
||||
if (mode !== 'native' && (options.has('--ref') || options.has('--url')))
|
||||
throw new Error('--ref and --url require the native profile');
|
||||
const lock = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../gitnexus-web/package-lock.json')),
|
||||
);
|
||||
const output = path.join(__dirname, 'generated');
|
||||
const contents = profile(fs.readFileSync(path.join(__dirname, 'devcontainer.json'), 'utf8'), {
|
||||
native: mode === 'native',
|
||||
ui: mode === 'ui' || options.has('--ui'),
|
||||
image:
|
||||
options.get('--image') ||
|
||||
(mode === 'prebuilt'
|
||||
? `ghcr.io/abhigyanpatwari/gitnexus-devcontainer:main${options.has('--ui') ? '-ui' : ''}`
|
||||
: undefined),
|
||||
imageOnly: mode === 'image-build',
|
||||
ref: options.get('--ref'),
|
||||
url: options.get('--url'),
|
||||
playwright: lock.packages['node_modules/@playwright/test'].version,
|
||||
});
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
fs.writeFileSync(path.join(output, 'devcontainer.json'), contents);
|
||||
fs.copyFileSync(
|
||||
path.join(__dirname, 'devcontainer-lock.json'),
|
||||
path.join(output, 'devcontainer-lock.json'),
|
||||
);
|
||||
console.log(
|
||||
'Generated .devcontainer/generated/devcontainer.json; select it when reopening in container. Regenerate after updating the main configuration.',
|
||||
);
|
||||
}
|
||||
286
.devcontainer/profiles.test.cjs
Normal file
286
.devcontainer/profiles.test.cjs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const ts = require('typescript');
|
||||
const { profile } = require('./profile.cjs');
|
||||
const { bootstrap } = require('./bootstrap-native.cjs');
|
||||
const source = fs.readFileSync(path.join(__dirname, 'devcontainer.json'), 'utf8');
|
||||
const parse = (text) => {
|
||||
const result = ts.parseConfigFileTextToJson('devcontainer.json', text);
|
||||
assert.equal(result.error, undefined);
|
||||
return result.config;
|
||||
};
|
||||
|
||||
test('native/UI profile inherits hooks, storage and defaults while changing workspace and browser setup', () => {
|
||||
const c = parse(
|
||||
profile(source, { native: true, ui: true, playwright: '1.63.0', ref: 'feature/test' }),
|
||||
);
|
||||
assert.match(c.workspaceMount, /type=volume/);
|
||||
assert.match(c.onCreateCommand, /bootstrap-native/);
|
||||
assert.equal(c.containerEnv.GITNEXUS_CLONE_REF, 'feature/test');
|
||||
assert.equal(c.build.args.PLAYWRIGHT_VERSION, '1.63.0');
|
||||
assert.equal(c.containerEnv.GITNEXUS_DEV_UI, '1');
|
||||
assert.ok(c.mounts.some((m) => m.includes('ms-playwright')));
|
||||
assert.ok(c.mounts.some((m) => m.includes('/home/node/.gitnexus')));
|
||||
assert.deepEqual(c.capAdd, ['SYS_ADMIN']);
|
||||
assert.equal(c.updateContentCommand, 'bash .devcontainer/install-deps.sh');
|
||||
});
|
||||
|
||||
test('image consumers retain configured features and runtime lifecycle', () => {
|
||||
for (const options of [{}, { native: true }, { ui: true }, { native: true, ui: true }]) {
|
||||
const image = 'ghcr.io/example/devcontainer@sha256:abcd';
|
||||
const c = parse(profile(source, { ...options, image }));
|
||||
assert.equal(c.build, undefined);
|
||||
assert.equal(c.image, image);
|
||||
assert.deepEqual(c.features, parse(source).features);
|
||||
assert.ok(c.features['ghcr.io/devcontainers/features/github-cli:1']);
|
||||
assert.equal(c.postCreateCommand, 'bash .devcontainer/post-create.sh');
|
||||
}
|
||||
});
|
||||
|
||||
test('image selection accepts short names, uppercase tags, ports and digests', () => {
|
||||
for (const image of [
|
||||
'a',
|
||||
'a:RC',
|
||||
'ghcr.io/example/image:ReleaseCandidate',
|
||||
'localhost:5000/example/image:ReleaseCandidate',
|
||||
`ghcr.io/example/image@sha256:${'a'.repeat(64)}`,
|
||||
`ghcr.io/example/image:RC@sha256:${'a'.repeat(64)}`,
|
||||
]) {
|
||||
assert.equal(parse(profile(source, { image })).image, image);
|
||||
}
|
||||
for (const image of [
|
||||
'--privileged',
|
||||
'image;bad',
|
||||
'image name',
|
||||
'image\nname',
|
||||
'$(id)',
|
||||
'"image"',
|
||||
])
|
||||
assert.throws(() => profile(source, { image }), /Invalid image reference/);
|
||||
});
|
||||
|
||||
test('published toolchain metadata excludes host mounts and lifecycle hooks', () => {
|
||||
const c = parse(profile(source, { imageOnly: true, ui: true, playwright: '1.63.0' }));
|
||||
assert.equal(c.mounts, undefined);
|
||||
assert.equal(c.updateContentCommand, undefined);
|
||||
assert.equal(c.containerEnv, undefined);
|
||||
assert.equal(c.build.args.INSTALL_BROWSER_DEPS, 'true');
|
||||
assert.ok(c.features['ghcr.io/devcontainers/features/github-cli:1']);
|
||||
});
|
||||
|
||||
test('profile generator rejects unsafe values and changed canonical layout', () => {
|
||||
assert.throws(() => profile(source, { ref: '--upload-pack=bad' }), /Invalid/);
|
||||
assert.throws(() => profile(source, { image: 'image;bad' }), /Invalid/);
|
||||
assert.throws(
|
||||
() => profile(source, { imageOnly: true, image: 'ghcr.io/example/image' }),
|
||||
/--image cannot/,
|
||||
);
|
||||
for (const url of ['--upload-pack=bad', 'ext::command', 'https://token@github.com/user/repo.git'])
|
||||
assert.throws(() => profile(source, { native: true, url }), /Invalid clone URL/);
|
||||
assert.throws(
|
||||
() => profile(source.replace('"context": "."', '"context": "elsewhere"')),
|
||||
/layout changed/,
|
||||
);
|
||||
});
|
||||
|
||||
test('native branch validation rejects invalid Git refs before generation', () => {
|
||||
for (const ref of [
|
||||
'foo..',
|
||||
'topic/.hidden',
|
||||
'topic/name.lock',
|
||||
'topic/name.lock/child',
|
||||
'topic//child',
|
||||
'topic/name.',
|
||||
'topic/',
|
||||
'HEAD',
|
||||
]) {
|
||||
assert.notEqual(spawnSync('git', ['check-ref-format', '--branch', ref]).status, 0);
|
||||
assert.throws(() => profile(source, { native: true, ref }), /Invalid clone branch/);
|
||||
}
|
||||
for (const ref of [
|
||||
'main',
|
||||
'feature/fix-123',
|
||||
'release/v1.2.3',
|
||||
'topic/name.locked',
|
||||
'topic/name.LOCK',
|
||||
]) {
|
||||
assert.equal(spawnSync('git', ['check-ref-format', '--branch', ref]).status, 0);
|
||||
assert.equal(
|
||||
parse(profile(source, { native: true, ref })).containerEnv.GITNEXUS_CLONE_REF,
|
||||
ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('UI variants preserve empty, custom, and absent capability selections', () => {
|
||||
for (const capabilities of [[], ['SYS_PTRACE'], undefined]) {
|
||||
const customized = source.replace(
|
||||
' "capAdd": ["SYS_ADMIN"],',
|
||||
capabilities === undefined ? '' : ` "capAdd": ${JSON.stringify(capabilities)},`,
|
||||
);
|
||||
for (const options of [{}, { native: true }, { image: 'ghcr.io/example/devcontainer:ui' }]) {
|
||||
const c = parse(profile(customized, { ...options, ui: true, playwright: '1.63.0' }));
|
||||
assert.deepEqual(c.capAdd, capabilities);
|
||||
assert.deepEqual(c.runArgs, ['--shm-size=1g']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('native profiles accept fork remotes over HTTPS and Git SSH', () => {
|
||||
for (const url of [
|
||||
'https://github.com/contributor/GitNexus.git',
|
||||
'git@github.com:contributor/GitNexus.git',
|
||||
'ssh://git@example.com:2222/contributor/GitNexus.git',
|
||||
]) {
|
||||
const c = parse(profile(source, { native: true, url, ref: 'feature/fork-only' }));
|
||||
assert.equal(c.containerEnv.GITNEXUS_CLONE_URL, url);
|
||||
assert.equal(c.containerEnv.GITNEXUS_CLONE_REF, 'feature/fork-only');
|
||||
}
|
||||
});
|
||||
|
||||
test('profile CLI validates combinations before writing and generates fork configuration', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-profile-cli-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const config = path.join(root, '.devcontainer');
|
||||
fs.mkdirSync(config);
|
||||
for (const file of ['profile.cjs', 'devcontainer.json', 'devcontainer-lock.json'])
|
||||
fs.copyFileSync(path.join(__dirname, file), path.join(config, file));
|
||||
fs.mkdirSync(path.join(root, 'gitnexus-web'));
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'gitnexus-web/package-lock.json'),
|
||||
JSON.stringify({
|
||||
packages: { 'node_modules/@playwright/test': { version: '1.63.0' } },
|
||||
}),
|
||||
);
|
||||
const run = (...args) =>
|
||||
spawnSync(process.execPath, [path.join(config, 'profile.cjs'), ...args], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
for (const [args, error] of [
|
||||
[
|
||||
['image-build', '--image', 'ghcr.io/example/image'],
|
||||
/--image cannot be used with image-build/,
|
||||
],
|
||||
[['native', '--url'], /--url requires a value/],
|
||||
[['native', '--ref', '--ui'], /--ref requires a value/],
|
||||
[['prebuilt', '--url', 'https://github.com/user/repo.git'], /require the native profile/],
|
||||
[['native', '--ref', 'main', '--ref', 'other'], /Duplicate profile argument/],
|
||||
[['native', '--ref', 'foo..'], /Invalid clone branch/],
|
||||
]) {
|
||||
const result = run(...args);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, error);
|
||||
assert.equal(fs.existsSync(path.join(config, 'generated')), false);
|
||||
}
|
||||
const result = run(
|
||||
'native',
|
||||
'--url',
|
||||
'https://github.com/contributor/GitNexus.git',
|
||||
'--ref',
|
||||
'feature/fork-only',
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const c = parse(fs.readFileSync(path.join(config, 'generated/devcontainer.json'), 'utf8'));
|
||||
assert.equal(c.containerEnv.GITNEXUS_CLONE_URL, 'https://github.com/contributor/GitNexus.git');
|
||||
assert.equal(c.containerEnv.GITNEXUS_CLONE_REF, 'feature/fork-only');
|
||||
for (const args of [
|
||||
['prebuilt'],
|
||||
['prebuilt', '--ui'],
|
||||
['native', '--image', 'a'],
|
||||
['ui', '--image', 'ghcr.io/example/image:ReleaseCandidate'],
|
||||
]) {
|
||||
const result = run(...args);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const generated = parse(
|
||||
fs.readFileSync(path.join(config, 'generated/devcontainer.json'), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(generated.features, parse(source).features);
|
||||
if (args.includes('--image')) assert.equal(generated.image, args.at(-1));
|
||||
}
|
||||
});
|
||||
|
||||
test('native workspace preserves edits, rejects a different origin and resumes interrupted fetches', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-native-profile-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const origin = path.join(root, 'origin');
|
||||
fs.mkdirSync(origin);
|
||||
const git = (...args) => execFileSync('git', args, { cwd: origin, stdio: 'pipe' });
|
||||
git('init', '-b', 'main');
|
||||
git('config', 'user.name', 'Profile test');
|
||||
git('config', 'user.email', 'profile@example.invalid');
|
||||
git('config', 'commit.gpgsign', 'false');
|
||||
git('config', 'core.hooksPath', '/dev/null');
|
||||
fs.writeFileSync(path.join(origin, 'file'), 'source');
|
||||
git('add', 'file');
|
||||
git('commit', '-m', 'fixture');
|
||||
const checkout = path.join(root, 'checkout');
|
||||
fs.mkdirSync(path.join(checkout, 'node_modules'), { recursive: true });
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
const ownershipLog = path.join(root, 'ownership.json');
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'sudo'),
|
||||
`#!${process.execPath}\nrequire('node:fs').writeFileSync(${JSON.stringify(ownershipLog)}, JSON.stringify(process.argv.slice(2)));\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(__dirname, 'bootstrap-native.cjs'), checkout],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
GITNEXUS_CLONE_URL: origin,
|
||||
GITNEXUS_CLONE_REF: 'main',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const ownership = JSON.parse(fs.readFileSync(ownershipLog, 'utf8'));
|
||||
assert.deepEqual(ownership.slice(0, 4), ['-n', 'find', checkout, '-xdev']);
|
||||
assert.ok(ownership.includes('-h'));
|
||||
const checkoutGit = (...args) =>
|
||||
execFileSync('git', args, { cwd: checkout, encoding: 'utf8' }).trim();
|
||||
assert.equal(checkoutGit('rev-parse', '--abbrev-ref', '@{upstream}'), 'origin/main');
|
||||
assert.match(checkoutGit('pull'), /Already up to date/);
|
||||
fs.writeFileSync(path.join(checkout, 'file'), 'local edits');
|
||||
bootstrap(checkout, origin, 'main');
|
||||
assert.equal(fs.readFileSync(path.join(checkout, 'file'), 'utf8'), 'local edits');
|
||||
assert.throws(() => bootstrap(checkout, path.join(root, 'other'), 'main'), /another origin/);
|
||||
const retry = path.join(root, 'retry');
|
||||
assert.throws(() => bootstrap(retry, origin, 'new-branch'));
|
||||
git('branch', 'new-branch');
|
||||
bootstrap(retry, origin, 'new-branch');
|
||||
assert.equal(fs.readFileSync(path.join(retry, 'file'), 'utf8'), 'source');
|
||||
assert.equal(
|
||||
execFileSync('git', ['rev-parse', '--abbrev-ref', '@{upstream}'], {
|
||||
cwd: retry,
|
||||
encoding: 'utf8',
|
||||
}).trim(),
|
||||
'origin/new-branch',
|
||||
);
|
||||
const conflict = path.join(root, 'conflict');
|
||||
fs.mkdirSync(conflict);
|
||||
fs.writeFileSync(path.join(conflict, 'file'), 'pre-existing user file');
|
||||
assert.throws(() => bootstrap(conflict, origin, 'main'));
|
||||
assert.equal(fs.readFileSync(path.join(conflict, 'file'), 'utf8'), 'pre-existing user file');
|
||||
const ignored = path.join(root, 'ignored-conflict');
|
||||
fs.mkdirSync(ignored);
|
||||
execFileSync('git', ['init', '-q'], { cwd: ignored });
|
||||
fs.writeFileSync(path.join(ignored, '.git/info/exclude'), 'file\n');
|
||||
fs.writeFileSync(path.join(ignored, 'file'), 'ignored user file');
|
||||
assert.throws(() => bootstrap(ignored, origin, 'main'));
|
||||
assert.equal(fs.readFileSync(path.join(ignored, 'file'), 'utf8'), 'ignored user file');
|
||||
// Preserve the user's file elsewhere, then verify the same checkout can resume.
|
||||
fs.renameSync(path.join(ignored, 'file'), path.join(ignored, 'saved-user-file'));
|
||||
bootstrap(ignored, origin, 'main');
|
||||
assert.equal(fs.readFileSync(path.join(ignored, 'file'), 'utf8'), 'source');
|
||||
assert.equal(fs.readFileSync(path.join(ignored, 'saved-user-file'), 'utf8'), 'ignored user file');
|
||||
});
|
||||
33
.devcontainer/resources.cjs
Normal file
33
.devcontainer/resources.cjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use strict';
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
|
||||
function read(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8').trim();
|
||||
} catch {
|
||||
return 'unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
function workerBudget({
|
||||
memory = os.totalmem(),
|
||||
parallelism = os.availableParallelism(),
|
||||
memoryMax = read('/sys/fs/cgroup/memory.max'),
|
||||
cpuMax = read('/sys/fs/cgroup/cpu.max'),
|
||||
override = process.env.GITNEXUS_DEV_TEST_WORKERS,
|
||||
} = {}) {
|
||||
if (override !== undefined && override !== '') {
|
||||
if (!/^[1-9][0-9]?$/.test(override))
|
||||
throw new Error('GITNEXUS_DEV_TEST_WORKERS must be an integer from 1 to 99');
|
||||
return Number(override);
|
||||
}
|
||||
if (/^[0-9]+$/.test(memoryMax)) memory = Math.min(memory, Number(memoryMax));
|
||||
const [quota, period] = cpuMax.split(' ').map(Number);
|
||||
if (Number.isFinite(quota) && quota > 0 && period > 0)
|
||||
parallelism = Math.min(parallelism, Math.max(1, Math.floor(quota / period)));
|
||||
// Leave room for the editor/API and native allocations outside Node's heap.
|
||||
return Math.max(1, Math.min(4, parallelism - 1, Math.floor(memory / 2 ** 30) - 1));
|
||||
}
|
||||
|
||||
module.exports = { read, workerBudget };
|
||||
25
.devcontainer/resources.test.cjs
Normal file
25
.devcontainer/resources.test.cjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { workerBudget } = require('./resources.cjs');
|
||||
|
||||
test('worker budget respects CPU quota, memory allowance and interactive headroom', () => {
|
||||
const limits = {
|
||||
memory: 32 * 2 ** 30,
|
||||
parallelism: 32,
|
||||
override: '',
|
||||
memoryMax: 'max',
|
||||
cpuMax: 'max 100000',
|
||||
};
|
||||
assert.equal(workerBudget(limits), 4);
|
||||
assert.equal(workerBudget({ ...limits, cpuMax: '200000 100000' }), 1);
|
||||
assert.equal(workerBudget({ ...limits, memoryMax: String(3 * 2 ** 30) }), 2);
|
||||
assert.equal(workerBudget({ ...limits, memoryMax: '100000000', cpuMax: '50000 100000' }), 1);
|
||||
assert.equal(workerBudget({ ...limits, memoryMax: 'unavailable', cpuMax: 'unavailable' }), 4);
|
||||
});
|
||||
|
||||
test('worker override is explicit and validated', () => {
|
||||
assert.equal(workerBudget({ override: '2' }), 2);
|
||||
for (const override of ['0', '-1', 'abc', '1;bad', '100'])
|
||||
assert.throws(() => workerBudget({ override }), /integer/);
|
||||
});
|
||||
25
.devcontainer/seed-codex.sh
Normal file
25
.devcontainer/seed-codex.sh
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
# Restore the image's standalone Codex package into its mounted config volume.
|
||||
# Optional paths allow migration tests to use isolated temporary directories.
|
||||
set -euo pipefail
|
||||
|
||||
seed=${1:-/home/node/.local/share/codex-standalone}
|
||||
standalone=${2:-/home/node/.codex/packages/standalone}
|
||||
command=${3:-/home/node/.local/bin/codex}
|
||||
|
||||
if [ -x "$command" ] && "$command" --version >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[post-create] restoring standalone Codex into its config volume"
|
||||
test -d "$seed/releases"
|
||||
test -L "$seed/current"
|
||||
mkdir -p "$standalone/releases"
|
||||
# Copy only packages; credentials, sessions and user configuration stay intact.
|
||||
# Publish `current` after copying so an interrupted copy can be retried.
|
||||
cp -r "$seed/releases/." "$standalone/releases/"
|
||||
if [ -f "$seed/auto-update-version" ]; then
|
||||
cp "$seed/auto-update-version" "$standalone/auto-update-version"
|
||||
fi
|
||||
ln -sfn "$(readlink "$seed/current")" "$standalone/current"
|
||||
"$command" --version
|
||||
88
.devcontainer/seed-codex.test.cjs
Normal file
88
.devcontainer/seed-codex.test.cjs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-codex-seed-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const seed = path.join(root, 'image-seed');
|
||||
const config = path.join(root, 'config-volume');
|
||||
const standalone = path.join(config, 'packages/standalone');
|
||||
const command = path.join(root, 'codex');
|
||||
const release = 'image-release';
|
||||
const bin = path.join(seed, 'releases', release, 'bin/codex');
|
||||
fs.mkdirSync(path.dirname(bin), { recursive: true });
|
||||
fs.writeFileSync(bin, '#!/bin/sh\necho codex-image\n', { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(seed, 'auto-update-version'), release);
|
||||
// Match the official installer's absolute symlinks, including the launcher
|
||||
// that becomes dangling when Docker mounts an existing config volume.
|
||||
fs.symlinkSync(path.join(standalone, 'releases', release), path.join(seed, 'current'));
|
||||
fs.symlinkSync(path.join(standalone, 'current/bin/codex'), command);
|
||||
const run = () =>
|
||||
spawnSync('bash', [path.join(__dirname, 'seed-codex.sh'), seed, standalone, command], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return { root, seed, config, standalone, command, bin, run };
|
||||
}
|
||||
|
||||
test('restores native Codex into an empty mounted config volume', (t) => {
|
||||
const f = fixture(t);
|
||||
const result = f.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /codex-image/);
|
||||
assert.equal(fs.statSync(f.command).mode & 0o111, 0o111);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(f.standalone, 'auto-update-version'), 'utf8'),
|
||||
'image-release',
|
||||
);
|
||||
});
|
||||
|
||||
test('migrates a reused config volume without changing its state', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(path.join(f.config, 'sessions'), { recursive: true });
|
||||
for (const file of ['auth.json', 'config.toml', 'sessions/rollout.jsonl'])
|
||||
fs.writeFileSync(path.join(f.config, file), `fixture-${file}`);
|
||||
const result = f.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
for (const file of ['auth.json', 'config.toml', 'sessions/rollout.jsonl'])
|
||||
assert.equal(fs.readFileSync(path.join(f.config, file), 'utf8'), `fixture-${file}`);
|
||||
});
|
||||
|
||||
test('keeps a working container-managed release without reading the image seed', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.run().status, 0);
|
||||
const custom = path.join(f.standalone, 'releases/custom/bin');
|
||||
fs.mkdirSync(custom, { recursive: true });
|
||||
fs.writeFileSync(path.join(custom, 'codex'), '#!/bin/sh\necho codex-custom\n', { mode: 0o755 });
|
||||
fs.unlinkSync(path.join(f.standalone, 'current'));
|
||||
fs.symlinkSync(path.dirname(custom), path.join(f.standalone, 'current'));
|
||||
fs.rmSync(f.seed, { recursive: true });
|
||||
const result = f.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.readlinkSync(path.join(f.standalone, 'current')), path.dirname(custom));
|
||||
assert.equal(result.stdout, '');
|
||||
});
|
||||
|
||||
test('repairs an installed release that fails its version check', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.run().status, 0);
|
||||
fs.writeFileSync(path.join(f.standalone, 'current/bin/codex'), '#!/bin/sh\nexit 1\n');
|
||||
const result = f.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /codex-image/);
|
||||
});
|
||||
|
||||
test('fails for an incomplete seed and succeeds after it is repaired', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.unlinkSync(f.bin);
|
||||
assert.notEqual(f.run().status, 0);
|
||||
fs.writeFileSync(f.bin, '#!/bin/sh\necho codex-image\n', { mode: 0o755 });
|
||||
const result = f.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /codex-image/);
|
||||
});
|
||||
64
.devcontainer/tune-9p.sh
Normal file
64
.devcontainer/tune-9p.sh
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env bash
|
||||
# Best-effort 9p remount, enabled by default. Requires SYS_ADMIN; failures never
|
||||
# block startup. A successful mount command does not prove msize changed.
|
||||
set -u
|
||||
|
||||
if [ "${WORKSPACE_9P_TUNE:-1}" != "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TARGET_MSIZE="${WORKSPACE_9P_MSIZE:-1048576}"
|
||||
MOUNTPOINT="${1:-/workspace}"
|
||||
if ! [[ "$TARGET_MSIZE" =~ ^[1-9][0-9]{0,9}$ ]] || (( TARGET_MSIZE > 2147483647 )); then
|
||||
echo "[tune-9p] invalid WORKSPACE_9P_MSIZE; use an integer from 1 to 2147483647"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v findmnt >/dev/null 2>&1; then
|
||||
echo "[tune-9p] findmnt unavailable; skipping"
|
||||
exit 0
|
||||
fi
|
||||
# Match the mountpoint itself, not the containing filesystem for an arbitrary
|
||||
# directory. This avoids attempting a remount of the container root.
|
||||
fstype=$(findmnt -rn --first-only --mountpoint "$MOUNTPOINT" -o FSTYPE 2>/dev/null || true)
|
||||
if [ "$fstype" != "9p" ]; then
|
||||
echo "[tune-9p] ${MOUNTPOINT} is ${fstype:-unknown}, not 9p; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! opts=$(findmnt -rn --first-only --mountpoint "$MOUNTPOINT" -o OPTIONS 2>/dev/null); then
|
||||
echo "[tune-9p] cannot read mount options; skipping"
|
||||
exit 0
|
||||
fi
|
||||
current=""
|
||||
if [[ ",$opts," =~ ,msize=([1-9][0-9]{0,9}), ]]; then
|
||||
current="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "[tune-9p] ${MOUNTPOINT} 9p msize=${current:-unknown} (target ${TARGET_MSIZE})"
|
||||
if [ -n "$current" ] && (( current >= TARGET_MSIZE )); then
|
||||
echo "[tune-9p] already at or above target"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v sudo >/dev/null 2>&1 || ! command -v timeout >/dev/null 2>&1; then
|
||||
echo "[tune-9p] sudo or timeout unavailable; skipping"
|
||||
exit 0
|
||||
fi
|
||||
if ! timeout --kill-after=1s 5s sudo -n mount -o "remount,msize=${TARGET_MSIZE}" "$MOUNTPOINT"; then
|
||||
echo "[tune-9p] remount failed or timed out; SYS_ADMIN may be missing, or the kernel/server rejected it"
|
||||
echo "[tune-9p] rebuild the devcontainer to apply its capAdd: [SYS_ADMIN] setting"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
newopts=$(findmnt -rn --first-only --mountpoint "$MOUNTPOINT" -o OPTIONS 2>/dev/null || true)
|
||||
new=""
|
||||
if [[ ",$newopts," =~ ,msize=([1-9][0-9]{0,9}), ]]; then
|
||||
new="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "[tune-9p] after remount msize=${new:-unknown}"
|
||||
if [ -n "$new" ] && (( new >= TARGET_MSIZE )); then
|
||||
echo "[tune-9p] verified at or above target"
|
||||
else
|
||||
echo "[tune-9p] remount returned success but no increase to the target was verified; the kernel or server may ignore or limit msize"
|
||||
fi
|
||||
exit 0
|
||||
163
.devcontainer/tune-9p.test.cjs
Normal file
163
.devcontainer/tune-9p.test.cjs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-tune-9p-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const log = path.join(root, 'mount-args');
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'findmnt'),
|
||||
`#!/bin/bash
|
||||
printf '%s\\n' "$@" >> "$TUNE_TEST_ROOT/findmnt-args"
|
||||
[ "$MOCK_FINDMNT_FAIL" = 0 ] || exit 1
|
||||
case "$*" in
|
||||
*FSTYPE) printf '%s\\n' "$MOCK_FSTYPE" ;;
|
||||
*OPTIONS)
|
||||
[ "$MOCK_OPTIONS_FAIL" = 0 ] || exit 1
|
||||
if [ -f "$TUNE_TEST_ROOT/remounted" ]; then
|
||||
printf '%s\\n' "$MOCK_AFTER_OPTIONS"
|
||||
else
|
||||
printf '%s\\n' "$MOCK_OPTIONS"
|
||||
fi ;;
|
||||
esac
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
// Never execute mount: record argv and simulate its result, even if tests
|
||||
// happen to run with SYS_ADMIN on a developer's machine.
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'sudo'),
|
||||
`#!/bin/bash
|
||||
printf '%s\\n' "$@" > "$TUNE_TEST_ROOT/mount-args"
|
||||
if [ "$MOCK_HANG" = 1 ]; then sleep 30; fi
|
||||
[ "$MOCK_REMOUNT_EXIT" = 0 ] || exit "$MOCK_REMOUNT_EXIT"
|
||||
touch "$TUNE_TEST_ROOT/remounted"
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${root}:${process.env.PATH}`,
|
||||
TUNE_TEST_ROOT: root,
|
||||
WORKSPACE_9P_TUNE: '1',
|
||||
WORKSPACE_9P_MSIZE: '1048576',
|
||||
MOCK_FSTYPE: '9p',
|
||||
MOCK_OPTIONS: 'rw,relatime,msize=65536,trans=virtio',
|
||||
MOCK_AFTER_OPTIONS: 'rw,msize=1048576,trans=virtio',
|
||||
MOCK_REMOUNT_EXIT: '0',
|
||||
MOCK_FINDMNT_FAIL: '0',
|
||||
MOCK_OPTIONS_FAIL: '0',
|
||||
MOCK_HANG: '0',
|
||||
...overrides,
|
||||
};
|
||||
const run = (...args) => {
|
||||
const result = spawnSync('/bin/bash', [path.join(__dirname, 'tune-9p.sh'), ...args], {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
});
|
||||
assert.equal(result.error, undefined, String(result.error));
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return result.stdout;
|
||||
};
|
||||
return { root, log, run };
|
||||
}
|
||||
|
||||
test('tuning is enabled when WORKSPACE_9P_TUNE is unset', (t) => {
|
||||
const f = fixture(t, { WORKSPACE_9P_TUNE: undefined });
|
||||
assert.match(f.run(), /verified at or above target/);
|
||||
assert.ok(fs.existsSync(f.log));
|
||||
});
|
||||
|
||||
test('disabled tuning does not inspect or remount anything', (t) => {
|
||||
const f = fixture(t, { WORKSPACE_9P_TUNE: '0' });
|
||||
assert.equal(f.run(), '');
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'findmnt-args')), false);
|
||||
assert.equal(fs.existsSync(f.log), false);
|
||||
});
|
||||
|
||||
for (const fstype of ['ext4', 'virtiofs', 'overlay']) {
|
||||
test(`skips a ${fstype} workspace without remounting`, (t) => {
|
||||
const f = fixture(t, { MOCK_FSTYPE: fstype });
|
||||
assert.match(f.run(), /not 9p/);
|
||||
assert.equal(fs.existsSync(f.log), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('failed mount lookup does not block startup or remount a containing filesystem', (t) => {
|
||||
const f = fixture(t, { MOCK_FINDMNT_FAIL: '1' });
|
||||
assert.match(f.run('/workspace/subdirectory'), /unknown/);
|
||||
assert.equal(fs.existsSync(f.log), false);
|
||||
const args = fs.readFileSync(path.join(f.root, 'findmnt-args'), 'utf8');
|
||||
assert.match(args, /--mountpoint\n\/workspace\/subdirectory\n/);
|
||||
});
|
||||
|
||||
for (const msize of ['1048576', '2097152']) {
|
||||
test(`keeps an existing msize of ${msize}`, (t) => {
|
||||
const f = fixture(t, { MOCK_OPTIONS: `rw,msize=${msize}` });
|
||||
assert.match(f.run(), /already at or above target/);
|
||||
assert.equal(fs.existsSync(f.log), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('uses non-interactive sudo, honors custom size/path and verifies the result', (t) => {
|
||||
const f = fixture(t, {
|
||||
WORKSPACE_9P_MSIZE: '2097152',
|
||||
MOCK_AFTER_OPTIONS: 'rw,msize=2097152',
|
||||
});
|
||||
assert.match(f.run('/workspace/project with spaces'), /verified at or above target/);
|
||||
assert.deepEqual(fs.readFileSync(f.log, 'utf8').trimEnd().split('\n'), [
|
||||
'-n',
|
||||
'mount',
|
||||
'-o',
|
||||
'remount,msize=2097152',
|
||||
'/workspace/project with spaces',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a denied remount is non-fatal and explains capability/recreation requirements', (t) => {
|
||||
const f = fixture(t, { MOCK_REMOUNT_EXIT: '1' });
|
||||
const output = f.run();
|
||||
assert.match(output, /remount failed/);
|
||||
assert.match(output, /SYS_ADMIN/);
|
||||
assert.match(output, /rebuild the devcontainer/);
|
||||
});
|
||||
|
||||
test('successful remount with unchanged msize does not claim a verified increase', (t) => {
|
||||
const f = fixture(t, { MOCK_AFTER_OPTIONS: 'rw,msize=65536' });
|
||||
const output = f.run();
|
||||
assert.match(output, /no increase to the target was verified/);
|
||||
assert.doesNotMatch(output, /verified at or above target/);
|
||||
});
|
||||
|
||||
test('unreadable mount options skip remounting', (t) => {
|
||||
const f = fixture(t, { MOCK_OPTIONS_FAIL: '1' });
|
||||
assert.match(f.run(), /cannot read mount options/);
|
||||
assert.equal(fs.existsSync(f.log), false);
|
||||
});
|
||||
|
||||
test('missing msize is reported as unknown without breaking startup', (t) => {
|
||||
const f = fixture(t, { MOCK_OPTIONS: 'rw', MOCK_AFTER_OPTIONS: 'rw' });
|
||||
assert.match(f.run(), /after remount msize=unknown/);
|
||||
});
|
||||
|
||||
for (const msize of ['0', '-1', 'abc', '1;exit 1', '999999999999999999999999']) {
|
||||
test(`invalid msize ${JSON.stringify(msize)} is non-fatal and never reaches mount`, (t) => {
|
||||
const f = fixture(t, { WORKSPACE_9P_MSIZE: msize });
|
||||
assert.match(f.run(), /invalid WORKSPACE_9P_MSIZE/);
|
||||
assert.equal(fs.existsSync(f.log), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('a hanging remount times out without blocking startup', (t) => {
|
||||
const f = fixture(t, { MOCK_HANG: '1' });
|
||||
const start = Date.now();
|
||||
assert.match(f.run(), /remount failed or timed out/);
|
||||
assert.ok(Date.now() - start < 9000, 'remount should be bounded by timeout');
|
||||
});
|
||||
63
.devcontainer/verify-git-hooks.cjs
Normal file
63
.devcontainer/verify-git-hooks.cjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
function verifyGitHooks(root = process.cwd()) {
|
||||
root = path.resolve(root);
|
||||
if (process.env.HUSKY === '0') throw new Error('HUSKY=0 disables pre-commit hooks.');
|
||||
|
||||
const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
if (path.resolve(root, hooksPath) !== path.join(root, '.husky', '_')) {
|
||||
throw new Error('core.hooksPath must point to .husky/_.');
|
||||
}
|
||||
|
||||
for (const [file, mode] of [
|
||||
['.husky/pre-commit', fs.constants.R_OK],
|
||||
['.husky/_/h', fs.constants.R_OK],
|
||||
['.husky/_/pre-commit', fs.constants.R_OK | fs.constants.X_OK],
|
||||
]) {
|
||||
const target = path.join(root, file);
|
||||
if (!fs.statSync(target).isFile()) throw new Error(`${file} must be a file.`);
|
||||
fs.accessSync(target, mode);
|
||||
}
|
||||
|
||||
// Exercise the installed Husky helper, including its init.sh, against a
|
||||
// temporary sentinel hook. Never run the real pre-commit or touch the index.
|
||||
const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-probe-'));
|
||||
try {
|
||||
const token = randomUUID();
|
||||
fs.mkdirSync(path.join(probe, '_'));
|
||||
fs.writeFileSync(path.join(probe, 'pre-commit'), `printf '%s\\n' '${token}'\n`);
|
||||
const output = execFileSync(
|
||||
'sh',
|
||||
['-c', '. "$1"', path.join(probe, '_/pre-commit'), path.join(root, '.husky/_/h')],
|
||||
{ cwd: root, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
if (!output.includes(token)) {
|
||||
throw new Error('Husky startup configuration disables or skips pre-commit hooks.');
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(probe, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifyGitHooks };
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
verifyGitHooks();
|
||||
console.log('[install-deps] Husky pre-commit hook is installed and enabled');
|
||||
} catch (error) {
|
||||
console.error(`[install-deps] Husky verification failed: ${error.message}`);
|
||||
console.error('Run npm run prepare at the repository root and keep HUSKY enabled.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
158
.devcontainer/verify-git-hooks.test.cjs
Normal file
158
.devcontainer/verify-git-hooks.test.cjs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
|
||||
const verifier = path.join(__dirname, 'verify-git-hooks.cjs');
|
||||
const husky = path.resolve(__dirname, '../node_modules/husky/bin.js');
|
||||
|
||||
function fixture(t, install = true) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-husky-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const globalConfig = path.join(root, 'global-gitconfig');
|
||||
fs.writeFileSync(globalConfig, '');
|
||||
const env = {
|
||||
...process.env,
|
||||
HUSKY: '',
|
||||
GIT_CONFIG_GLOBAL: globalConfig,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_COUNT: '0',
|
||||
XDG_CONFIG_HOME: path.join(root, 'xdg'),
|
||||
};
|
||||
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'])
|
||||
delete env[key];
|
||||
const options = { cwd: root, env, encoding: 'utf8' };
|
||||
const git = (...args) => execFileSync('git', args, options);
|
||||
git('init', '-q');
|
||||
git('config', 'user.name', 'Husky test');
|
||||
git('config', 'user.email', 'husky-test@example.invalid');
|
||||
fs.mkdirSync(path.join(root, '.husky'));
|
||||
fs.copyFileSync(
|
||||
path.resolve(__dirname, '../.husky/pre-commit'),
|
||||
path.join(root, '.husky/pre-commit'),
|
||||
);
|
||||
if (install) execFileSync(process.execPath, [husky], options);
|
||||
const verify = (overrides = {}) =>
|
||||
spawnSync(process.execPath, [verifier], { ...options, env: { ...env, ...overrides } });
|
||||
const commit = () => spawnSync('git', ['commit', '--allow-empty', '-m', 'hook test'], options);
|
||||
const tool = (file, status) => {
|
||||
const target = path.join(root, file);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, `#!/bin/sh\nprintf called > "$0.called"\nexit ${status}\n`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
return `${target}.called`;
|
||||
};
|
||||
return { root, git, verify, commit, tool };
|
||||
}
|
||||
|
||||
test('Husky verification accepts a real installation', (t) => {
|
||||
const repo = fixture(t);
|
||||
const result = repo.verify();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /installed and enabled/);
|
||||
});
|
||||
|
||||
test('Husky verification rejects missing Git hook configuration', (t) => {
|
||||
const repo = fixture(t, false);
|
||||
assert.equal(repo.verify().status, 1);
|
||||
});
|
||||
|
||||
test('Husky verification rejects a cleared cache without removing the tracked hook', (t) => {
|
||||
const repo = fixture(t);
|
||||
fs.rmSync(path.join(repo.root, '.husky/_'), { recursive: true });
|
||||
assert.equal(repo.verify().status, 1);
|
||||
assert.ok(fs.existsSync(path.join(repo.root, '.husky/pre-commit')));
|
||||
});
|
||||
|
||||
test('Husky verification rejects hooks redirected elsewhere', (t) => {
|
||||
const repo = fixture(t);
|
||||
repo.git('config', 'core.hooksPath', 'other-hooks');
|
||||
assert.equal(repo.verify().status, 1);
|
||||
});
|
||||
|
||||
test('Husky verification rejects a missing tracked pre-commit script', (t) => {
|
||||
const repo = fixture(t);
|
||||
fs.rmSync(path.join(repo.root, '.husky/pre-commit'));
|
||||
assert.equal(repo.verify().status, 1);
|
||||
});
|
||||
|
||||
test('Husky verification rejects a non-executable Git hook', (t) => {
|
||||
const repo = fixture(t);
|
||||
fs.chmodSync(path.join(repo.root, '.husky/_/pre-commit'), 0o644);
|
||||
assert.equal(repo.verify().status, 1);
|
||||
});
|
||||
|
||||
test('Husky verification rejects an explicitly disabled hook', (t) => {
|
||||
const repo = fixture(t);
|
||||
const result = repo.verify({ HUSKY: '0' });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /HUSKY=0 disables/);
|
||||
});
|
||||
|
||||
for (const init of ['export HUSKY=0\n', 'exit 0\n']) {
|
||||
test(`Husky verification rejects startup configuration ${init.trim()}`, (t) => {
|
||||
const repo = fixture(t);
|
||||
const marker = repo.tool('node_modules/.bin/lint-staged', 1);
|
||||
fs.mkdirSync(path.join(repo.root, 'xdg/husky'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.root, 'xdg/husky/init.sh'), init);
|
||||
const result = repo.verify();
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /startup configuration disables or skips/);
|
||||
// Demonstrate why the verifier must fail: real Git would skip the formatter.
|
||||
assert.equal(repo.commit().status, 0);
|
||||
assert.equal(fs.existsSync(marker), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('Husky probe accepts enabled startup configuration without running the tracked hook', (t) => {
|
||||
const repo = fixture(t);
|
||||
const marker = repo.tool('node_modules/.bin/lint-staged', 1);
|
||||
fs.mkdirSync(path.join(repo.root, 'xdg/husky'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.root, 'xdg/husky/init.sh'), 'export HUSKY=1\nprintf startup\n');
|
||||
assert.equal(repo.verify().status, 0);
|
||||
assert.equal(fs.existsSync(marker), false);
|
||||
assert.equal(repo.commit().status, 1);
|
||||
assert.equal(fs.existsSync(marker), true);
|
||||
});
|
||||
|
||||
test('Git rejects a commit when the actual pre-commit formatter fails', (t) => {
|
||||
const repo = fixture(t);
|
||||
const marker = repo.tool('node_modules/.bin/lint-staged', 1);
|
||||
assert.equal(repo.verify().status, 0);
|
||||
const result = repo.commit();
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stdout + result.stderr, /pre-commit: formatting staged files/);
|
||||
assert.equal(fs.readFileSync(marker, 'utf8'), 'called');
|
||||
assert.throws(() => repo.git('rev-parse', '--verify', 'HEAD'));
|
||||
});
|
||||
|
||||
for (const pkg of ['gitnexus', 'gitnexus-web']) {
|
||||
test(`Git rejects a commit when the actual ${pkg} pre-commit typecheck fails`, (t) => {
|
||||
const repo = fixture(t);
|
||||
repo.tool('node_modules/.bin/lint-staged', 0);
|
||||
const marker = repo.tool(`${pkg}/node_modules/.bin/tsc`, 1);
|
||||
fs.writeFileSync(path.join(repo.root, pkg, 'change.ts'), 'export const value = 1;\n');
|
||||
repo.git('add', `${pkg}/change.ts`);
|
||||
assert.equal(repo.verify().status, 0);
|
||||
const result = repo.commit();
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stdout + result.stderr, new RegExp(`pre-commit: typechecking ${pkg}`));
|
||||
assert.equal(fs.readFileSync(marker, 'utf8'), 'called');
|
||||
assert.throws(() => repo.git('rev-parse', '--verify', 'HEAD'));
|
||||
});
|
||||
}
|
||||
|
||||
test('Git accepts a commit when the actual pre-commit checks pass', (t) => {
|
||||
const repo = fixture(t);
|
||||
repo.tool('node_modules/.bin/lint-staged', 0);
|
||||
assert.equal(repo.verify().status, 0);
|
||||
const result = repo.commit();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout + result.stderr, /pre-commit: all checks passed/);
|
||||
assert.ok(repo.git('rev-parse', '--verify', 'HEAD').trim());
|
||||
});
|
||||
|
|
@ -14,6 +14,12 @@ WEB_CONTAINER_NAME=gitnexus-web
|
|||
SERVER_HOST_PORT=4747
|
||||
WEB_HOST_PORT=4173
|
||||
|
||||
# Where the browser reaches the API. Unset: http://localhost:4747. An absolute
|
||||
# URL for a server on another host; `/` behind a reverse proxy that serves the
|
||||
# API on the same origin as the UI (see README, "Same-origin API behind a
|
||||
# reverse proxy").
|
||||
# GITNEXUS_BACKEND_URL=/
|
||||
|
||||
# Optional read-only mount, exposed to the server as /workspace.
|
||||
# Override with the directory that contains the repos you want to index.
|
||||
WORKSPACE_DIR=./
|
||||
|
|
|
|||
53
.github/actions/download-test-artifact/action.yml
vendored
Normal file
53
.github/actions/download-test-artifact/action.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: Download test artifact
|
||||
description: Download a required artifact from this run with bounded transport retries
|
||||
|
||||
inputs:
|
||||
name:
|
||||
description: Exact artifact name to download from this workflow run
|
||||
required: true
|
||||
path:
|
||||
description: Destination directory
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# Restart the whole action after its internal retries are exhausted, so
|
||||
# each attempt obtains a fresh download URL for the same required artifact.
|
||||
- name: Download
|
||||
id: first
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.path }}
|
||||
digest-mismatch: error
|
||||
|
||||
- name: Wait before retry
|
||||
if: ${{ !cancelled() && steps.first.outcome == 'failure' }}
|
||||
shell: bash
|
||||
run: sleep 10
|
||||
|
||||
- name: Retry download
|
||||
id: second
|
||||
if: ${{ !cancelled() && steps.first.outcome == 'failure' }}
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.path }}
|
||||
digest-mismatch: error
|
||||
|
||||
- name: Wait before final attempt
|
||||
if: ${{ !cancelled() && steps.second.outcome == 'failure' }}
|
||||
shell: bash
|
||||
run: sleep 30
|
||||
|
||||
# Keep the final failure fatal: shards must never run without the snapshot.
|
||||
- name: Final download attempt
|
||||
if: ${{ !cancelled() && steps.second.outcome == 'failure' }}
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.path }}
|
||||
digest-mismatch: error
|
||||
56
.github/actions/ec2-runner/action.yml
vendored
Normal file
56
.github/actions/ec2-runner/action.yml
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
name: Dedicated evolution runner power
|
||||
description: Use the protected environment's existing OIDC role to start or stop its EC2 runner.
|
||||
inputs:
|
||||
operation:
|
||||
description: start or stop
|
||||
required: true
|
||||
role-arn:
|
||||
description: Existing scoped AWS OIDC role
|
||||
required: true
|
||||
instance-id:
|
||||
description: Existing dedicated EC2 instance
|
||||
required: true
|
||||
region:
|
||||
description: Instance region
|
||||
required: true
|
||||
stop-schedule:
|
||||
description: Existing EventBridge weekly stop in DAY HH:MM UTC format
|
||||
required: true
|
||||
outputs:
|
||||
cleanup_required:
|
||||
description: The instance was claimed and must be stopped, including on startup failure.
|
||||
value: ${{ steps.power.outputs.cleanup_required }}
|
||||
stop_deadline_epoch:
|
||||
description: Earliest configured stop or 24-hour cap, as Unix seconds.
|
||||
value: ${{ steps.power.outputs.stop_deadline_epoch }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check startup configuration and stop window
|
||||
if: inputs.operation == 'start'
|
||||
shell: bash
|
||||
env:
|
||||
GITNEXUS_EVOLUTION_AWS_ROLE_ARN: ${{ inputs.role-arn }}
|
||||
GITNEXUS_EVOLUTION_EC2_INSTANCE_ID: ${{ inputs.instance-id }}
|
||||
GITNEXUS_EVOLUTION_AWS_REGION: ${{ inputs.region }}
|
||||
GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC: ${{ inputs.stop-schedule }}
|
||||
run: python3 .github/scripts/evolution-runner.py check
|
||||
- name: Assume the existing protected AWS role
|
||||
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
|
||||
with:
|
||||
role-to-assume: ${{ inputs.role-arn }}
|
||||
aws-region: ${{ inputs.region }}
|
||||
role-duration-seconds: 1800
|
||||
mask-aws-account-id: true
|
||||
retry-max-attempts: 3
|
||||
unset-current-credentials: true
|
||||
- name: Change power and verify the resulting state
|
||||
id: power
|
||||
shell: bash
|
||||
env:
|
||||
GITNEXUS_EVOLUTION_AWS_ROLE_ARN: ${{ inputs.role-arn }}
|
||||
GITNEXUS_EVOLUTION_EC2_INSTANCE_ID: ${{ inputs.instance-id }}
|
||||
GITNEXUS_EVOLUTION_AWS_REGION: ${{ inputs.region }}
|
||||
GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC: ${{ inputs.stop-schedule }}
|
||||
# The expression emits only literal command names, never supplied text.
|
||||
run: python3 .github/scripts/evolution-runner.py ${{ inputs.operation == 'start' && 'start' || 'stop' }}
|
||||
42
.github/actions/require-agent-release-evidence/action.yml
vendored
Normal file
42
.github/actions/require-agent-release-evidence/action.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
name: Require paired agent release evidence
|
||||
description: Verify fresh agent evidence for the checked out commit, measured by main's current evaluator
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup locked evaluation validator
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
# Publishing must not restore caches populated by other runs.
|
||||
enable-cache: false
|
||||
# Release evaluation always measures with main's harness. Validate with that
|
||||
# evaluator, not the release commit's older eval/ tree; the validator then
|
||||
# rejects evidence whose harness differs from it in any evaluation input.
|
||||
- name: Load main's current release evaluator
|
||||
id: evaluator
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
evaluator="$RUNNER_TEMP/release-evaluator"
|
||||
rm -rf "$evaluator"
|
||||
mkdir -p "$evaluator"
|
||||
timeout 300 git fetch --no-tags --depth=1 origin refs/heads/main
|
||||
sha="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
git archive "$sha" eval | tar -x -C "$evaluator"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
- name: Require paired evidence for this exact commit
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVALUATOR_SHA: ${{ steps.evaluator.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
runtime_sha="$(git rev-parse HEAD)"
|
||||
cd "$RUNNER_TEMP/release-evaluator/eval"
|
||||
# Locked base dependencies only: no dev extras or project build in this privileged job.
|
||||
uv sync --locked --no-install-project
|
||||
uv run --no-sync python -m workflow_bench.release_evidence \
|
||||
--repo "$GITHUB_REPOSITORY" --runtime-sha "$runtime_sha" \
|
||||
--evaluator-sha "$EVALUATOR_SHA" --out "$RUNNER_TEMP/release-evidence"
|
||||
66
.github/actions/upload-test-artifact/action.yml
vendored
Normal file
66
.github/actions/upload-test-artifact/action.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
name: Upload test artifact
|
||||
description: Upload required CI evidence with bounded transport retries
|
||||
|
||||
inputs:
|
||||
name:
|
||||
description: Unique artifact name for this job
|
||||
required: true
|
||||
path:
|
||||
description: Files to upload
|
||||
required: true
|
||||
retention-days:
|
||||
description: Artifact retention in days
|
||||
default: '5'
|
||||
include-hidden-files:
|
||||
description: Include explicitly selected hidden report directories
|
||||
default: 'false'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Upload
|
||||
id: first
|
||||
if: ${{ always() }}
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.path }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
include-hidden-files: ${{ inputs.include-hidden-files }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Wait before retry
|
||||
if: ${{ !cancelled() && steps.first.outcome == 'failure' }}
|
||||
shell: bash
|
||||
run: sleep 10
|
||||
|
||||
- name: Retry upload
|
||||
id: second
|
||||
if: ${{ !cancelled() && steps.first.outcome == 'failure' }}
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.path }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
include-hidden-files: ${{ inputs.include-hidden-files }}
|
||||
if-no-files-found: error
|
||||
# A failed request may already have created an incomplete artifact.
|
||||
overwrite: true
|
||||
|
||||
- name: Wait before final attempt
|
||||
if: ${{ !cancelled() && steps.second.outcome == 'failure' }}
|
||||
shell: bash
|
||||
run: sleep 30
|
||||
|
||||
- name: Final upload attempt
|
||||
if: ${{ !cancelled() && steps.second.outcome == 'failure' }}
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.path }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
include-hidden-files: ${{ inputs.include-hidden-files }}
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -52,11 +52,11 @@ updates:
|
|||
- ci
|
||||
|
||||
# Gitnexus npm deps — tree-sitter grammars checked daily so we catch
|
||||
# new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars
|
||||
# releases that let us drop a pack-time peer patch ASAP. Grammars
|
||||
# are grouped so lockstep bumps produce a single PR. The tree-sitter
|
||||
# RUNTIME is pinned — upgrade deliberately via the drift check workflow.
|
||||
# See .github/scripts/check-tree-sitter-upgrade-readiness.py for
|
||||
# the upgrade readiness tracker.
|
||||
# RUNTIME is pinned — upgrade deliberately. See
|
||||
# .github/scripts/check-tree-sitter-upgrade-readiness.py for the
|
||||
# compatibility-workarounds tracker.
|
||||
- package-ecosystem: npm
|
||||
directory: /gitnexus
|
||||
schedule:
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Monitor tree-sitter 0.25 upgrade readiness — two things Dependabot can't see:
|
||||
"""Track the compatibility workarounds GitNexus carries for its bundled tree-sitter
|
||||
runtime — things Dependabot can't see:
|
||||
|
||||
1. Peer-dep compatibility: when every grammar's *latest npm release* accepts
|
||||
tree-sitter@0.25.0 (so we can upgrade without --legacy-peer-deps).
|
||||
2. Vendored upstream drift: whether a vendored grammar's upstream parser.c moved.
|
||||
1. Peer-dep patches: several grammars' npm peer ranges reject the bundled
|
||||
runtime, so prepare-tree-sitter-bundle.cjs patches their manifests at pack
|
||||
time (AUDITED_PEERS). The report flags when npm latest (or upstream main)
|
||||
accepts the runtime, so a patch can be dropped by bumping the grammar.
|
||||
2. Intentional pins and vendored holds, plus vendored upstream drift.
|
||||
|
||||
Invoked daily from tree-sitter-upgrade-readiness.yml; runs locally too. Outputs
|
||||
Markdown to stdout; exit 1 when blockers remain (the workflow upserts a tracking
|
||||
issue). stdlib-only — runs on any vanilla runner.
|
||||
Markdown to stdout; exit 1 while any workaround remains (the workflow upserts a
|
||||
tracking issue). stdlib-only — runs on any vanilla runner.
|
||||
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py [--offline | --assert-current]
|
||||
"""
|
||||
|
||||
|
|
@ -31,10 +34,12 @@ GITNEXUS_DIR = REPO_ROOT / "gitnexus"
|
|||
# vendored ABIs are still read from the repo. The read-path mirror of --assert-current.
|
||||
OFFLINE = os.environ.get("GITNEXUS_TS_READINESS_OFFLINE", "") not in ("", "0", "false")
|
||||
|
||||
# ── Upgrade target ──────────────────────────────────────────────────────
|
||||
# The runtime version we want to upgrade TO. Update this when the goal
|
||||
# changes (e.g. once 0.25 lands and we target 0.26).
|
||||
TARGET_RUNTIME = "0.25.0"
|
||||
# ── Target runtime ──────────────────────────────────────────────────────
|
||||
# The exact runtime GitNexus bundles, read from the package.json pin so the report
|
||||
# can never go stale against an upgrade (a hardcoded target did after 0.25 landed).
|
||||
TARGET_RUNTIME = json.loads((GITNEXUS_DIR / "package.json").read_text())["dependencies"][
|
||||
"tree-sitter"
|
||||
]
|
||||
TARGET_RUNTIME_MAJOR_MINOR = ".".join(TARGET_RUNTIME.split(".")[:2])
|
||||
|
||||
# Tree-sitter runtime -> (min_abi, max_abi) it can load. Only the current
|
||||
|
|
@ -168,6 +173,20 @@ def read_pinned_grammar_versions() -> dict[str, str]:
|
|||
return pinned
|
||||
|
||||
|
||||
def read_patched_peers() -> list[tuple[str, str, str]]:
|
||||
"""Return the pack-time peer patches as (name, version, original peer range).
|
||||
|
||||
Parsed from AUDITED_PEERS in gitnexus/scripts/prepare-tree-sitter-bundle.cjs,
|
||||
the single source of truth for which bundled manifests get the runtime added
|
||||
to their tree-sitter peer range.
|
||||
"""
|
||||
script = GITNEXUS_DIR / "scripts" / "prepare-tree-sitter-bundle.cjs"
|
||||
return re.findall(
|
||||
r"\['(tree-sitter[\w-]*)@([\d.]+)',\s*'([^']+)'\]",
|
||||
script.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
|
||||
def npm_view_json(pkg: str) -> dict | None:
|
||||
"""Fetch package metadata from the npm registry via HTTPS.
|
||||
|
||||
|
|
@ -620,13 +639,13 @@ def _render_vendored_section(
|
|||
elif v["upstream_abi"] and v["vendored_abi"] and v["upstream_abi"] > v["vendored_abi"]:
|
||||
if v["upstream_abi"] <= target_abi_range[1]:
|
||||
lines.append(
|
||||
f" - **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, "
|
||||
f"regenerate `parser.c` from upstream `{v['upstream_sha']}`."
|
||||
f" - **Action:** regenerate `parser.c` from upstream "
|
||||
f"`{v['upstream_sha']}` (its ABI loads on tree-sitter@{TARGET_RUNTIME})."
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f" - **Action:** wait for a runtime supporting ABI "
|
||||
f"{v['upstream_abi']}; current target ({TARGET_RUNTIME}) only "
|
||||
f"{v['upstream_abi']}; tree-sitter@{TARGET_RUNTIME} only "
|
||||
f"goes up to ABI {target_abi_range[1]}."
|
||||
)
|
||||
blockers[f"vendored-{v['name']}-abi"] = (
|
||||
|
|
@ -647,7 +666,7 @@ def main() -> int:
|
|||
# Label for npm/upstream values we couldn't determine: in --offline mode the
|
||||
# fetch was deliberately skipped (not "failed"), so say so honestly.
|
||||
miss_label = "offline" if OFFLINE else "fetch failed"
|
||||
lines.append(md_h("Tree-sitter 0.25 upgrade readiness", 1))
|
||||
lines.append(md_h("Tree-sitter compatibility workarounds", 1))
|
||||
lines.append("")
|
||||
if OFFLINE:
|
||||
lines.append(
|
||||
|
|
@ -663,8 +682,7 @@ def main() -> int:
|
|||
pinned_versions = read_pinned_grammar_versions()
|
||||
|
||||
lines.append(
|
||||
f"`tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}–{current_abi_range[1]}) "
|
||||
f"→ target `tree-sitter@{TARGET_RUNTIME}` "
|
||||
f"Bundled runtime `tree-sitter@{TARGET_RUNTIME}` "
|
||||
f"(ABI {target_abi_range[0]}–{target_abi_range[1]})."
|
||||
)
|
||||
lines.append("")
|
||||
|
|
@ -674,7 +692,7 @@ def main() -> int:
|
|||
# workflow's row-diff change-detection keeps working).
|
||||
grammar_rows: list[dict] = []
|
||||
raw_matrix: list[str] = [
|
||||
"| Grammar | Pinned | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |",
|
||||
"| Grammar | Pinned | npm latest | Peer dep | Peer accepts runtime | ABI | Upstream ABI | Status |",
|
||||
"|---|---|---|---|---|---|---|---|",
|
||||
]
|
||||
|
||||
|
|
@ -710,8 +728,8 @@ def main() -> int:
|
|||
f"vendored `{name}`: ABI {v['vendored_abi']} outside target range "
|
||||
f"{target_abi_range[0]}..{target_abi_range[1]}"
|
||||
)
|
||||
# A held vendored grammar (e.g. tree-sitter-c, #1242/#858) is frozen below
|
||||
# a runtime upgrade: in-range ABI or not, keep it a blocker until the hold
|
||||
# A held vendored grammar (e.g. tree-sitter-swift) is a workaround we carry:
|
||||
# in-range ABI or not, keep it a blocker until the hold
|
||||
# (from the manifest) is lifted — same treatment as npm INTENTIONAL_PINS.
|
||||
if hold:
|
||||
v["target_compat"] = False
|
||||
|
|
@ -802,19 +820,15 @@ def main() -> int:
|
|||
# (entry removed from INTENTIONAL_PINS), then reclassified next run.
|
||||
status = "Intentionally pinned"
|
||||
blockers[name] = (
|
||||
f"`{name}` intentionally pinned at `{pinned_spec}` "
|
||||
f"({INTENTIONAL_PINS[name]}) — pin must be lifted "
|
||||
f"before the {TARGET_RUNTIME} runtime upgrade"
|
||||
f"`{name}` intentionally pinned at `{pinned_spec}` ({INTENTIONAL_PINS[name]})"
|
||||
)
|
||||
elif target_compat:
|
||||
status = "Ready"
|
||||
elif upstream_abi and upstream_abi >= 15:
|
||||
status = "Unreleased (ABI 15 on main)"
|
||||
upstream_progress = f"ABI 15 on `{upstream_repo}@{upstream_branch}` not yet published"
|
||||
blockers[name] = f"`{name}`: ABI 15 on `{upstream_repo}` main but not published to npm"
|
||||
else:
|
||||
status = "Blocking"
|
||||
blockers[name] = f"`{name}@{npm_version}`: peer `{peer_display}` incompatible with 0.25"
|
||||
status = "Peer patch required"
|
||||
blockers[name] = (
|
||||
f"`{name}@{npm_version}`: peer `{peer_display}` rejects {TARGET_RUNTIME}"
|
||||
)
|
||||
|
||||
# Also check upstream package.json for relaxed peer dep — beats
|
||||
# the ABI-15 hint when both are true.
|
||||
|
|
@ -874,11 +888,14 @@ def main() -> int:
|
|||
vendored_ready = sum(1 for v in vendored_grammars if v["target_compat"])
|
||||
|
||||
if not blockers:
|
||||
verdict = "**Ready** — all grammars are 0.25-compatible. The runtime upgrade can proceed."
|
||||
verdict = (
|
||||
f"**Clean** — every grammar runs on tree-sitter@{TARGET_RUNTIME} "
|
||||
"without a workaround."
|
||||
)
|
||||
else:
|
||||
moved = "no" if not by_bucket["waiting"] else f"yes — {len(by_bucket['waiting'])} grammars have unreleased fixes on main"
|
||||
verdict = (
|
||||
f"**Blocked** — {len(blockers)} grammars are not yet 0.25-compatible. "
|
||||
f"**Workarounds** — {len(blockers)} grammars still need one. "
|
||||
f"Upstream movement: {moved}."
|
||||
)
|
||||
|
||||
|
|
@ -888,11 +905,11 @@ def main() -> int:
|
|||
lines.append(f"- {ready_count}/{npm_count} npm-installed grammars already accept tree-sitter@{TARGET_RUNTIME}")
|
||||
if vendored_count:
|
||||
lines.append(
|
||||
f"- {vendored_ready}/{vendored_count} vendored grammars at an ABI within the target runtime range"
|
||||
f"- {vendored_ready}/{vendored_count} vendored grammars at an ABI within the runtime range"
|
||||
)
|
||||
lines.append(f"- {len(by_bucket['intentional'])} intentionally pinned (see below)")
|
||||
lines.append(f"- {len(by_bucket['waiting'])} waiting on an upstream npm release")
|
||||
lines.append(f"- {len(by_bucket['blocked'])} blocked on upstream (no fix even on main)")
|
||||
lines.append(f"- {len(by_bucket['blocked'])} need a peer patch with no upstream fix (not even on main)")
|
||||
if by_bucket['fetch_failed']:
|
||||
why = "checks skipped in offline mode" if OFFLINE else "npm registry unreachable"
|
||||
lines.append(f"- {len(by_bucket['fetch_failed'])} could not be checked ({why})")
|
||||
|
|
@ -909,7 +926,8 @@ def main() -> int:
|
|||
lines.append(
|
||||
"These pins lag npm latest and the latest version's peer dep already "
|
||||
"accepts our current `tree-sitter@" + current_runtime + ".x` runtime. "
|
||||
"Bumping is independent of the 0.25 upgrade and should be a quick PR."
|
||||
"When bumping, remove the old version's `AUDITED_PEERS` entry in "
|
||||
"`gitnexus/scripts/prepare-tree-sitter-bundle.cjs` (prepack fails until you do)."
|
||||
)
|
||||
lines.append("")
|
||||
for r in sorted(bump_now, key=lambda r: r["name"]):
|
||||
|
|
@ -933,8 +951,9 @@ def main() -> int:
|
|||
lines.append(md_h("Disposition", 2))
|
||||
|
||||
_emit_bucket(
|
||||
"Ready for 0.25",
|
||||
"These grammars' npm-latest peer dep already accepts the target runtime. No action needed for the upgrade.",
|
||||
"Peer accepts runtime",
|
||||
f"These grammars' npm-latest peer dep already accepts tree-sitter@{TARGET_RUNTIME}. "
|
||||
"No workaround needed at npm latest.",
|
||||
by_bucket["ready"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}` — pinned `{r['pinned_spec']}`, npm latest `{r['npm_version_label']}`"
|
||||
|
|
@ -960,7 +979,7 @@ def main() -> int:
|
|||
_emit_bucket(
|
||||
"Waiting on upstream npm release",
|
||||
"Fixes are merged on the upstream main branch but not yet published to npm. "
|
||||
"We can move forward as soon as upstream cuts a release.",
|
||||
"The peer patch can be dropped as soon as upstream cuts a release.",
|
||||
by_bucket["waiting"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}@{r['npm_version_label']}` — peer `{r['peer_range'] or 'none'}`. "
|
||||
|
|
@ -969,9 +988,9 @@ def main() -> int:
|
|||
)
|
||||
|
||||
_emit_bucket(
|
||||
"Blocked on upstream",
|
||||
"Peer dep is too tight on both the latest npm release and on upstream main. "
|
||||
"These need an upstream issue/PR before we can proceed.",
|
||||
"Peer patch required",
|
||||
"Peer dep rejects the runtime on both the latest npm release and upstream main. "
|
||||
"The pack-time peer patch stays until upstream relaxes it.",
|
||||
by_bucket["blocked"],
|
||||
lambda r: f"- `{r['name']}@{r['npm_version_label']}` — peer `{r['peer_range'] or 'none'}`",
|
||||
)
|
||||
|
|
@ -988,6 +1007,35 @@ def main() -> int:
|
|||
lambda r: f"- `{r['name']}` (pinned `{r['pinned_spec']}`)",
|
||||
)
|
||||
|
||||
# ── Pack-time peer patches ──────────────────────────────────────
|
||||
patched = read_patched_peers()
|
||||
if patched:
|
||||
rows_by_name = {r["name"]: r for r in grammar_rows}
|
||||
patch_status = {
|
||||
"ready": "can drop: npm latest accepts the runtime",
|
||||
"intentional": "required while intentionally pinned",
|
||||
"waiting": "drop once upstream publishes its relaxed peer",
|
||||
"blocked": "required: no upstream fix yet",
|
||||
"fetch_failed": f"unknown ({miss_label})",
|
||||
}
|
||||
lines.append(md_h(f"Pack-time peer patches ({len(patched)})", 2))
|
||||
lines.append(
|
||||
"`gitnexus/scripts/prepare-tree-sitter-bundle.cjs` adds "
|
||||
f"`{TARGET_RUNTIME}` to these bundled manifests' tree-sitter peer range. "
|
||||
"Removing an entry requires bumping (or dropping) the package that brings it in."
|
||||
)
|
||||
lines.append("")
|
||||
for name, version, peer in sorted(patched):
|
||||
row = rows_by_name.get(name)
|
||||
if row is None or pinned_versions.get(name) != version:
|
||||
status = "transitive: dropped when the grammar that depends on it is bumped"
|
||||
elif row["bump_now"]:
|
||||
status = f"can drop: bump to `{row['npm_version_label']}`"
|
||||
else:
|
||||
status = patch_status[row["bucket"]]
|
||||
lines.append(f"- `{name}@{version}` (peer `{peer}`) — {status}")
|
||||
lines.append("")
|
||||
|
||||
# ── Vendored parsers ────────────────────────────────────────────
|
||||
lines.extend(_render_vendored_section(vendored_grammars, target_abi_range, blockers))
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ Rules:
|
|||
This is checked by substring containment rather than prefix match because
|
||||
the group value is a conditional expression that resolves to a `CI-…` or
|
||||
`docker-build-push-…` literal at runtime.
|
||||
The two dedicated EC2 workflows instead share the exact resource lock
|
||||
`gitnexus-evolution-runner`, including their hosted cleanup jobs.
|
||||
|
||||
We deliberately do not use a YAML library — keeps the script dependency-free
|
||||
on any vanilla runner. `on:` block parsing is line-based and handles both the
|
||||
|
|
@ -33,6 +35,7 @@ import sys
|
|||
|
||||
|
||||
REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-", "docker-build-push-")
|
||||
EC2_WORKFLOWS = {"gitnexus-skill-evolution.yml", "release-evaluation.yml"}
|
||||
|
||||
|
||||
def is_reusable(lines: list[str]) -> bool:
|
||||
|
|
@ -151,7 +154,8 @@ def check(workflows_dir: pathlib.Path) -> int:
|
|||
fail = 1
|
||||
continue
|
||||
|
||||
if not any(token in group for token in REQUIRED_TOKENS):
|
||||
shared_ec2_lock = path.name in EC2_WORKFLOWS and group == "gitnexus-evolution-runner"
|
||||
if not shared_ec2_lock and not any(token in group for token in REQUIRED_TOKENS):
|
||||
print(
|
||||
f"::error file={path}::concurrency.group `{group}` must "
|
||||
f"reference one of {REQUIRED_TOKENS} (use ${{{{ github.workflow }}}} "
|
||||
|
|
|
|||
86
.github/scripts/evolution-runner-ready.py
vendored
Normal file
86
.github/scripts/evolution-runner-ready.py
vendored
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Bound this run's native probe or paid job pickup to five minutes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def wait_for_runner(*, timeout: float = 300, job_name: str | None = None) -> None:
|
||||
"""Require probe success, or bound pickup of the named paid job."""
|
||||
repository = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
run_id = os.environ.get("GITHUB_RUN_ID", "")
|
||||
if repository != "abhigyanpatwari/GitNexus" or not re.fullmatch(r"\d+", run_id):
|
||||
raise ValueError(
|
||||
"runner pickup requires this repository's current workflow run"
|
||||
)
|
||||
until = time.monotonic() + timeout
|
||||
paid = job_name is not None
|
||||
job_name = job_name or "Verify the runner service is online"
|
||||
# Never start a request with no time left, nor let one outlive the deadline.
|
||||
while (remaining := until - time.monotonic()) > 0:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"api",
|
||||
f"repos/{repository}/actions/runs/{run_id}/jobs?per_page=100",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=min(30, remaining),
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError(
|
||||
"cannot verify runner pickup through the current run's jobs API"
|
||||
)
|
||||
jobs = json.loads(result.stdout).get("jobs", [])
|
||||
probes = [
|
||||
job for job in jobs if job.get("name", "").split(" / ")[-1] == job_name
|
||||
]
|
||||
if len(probes) > 1:
|
||||
raise RuntimeError("current run has multiple runner pickup probes")
|
||||
if paid and probes:
|
||||
if probes[0].get("status") == "in_progress":
|
||||
return
|
||||
if probes[0].get("status") == "completed":
|
||||
if probes[0].get("conclusion") in ("skipped", "cancelled"):
|
||||
raise RuntimeError("paid job did not run")
|
||||
return
|
||||
if probes and probes[0].get("status") == "completed":
|
||||
if probes[0].get("conclusion") != "success":
|
||||
raise RuntimeError("runner pickup probe failed")
|
||||
return
|
||||
time.sleep(min(10, max(0, until - time.monotonic())))
|
||||
raise RuntimeError(
|
||||
"runner service did not complete its pickup probe before the deadline"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
args = sys.argv[1:]
|
||||
if len(args) == 2 and args[0] == "--job-name" and args[1].strip():
|
||||
wait_for_runner(job_name=args[1])
|
||||
elif args == []:
|
||||
wait_for_runner()
|
||||
else:
|
||||
raise ValueError("expected no arguments or --job-name NAME")
|
||||
print("The dedicated runner picked up this run's requested job.")
|
||||
except (
|
||||
ValueError,
|
||||
RuntimeError,
|
||||
subprocess.TimeoutExpired,
|
||||
OSError,
|
||||
json.JSONDecodeError,
|
||||
) as exc:
|
||||
message = (
|
||||
str(exc)
|
||||
if isinstance(exc, (ValueError, RuntimeError))
|
||||
else "runner readiness command failed"
|
||||
)
|
||||
print(f"::error::{message}")
|
||||
raise SystemExit(1) from None
|
||||
240
.github/scripts/evolution-runner.py
vendored
Normal file
240
.github/scripts/evolution-runner.py
vendored
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""Start and stop the existing dedicated EC2 runner without provisioning resources.
|
||||
|
||||
Call only from trusted hosted jobs under the shared evolution/release concurrency
|
||||
group. The instance is dedicated to that group, so an already-running instance is
|
||||
also claimed for cleanup. GitHub runner pickup is checked separately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
PREFIX = "GITNEXUS_EVOLUTION_"
|
||||
DAYS = ("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN")
|
||||
STOP_PATTERN = re.compile(r"(MON|TUE|WED|THU|FRI|SAT|SUN) ([01]\d|2[0-3]):([0-5]\d)")
|
||||
START_TIMEOUT = 600
|
||||
STOP_TIMEOUT = 600
|
||||
EVIDENCE_RESERVE = 90 * 60
|
||||
|
||||
|
||||
def configuration() -> dict[str, str]:
|
||||
values = {}
|
||||
for name in ("AWS_REGION", "AWS_ROLE_ARN", "EC2_INSTANCE_ID", "STOP_SCHEDULE_UTC"):
|
||||
value = os.environ.get(PREFIX + name, "")
|
||||
if not value:
|
||||
raise ValueError(f"configure protected environment setting {PREFIX + name}")
|
||||
values[name] = value
|
||||
if not re.fullmatch(r"[a-z]{2}(?:-[a-z]+)+-\d", values["AWS_REGION"]):
|
||||
raise ValueError("invalid AWS region")
|
||||
if not re.fullmatch(r"arn:aws(?:-us-gov|-cn)?:iam::\d{12}:role/[A-Za-z0-9_+=,.@/-]+", values["AWS_ROLE_ARN"]):
|
||||
raise ValueError("invalid AWS role ARN")
|
||||
if not re.fullmatch(r"i-(?:[0-9a-f]{8}|[0-9a-f]{17})", values["EC2_INSTANCE_ID"]):
|
||||
raise ValueError("invalid EC2 instance ID")
|
||||
if STOP_PATTERN.fullmatch(values["STOP_SCHEDULE_UTC"]) is None:
|
||||
raise ValueError("STOP_SCHEDULE_UTC must name the actual weekly stop as DAY HH:MM in UTC")
|
||||
return values
|
||||
|
||||
|
||||
def stop_deadline(schedule: str, *, now: datetime | None = None) -> int:
|
||||
"""Reserve startup/upload time and respect both the weekly stop and a 24h cap."""
|
||||
match = STOP_PATTERN.fullmatch(schedule)
|
||||
if match is None:
|
||||
raise ValueError("STOP_SCHEDULE_UTC must name the actual weekly stop as DAY HH:MM in UTC")
|
||||
now = now or datetime.now(UTC)
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("stop deadline requires an explicit timezone")
|
||||
now = now.astimezone(UTC)
|
||||
scheduled = now.replace(hour=int(match[2]), minute=int(match[3]), second=0, microsecond=0)
|
||||
scheduled += timedelta(days=(DAYS.index(match[1]) - now.weekday()) % 7)
|
||||
# EventBridge may fire anywhere within its scheduled minute. Starting during
|
||||
# that minute must not be interpreted as having a fresh seven-day window.
|
||||
if scheduled <= now < scheduled + timedelta(minutes=1):
|
||||
raise ValueError("the configured EventBridge stop is due now")
|
||||
if scheduled < now:
|
||||
scheduled += timedelta(days=7)
|
||||
deadline = min(scheduled, now + timedelta(hours=24))
|
||||
if (deadline - now).total_seconds() < START_TIMEOUT + EVIDENCE_RESERVE:
|
||||
raise ValueError("insufficient time before the configured stop and evidence-upload reserve")
|
||||
return int(deadline.timestamp())
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
"""The AWS CLI call itself failed, timed out, or returned unusable output."""
|
||||
|
||||
|
||||
def aws(values: dict[str, str], operation: str, *extra: str, until: float | None = None) -> dict:
|
||||
"""Keep each command within the remaining budget and hide AWS diagnostics."""
|
||||
timeout = 30 if until is None else min(30, until - time.monotonic())
|
||||
if timeout <= 0:
|
||||
raise CommandError("EC2 command deadline expired")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"aws",
|
||||
"ec2",
|
||||
operation,
|
||||
"--instance-ids",
|
||||
values["EC2_INSTANCE_ID"],
|
||||
"--region",
|
||||
values["AWS_REGION"],
|
||||
"--output",
|
||||
"json",
|
||||
"--no-cli-pager",
|
||||
*extra,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
# TimeoutExpired includes the full command; never expose private IDs.
|
||||
raise CommandError(f"EC2 {operation} command failed or timed out") from None
|
||||
if result.returncode:
|
||||
raise CommandError(f"EC2 {operation} failed; verify the protected role's scoped permissions")
|
||||
try:
|
||||
response = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise CommandError(f"EC2 {operation} returned an invalid response") from None
|
||||
if not isinstance(response, dict):
|
||||
raise CommandError(f"EC2 {operation} returned an invalid response")
|
||||
return response
|
||||
|
||||
|
||||
def instance_state(values: dict[str, str], *, until: float | None = None) -> str:
|
||||
response = aws(values, "describe-instances", until=until)
|
||||
try:
|
||||
instances = [instance for reservation in response["Reservations"] for instance in reservation["Instances"]]
|
||||
if len(instances) != 1 or instances[0].get("InstanceId") != values["EC2_INSTANCE_ID"]:
|
||||
raise ValueError
|
||||
state = instances[0].get("State", {}).get("Name")
|
||||
except (KeyError, TypeError, AttributeError, ValueError):
|
||||
raise RuntimeError("EC2 did not return exactly the configured instance") from None
|
||||
if state not in ("stopped", "stopping", "pending", "running"):
|
||||
raise RuntimeError("EC2 instance state does not permit this lifecycle operation")
|
||||
return state
|
||||
|
||||
|
||||
def healthy(values: dict[str, str], *, until: float | None = None) -> bool:
|
||||
response = aws(values, "describe-instance-status", "--include-all-instances", until=until)
|
||||
statuses = response.get("InstanceStatuses", [])
|
||||
if statuses == []:
|
||||
return False # Status checks can lag behind the running state.
|
||||
if (
|
||||
not isinstance(statuses, list)
|
||||
or len(statuses) != 1
|
||||
or not isinstance(statuses[0], dict)
|
||||
or statuses[0].get("InstanceId") != values["EC2_INSTANCE_ID"]
|
||||
):
|
||||
raise RuntimeError("EC2 status did not return exactly the configured instance")
|
||||
return all(
|
||||
isinstance(statuses[0].get(field), dict) and statuses[0][field].get("Status") == "ok"
|
||||
for field in ("SystemStatus", "InstanceStatus")
|
||||
)
|
||||
|
||||
|
||||
def output(name: str, value: str) -> None:
|
||||
with Path(os.environ["GITHUB_OUTPUT"]).open("a") as handle:
|
||||
handle.write(f"{name}={value}\n")
|
||||
|
||||
|
||||
def pause(until: float) -> None:
|
||||
time.sleep(min(10, max(0, until - time.monotonic())))
|
||||
|
||||
|
||||
def stop(values: dict[str, str], *, timeout: float = STOP_TIMEOUT) -> None:
|
||||
"""Gracefully stop and observe stopped; an accepted API call is insufficient."""
|
||||
until = time.monotonic() + timeout
|
||||
failure: CommandError | None = None
|
||||
while time.monotonic() < until:
|
||||
try:
|
||||
state = instance_state(values, until=until)
|
||||
except CommandError as error:
|
||||
# A failed or timed-out describe is unknown state, not proof of
|
||||
# either outcome. Keep observing within the same stop budget; an
|
||||
# unusable state or identity mismatch still fails at once.
|
||||
failure = error
|
||||
pause(until)
|
||||
continue
|
||||
failure = None
|
||||
if state == "stopped":
|
||||
print("Dedicated EC2 instance confirmed stopped.")
|
||||
return
|
||||
if state == "running":
|
||||
try:
|
||||
aws(values, "stop-instances", until=until)
|
||||
except RuntimeError:
|
||||
# A timeout can lose an accepted response. Observe state again;
|
||||
# retry only while still running, within the same stop budget.
|
||||
pass
|
||||
# StopInstances is invalid for pending instances. Wait for running; for
|
||||
# stopping instances, wait for the real terminal stopped state.
|
||||
pause(until)
|
||||
if failure is not None:
|
||||
raise RuntimeError(f"EC2 shutdown deadline expired after: {failure}; use the external stop watchdog")
|
||||
raise RuntimeError("EC2 shutdown deadline expired; use the external stop watchdog")
|
||||
|
||||
|
||||
def start(values: dict[str, str], *, timeout: float = START_TIMEOUT, cleanup_timeout: float = STOP_TIMEOUT) -> None:
|
||||
deadline_epoch = stop_deadline(values["STOP_SCHEDULE_UTC"])
|
||||
until = time.monotonic() + timeout
|
||||
claimed = False
|
||||
requested = False
|
||||
try:
|
||||
while time.monotonic() < until:
|
||||
state = instance_state(values, until=until)
|
||||
if state != "stopping" and not claimed:
|
||||
# Persist cleanup ownership BEFORE StartInstances: an accepted
|
||||
# call can time out or the hosted process can be interrupted.
|
||||
output("cleanup_required", "true")
|
||||
claimed = True
|
||||
if state == "stopped":
|
||||
if requested:
|
||||
raise RuntimeError("EC2 stopped again during bootstrap")
|
||||
requested = True
|
||||
aws(values, "start-instances", until=until)
|
||||
elif state == "running" and healthy(values, until=until):
|
||||
output("stop_deadline_epoch", str(deadline_epoch))
|
||||
print("Dedicated EC2 instance is running with healthy system and instance checks.")
|
||||
return
|
||||
pause(until)
|
||||
raise RuntimeError("EC2 startup/health deadline expired")
|
||||
except BaseException:
|
||||
if claimed:
|
||||
try:
|
||||
stop(values, timeout=cleanup_timeout)
|
||||
except (RuntimeError, OSError):
|
||||
print(
|
||||
"::error::EC2 cleanup also failed; hosted cleanup must retry and the external stop watchdog remains required"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("operation", choices=("check", "start", "stop"))
|
||||
args = parser.parse_args()
|
||||
values = configuration()
|
||||
if args.operation == "check":
|
||||
stop_deadline(values["STOP_SCHEDULE_UTC"])
|
||||
print("Dedicated runner configuration and stop window validated.")
|
||||
elif args.operation == "start":
|
||||
start(values)
|
||||
else:
|
||||
# Shutdown must still run after the startup window or stop deadline.
|
||||
stop(values)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, RuntimeError, OSError, KeyError) as exc:
|
||||
message = str(exc) if isinstance(exc, (ValueError, RuntimeError)) else "runner lifecycle command failed"
|
||||
print(f"::error::{message}")
|
||||
raise SystemExit(1) from None
|
||||
|
|
@ -46,7 +46,7 @@ _ROW_DIFF_RE = re.compile(r"\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$", re.M)
|
|||
_ISSUE_READY_RE = re.compile(
|
||||
r"- (\d+)/(\d+) npm-installed grammars already accept tree-sitter@"
|
||||
)
|
||||
_ISSUE_BLOCKER_RE = re.compile(r"\*\*Blocked\*\* — (\d+) grammars? ")
|
||||
_ISSUE_BLOCKER_RE = re.compile(r"\*\*Workarounds\*\* — (\d+) grammars? ")
|
||||
|
||||
|
||||
def _physical_vendor_grammars() -> set[str]:
|
||||
|
|
@ -118,8 +118,9 @@ class ManifestClassification(TestCase):
|
|||
self.assertNotIn("tree-sitter-cpp", readiness.VENDORED_NAMES)
|
||||
self.assertNotIn("tree-sitter-go", readiness.VENDORED_NAMES)
|
||||
|
||||
def test_c_carries_a_hold_cpp_does_not(self):
|
||||
self.assertTrue(readiness.VENDORED["tree-sitter-c"]["hold"])
|
||||
def test_swift_carries_a_hold_c_does_not(self):
|
||||
self.assertTrue(readiness.VENDORED["tree-sitter-swift"]["hold"])
|
||||
self.assertFalse(readiness.VENDORED["tree-sitter-c"]["hold"])
|
||||
self.assertNotIn("tree-sitter-c", readiness.INTENTIONAL_PINS)
|
||||
# cpp stays an npm intentional pin.
|
||||
self.assertIn("tree-sitter-cpp", readiness.INTENTIONAL_PINS)
|
||||
|
|
@ -278,9 +279,7 @@ class ReportRendering(TestCase):
|
|||
cls.rows = dict(_ROW_DIFF_RE.findall(cls.report))
|
||||
|
||||
def test_no_bare_question_mark_anywhere(self):
|
||||
# The only legitimate '?' is the "Satisfies 0.25?" column header.
|
||||
sanitized = self.report.replace("Satisfies 0.25?", "Satisfies 0.25")
|
||||
self.assertNotIn("?", sanitized, "report still contains a bare '?' placeholder")
|
||||
self.assertNotIn("?", self.report, "report still contains a bare '?' placeholder")
|
||||
|
||||
def test_malformed_npm_version_renders_unknown_in_prose_not_bare_question(self):
|
||||
# A successful (200) npm /latest response that omits `version` leaves
|
||||
|
|
@ -304,8 +303,7 @@ class ReportRendering(TestCase):
|
|||
contextlib.redirect_stdout(buf):
|
||||
readiness.main()
|
||||
report = buf.getvalue()
|
||||
sanitized = report.replace("Satisfies 0.25?", "Satisfies 0.25")
|
||||
self.assertNotIn("?", sanitized)
|
||||
self.assertNotIn("?", report)
|
||||
# The Ready bucket prose line for go shows the labeled 'unknown', not '?'.
|
||||
self.assertRegex(report, r"`tree-sitter-go`.*npm latest `unknown`")
|
||||
|
||||
|
|
@ -328,14 +326,14 @@ class ReportRendering(TestCase):
|
|||
self.assertNotIn("Could not check", self.report)
|
||||
self.assertNotIn("fetch failed", self.report)
|
||||
|
||||
def test_held_c_renders_held_and_keeps_exit_nonzero(self):
|
||||
def test_held_swift_renders_held_and_keeps_exit_nonzero(self):
|
||||
# Status is the last matrix cell (the row-diff regex captures the whole
|
||||
# tail, not just status, so read the cell directly).
|
||||
cells = [c.strip() for c in self._matrix_row("tree-sitter-c").strip().strip("|").split("|")]
|
||||
cells = [c.strip() for c in self._matrix_row("tree-sitter-swift").strip().strip("|").split("|")]
|
||||
self.assertEqual(cells[-1], "Vendored — held")
|
||||
self.assertIn("**Held:**", self.report)
|
||||
# With every npm grammar mocked to "Ready", the ONLY remaining blocker is
|
||||
# the held c — so a non-zero exit proves the hold is treated as a blocker.
|
||||
# the held Swift — so a non-zero exit proves the hold is treated as a blocker.
|
||||
self.assertEqual(self.code, 1)
|
||||
|
||||
def test_upstream_abi_miss_uses_labeled_sentinel(self):
|
||||
|
|
@ -351,9 +349,9 @@ class ReportRendering(TestCase):
|
|||
self.assertEqual(len(self.rows), len(readiness.GRAMMARS))
|
||||
for name in readiness.VENDORED_NAMES:
|
||||
self.assertIn(name, self.rows)
|
||||
# group 2 is the Status cell — held c renders exactly "Vendored — held",
|
||||
# group 2 is the Status cell — held Swift renders exactly "Vendored — held",
|
||||
# and no captured status contains a pipe (proves cell-scoped capture).
|
||||
self.assertEqual(self.rows["tree-sitter-c"], "Vendored — held")
|
||||
self.assertEqual(self.rows["tree-sitter-swift"], "Vendored — held")
|
||||
for status in self.rows.values():
|
||||
self.assertNotIn("|", status)
|
||||
|
||||
|
|
@ -365,16 +363,30 @@ class ReportRendering(TestCase):
|
|||
# Counts are derived from _render_report()'s mock corpus (all npm peer
|
||||
# deps mocked permissive): of the 10 npm-installed grammars, 9 render
|
||||
# Ready and 1 — tree-sitter-cpp — is the intentional pin (#1242), so it is
|
||||
# not counted ready. The 4 blockers are that same pinned tree-sitter-cpp
|
||||
# plus three held vendored grammars: ABI-held tree-sitter-c (#1242/#858),
|
||||
# tree-sitter-kotlin (pinned to an unreleased fwcd main commit for `fun
|
||||
# interface` support — ABI 14 is in range, but a hold counts as a blocker
|
||||
# until it is lifted), and tree-sitter-objc. If a grammar is added/removed
|
||||
# or a pin/hold changes,
|
||||
# update _render_report()'s mock AND these expected counts together; a
|
||||
# mismatch here means the report prose drifted, not the regex.
|
||||
# not counted ready. The remaining workarounds are that intentional pin
|
||||
# and Swift's declaration-recovery hold.
|
||||
self.assertEqual(ready.groups(), ("9", "10"))
|
||||
self.assertEqual(blockers.group(1), "4")
|
||||
self.assertEqual(blockers.group(1), "2")
|
||||
|
||||
def test_lists_every_pack_time_peer_patch_with_its_drop_condition(self):
|
||||
patched = readiness.read_patched_peers()
|
||||
# Guards the AUDITED_PEERS regex: the bundle script patches 8 manifests today.
|
||||
self.assertGreaterEqual(len(patched), 8)
|
||||
self.assertIn(f"## Pack-time peer patches ({len(patched)})", self.report)
|
||||
for name, version, _ in patched:
|
||||
self.assertIn(f"- `{name}@{version}`", self.report)
|
||||
line = lambda pkg: next(l for l in self.report.splitlines() if l.startswith(f"- `{pkg}`"))
|
||||
# npm is mocked to a permissive 9.9.9, so a direct pin can be bumped away...
|
||||
self.assertIn("can drop: bump to `9.9.9`", line("tree-sitter-java@0.23.5"))
|
||||
# ...but not an intentional pin, nor a nested copy owned by another grammar.
|
||||
self.assertIn("required while intentionally pinned", line("tree-sitter-cpp@0.23.4"))
|
||||
self.assertIn("transitive", line("tree-sitter-javascript@0.23.1"))
|
||||
self.assertIn("transitive", line("tree-sitter-c@0.23.6"))
|
||||
|
||||
def test_target_runtime_is_the_bundled_pin(self):
|
||||
pkg = json.loads((_REPO_ROOT / "gitnexus" / "package.json").read_text())
|
||||
self.assertEqual(readiness.TARGET_RUNTIME, pkg["dependencies"]["tree-sitter"])
|
||||
self.assertIn(f"Bundled runtime `tree-sitter@{readiness.TARGET_RUNTIME}`", self.report)
|
||||
|
||||
def _matrix_row(self, name: str) -> str:
|
||||
for line in self.report.splitlines():
|
||||
|
|
@ -419,8 +431,7 @@ class OfflineMode(TestCase):
|
|||
|
||||
def test_offline_report_has_no_bare_question_mark(self):
|
||||
report, _ = self._render_offline()
|
||||
sanitized = report.replace("Satisfies 0.25?", "Satisfies 0.25")
|
||||
self.assertNotIn("?", sanitized)
|
||||
self.assertNotIn("?", report)
|
||||
|
||||
|
||||
class VendoredAbiBranches(TestCase):
|
||||
|
|
|
|||
35
.github/scripts/update-vendored-grammars.mjs
vendored
35
.github/scripts/update-vendored-grammars.mjs
vendored
|
|
@ -9,22 +9,16 @@
|
|||
* 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.
|
||||
* The runtime is tree-sitter@0.25.1 (LANGUAGE_VERSION 13–15). Read the
|
||||
* candidate's generated ABI before updating; incompatible candidates are
|
||||
* reported rather than applied. Semantic compatibility is verified by PR CI.
|
||||
*
|
||||
* 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 and tree-sitter-objc are MONITORED but report-only (`hold`): c is ABI-pinned at 0.21.4
|
||||
* (#1242/#858) and must not auto-bump without a tree-sitter runtime upgrade, so an
|
||||
* available c update is detected + reported but never auto-applied — even if it is
|
||||
* ABI-13/14. A maintainer re-vendors it deliberately.
|
||||
* Policy holds are recorded in the shared manifest. Swift is held at the
|
||||
* newest tested release that preserves declaration ownership during recovery.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
|
|
@ -36,12 +30,12 @@ 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
|
||||
const COMPATIBLE_ABI = new Set([13, 14, 15]); // tree-sitter@0.25.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).
|
||||
// auto-applied (for example, when newer upstream error recovery regresses ownership).
|
||||
//
|
||||
// The vendored set lives in .github/vendored-grammars.json — the SHARED source of
|
||||
// truth this monitor and .github/scripts/check-tree-sitter-upgrade-readiness.py both
|
||||
|
|
@ -230,7 +224,7 @@ function detect(deps = {}) {
|
|||
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).
|
||||
// AND the grammar is not on a policy hold (e.g. swift).
|
||||
applicable: newer && abi != null && COMPATIBLE_ABI.has(abi) && !g.hold,
|
||||
});
|
||||
}
|
||||
|
|
@ -285,8 +279,8 @@ function apply(key, opts = {}) {
|
|||
const abi = readAbiFn(srcRoot);
|
||||
if (abi == null || !COMPATIBLE_ABI.has(abi))
|
||||
throw new ApplyExit(
|
||||
`${key}: candidate ${up.version} is ABI ${abi ?? 'unknown'} — not tree-sitter@0.21.1 ` +
|
||||
`compatible (need 13/14); refusing to re-vendor. Handle manually.`,
|
||||
`${key}: candidate ${up.version} is ABI ${abi ?? 'unknown'} — not tree-sitter@0.25.1 ` +
|
||||
`compatible (need 13/14/15); refusing to re-vendor. Handle manually.`,
|
||||
3,
|
||||
);
|
||||
|
||||
|
|
@ -299,7 +293,8 @@ function apply(key, opts = {}) {
|
|||
|
||||
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.
|
||||
// Keep the synchronous CommonJS wrapper: upstream C now uses top-level await.
|
||||
// binding.gyp / bindings/node/index.* / README / LICENSE / prebuilds are preserved.
|
||||
for (const rel of [
|
||||
'src/parser.c',
|
||||
'src/scanner.c',
|
||||
|
|
@ -308,8 +303,6 @@ function apply(key, opts = {}) {
|
|||
'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);
|
||||
}
|
||||
|
|
@ -317,10 +310,12 @@ function apply(key, opts = {}) {
|
|||
const pkgPath = path.join(dest, 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
pkg.version = up.version;
|
||||
if (up.kind === 'github') pkg._upstreamCommit = up.ref;
|
||||
else delete pkg._upstreamCommit;
|
||||
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 ` +
|
||||
`the GitNexus-hardened binding.gyp + synchronous loader + 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');
|
||||
|
|
|
|||
32
.github/vendored-grammars.json
vendored
32
.github/vendored-grammars.json
vendored
|
|
@ -3,34 +3,46 @@
|
|||
"grammars": {
|
||||
"c": {
|
||||
"name": "tree-sitter-c",
|
||||
"upstream": { "npm": "tree-sitter-c" },
|
||||
"hold": "ABI-pinned at 0.21.4 (#1242/#858) — needs a tree-sitter runtime upgrade before bumping"
|
||||
"upstream": {
|
||||
"github": "tree-sitter/tree-sitter-c"
|
||||
}
|
||||
},
|
||||
"objc": {
|
||||
"name": "tree-sitter-objc",
|
||||
"upstream": { "npm": "tree-sitter-objc" },
|
||||
"hold": "Pinned at 3.0.2 for the Objective-C provider MVP; carries darwin/linux arm64+x64 prebuilds compatible with the current tree-sitter runtime (linux-arm64 built from vendored source because the upstream npm artifact is mislabeled)"
|
||||
"upstream": {
|
||||
"github": "tree-sitter-grammars/tree-sitter-objc"
|
||||
}
|
||||
},
|
||||
"swift": {
|
||||
"name": "tree-sitter-swift",
|
||||
"upstream": { "npm": "tree-sitter-swift" }
|
||||
"upstream": {
|
||||
"npm": "tree-sitter-swift"
|
||||
},
|
||||
"hold": "Pinned to upstream 0.7.2-with-generated-files (7b7909f2): 0.7.3 and 0.7.4 re-parent a later top-level declaration after split conditional function headers. Keep until the declaration-ownership regression tests pass; see gitnexus/vendor/README.md."
|
||||
},
|
||||
"kotlin": {
|
||||
"name": "tree-sitter-kotlin",
|
||||
"upstream": { "npm": "tree-sitter-kotlin" },
|
||||
"hold": "pinned to unreleased fwcd main commit c8ac3d26 for `fun interface` support (fwcd/tree-sitter-kotlin#169, closes #87) — npm latest (0.3.8) lacks the fix, so the monitor must NOT auto-revert (isNewer is strict-inequality: 0.3.8 != 0.4.0). Drop this hold and bump when upstream cuts a release that includes the fix"
|
||||
"upstream": {
|
||||
"github": "fwcd/tree-sitter-kotlin"
|
||||
}
|
||||
},
|
||||
"dart": {
|
||||
"name": "tree-sitter-dart",
|
||||
"upstream": { "github": "UserNobody14/tree-sitter-dart" }
|
||||
"upstream": {
|
||||
"github": "UserNobody14/tree-sitter-dart"
|
||||
}
|
||||
},
|
||||
"proto": {
|
||||
"name": "tree-sitter-proto",
|
||||
"upstream": { "github": "coder3101/tree-sitter-proto" }
|
||||
"upstream": {
|
||||
"github": "coder3101/tree-sitter-proto"
|
||||
}
|
||||
},
|
||||
"zig": {
|
||||
"name": "tree-sitter-zig",
|
||||
"upstream": { "npm": "@tree-sitter-grammars/tree-sitter-zig" }
|
||||
"upstream": {
|
||||
"github": "tree-sitter-grammars/tree-sitter-zig"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ name: Build tree-sitter prebuilds
|
|||
# upstream prebuilds AND stays dependency-review-tracked, so it is left alone).
|
||||
# All seven 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-c (vendored source from upstream Git, including releases
|
||||
# newer than npm; REQUIRED grammar)
|
||||
# - tree-sitter-dart (vendored source; built from gitnexus/vendor/)
|
||||
# - tree-sitter-proto (vendored source; built from gitnexus/vendor/)
|
||||
# - tree-sitter-kotlin (vendored source; built from gitnexus/vendor/ — pinned to
|
||||
|
|
@ -152,11 +151,11 @@ jobs:
|
|||
// '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
|
||||
// c builds the exact upstream source snapshot in vendor/ so the
|
||||
// prebuild matches its metadata — this also 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' },
|
||||
c: { name: 'tree-sitter-c', kind: 'vendored' },
|
||||
dart: { name: 'tree-sitter-dart', kind: 'vendored' },
|
||||
proto: { name: 'tree-sitter-proto', kind: 'vendored' },
|
||||
// kotlin is vendored WITH its source (parser.c/scanner.c/binding.gyp),
|
||||
|
|
@ -370,7 +369,7 @@ jobs:
|
|||
|
||||
- name: Upload fork PR meta
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && steps.decide.outputs.any == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: pr-meta
|
||||
path: ${{ runner.temp }}/pr-meta/metadata.json
|
||||
|
|
@ -396,13 +395,13 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false # this job uploads artifacts (artipacked)
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Ensure Python (arm64 Windows only)
|
||||
if: matrix.platform_arch == 'win32-arm64'
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
|
@ -424,19 +423,20 @@ jobs:
|
|||
# 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).
|
||||
# node-gyp >=12.1 recognizes Visual Studio 2026 on Windows ARM runners.
|
||||
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
|
||||
prebuildify@^6 node-gyp@12.4.0 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
|
||||
"$NAME@${REF}" prebuildify@^6 node-gyp@12.4.0
|
||||
pkgdir="$work/node_modules/$NAME"
|
||||
fi
|
||||
|
||||
|
|
@ -489,15 +489,11 @@ jobs:
|
|||
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.
|
||||
# Allow install scripts so node-gyp-build can compile the runtime if
|
||||
# no matching runtime prebuild is available. The grammar built above
|
||||
# is still loaded as a prebuild; setLanguage checks its runtime ABI.
|
||||
npm install --no-audit --no-fund \
|
||||
node-gyp-build@^4 node-gyp@^11 node-addon-api@^8 tree-sitter@0.21.1
|
||||
node-gyp-build@^4 node-gyp@12.4.0 node-addon-api@^8 tree-sitter@0.25.1
|
||||
# The node script is single-quoted on purpose — its ${...} are JS
|
||||
# template literals read from the environment, not shell expansions.
|
||||
# shellcheck disable=SC2016
|
||||
|
|
@ -527,7 +523,7 @@ jobs:
|
|||
'
|
||||
|
||||
- name: Upload prebuild artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ts-prebuild-${{ matrix.grammar }}-${{ matrix.platform_arch }}
|
||||
path: ${{ steps.build.outputs.stage }}/${{ matrix.name }}.node
|
||||
|
|
@ -560,7 +556,7 @@ jobs:
|
|||
steps:
|
||||
- name: Mint GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
|
@ -575,7 +571,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Download all prebuild artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
path: ${{ runner.temp }}/dl
|
||||
pattern: ts-prebuild-*
|
||||
|
|
|
|||
54
.github/workflows/ci-devcontainer.yml
vendored
54
.github/workflows/ci-devcontainer.yml
vendored
|
|
@ -1,10 +1,9 @@
|
|||
name: Devcontainer Smoke
|
||||
|
||||
# Smoke-tests .devcontainer/ whenever it changes. Two things happen here.
|
||||
# First, unit tests run on the pure host->container config transforms: the
|
||||
# plugin-registry path translation, and the strip of the machine field from
|
||||
# $HOME/.claude.json. Second, the devcontainer image is built through the
|
||||
# standard @devcontainers/cli path. That CLI reads build.args from
|
||||
# First, unit tests cover host->container config transforms and actual Husky
|
||||
# pre-commit enforcement in temporary repositories. Second, the image is built
|
||||
# through the standard @devcontainers/cli path. That CLI reads build.args from
|
||||
# devcontainer.json, so the version pin there stays the single source of truth.
|
||||
on:
|
||||
push:
|
||||
|
|
@ -12,10 +11,12 @@ on:
|
|||
paths:
|
||||
- '.devcontainer/**'
|
||||
- '.github/workflows/ci-devcontainer.yml'
|
||||
- 'gitnexus-web/package-lock.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.devcontainer/**'
|
||||
- '.github/workflows/ci-devcontainer.yml'
|
||||
- 'gitnexus-web/package-lock.json'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -39,20 +40,31 @@ jobs:
|
|||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Unit-test the host->container config transforms
|
||||
run: node --test .devcontainer/translate-plugin-registries.test.cjs
|
||||
# Hook tests install real Husky in temporary repositories.
|
||||
- name: Install hook test tooling
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
node .devcontainer/verify-git-hooks.cjs
|
||||
- name: Unit-test devcontainer setup and hooks
|
||||
run: node --test .devcontainer/*.test.cjs
|
||||
- name: Syntax-check the lifecycle shell scripts
|
||||
run: |
|
||||
bash -n .devcontainer/install-deps.sh
|
||||
bash -n .devcontainer/post-create.sh
|
||||
bash -n .devcontainer/seed-codex.sh
|
||||
bash -n .devcontainer/tune-9p.sh
|
||||
|
||||
build:
|
||||
name: Build devcontainer image
|
||||
name: Build devcontainer image (${{ matrix.profile }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
profile: [default, ui]
|
||||
steps:
|
||||
# persist-credentials: false — this is a read-only build smoke that
|
||||
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
|
||||
|
|
@ -60,24 +72,22 @@ jobs:
|
|||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Generate UI profile
|
||||
if: matrix.profile == 'ui'
|
||||
run: node .devcontainer/profile.cjs ui
|
||||
# Builds the image the same way a developer's "Reopen in Container" does.
|
||||
# @devcontainers/cli reads devcontainer.json (jsonc format), resolves
|
||||
# build.args (the CLAUDE_CODE_VERSION / CODEX_VERSION pins), and runs the
|
||||
# Dockerfile. This smoke catches Dockerfile regressions and any drift from
|
||||
# the canonical version pins. The lifecycle hooks (post-create.sh) do not
|
||||
# build.args (including BUN_VERSION), and runs the Dockerfile. This smoke
|
||||
# catches Dockerfile regressions, including native CLI installation.
|
||||
# The lifecycle hooks (post-create.sh) do not
|
||||
# run here. They need the host config mounts, and CI has none.
|
||||
#
|
||||
# ARCH COVERAGE: this runs on an x64 runner with no --platform or QEMU, so
|
||||
# it builds only the amd64 Cursor branch (CURSOR_SHA256_X64). The arm64
|
||||
# branch (CURSOR_SHA256_ARM64 plus the arm64 tarball URL) is pinned by a
|
||||
# sha256 checked against the published artifact, but it is not BUILT here.
|
||||
# Cursor's extract-and-symlink step does not depend on the architecture, so
|
||||
# the only remaining gap is a stale arm64 URL or hash. If that becomes a
|
||||
# concern, add a linux/arm64 matrix leg (docker/setup-qemu-action plus
|
||||
# `--platform`).
|
||||
# it builds only amd64. Native CLI installers also support arm64, but that
|
||||
# path is not built here. Add QEMU and a linux/arm64 matrix leg if needed.
|
||||
#
|
||||
# The @devcontainers/cli version is pinned on purpose. A bare
|
||||
# `npx --yes @devcontainers/cli` would resolve @latest at run time. A
|
||||
|
|
@ -109,10 +119,12 @@ jobs:
|
|||
echo "::warning::failed to pre-pull ${img} after 3 attempts; continuing — build step may still succeed"
|
||||
exit 1
|
||||
- name: Build devcontainer via @devcontainers/cli
|
||||
env:
|
||||
DEVCONTAINER_CONFIG: ${{ matrix.profile == 'ui' && '.devcontainer/generated/devcontainer.json' || '.devcontainer/devcontainer.json' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2; do
|
||||
if npx --yes @devcontainers/cli@0.87.0 build --workspace-folder .; then
|
||||
if npx --yes @devcontainers/cli@0.87.0 build --workspace-folder . --config "$DEVCONTAINER_CONFIG" --frozen-lockfile; then
|
||||
if [ "$attempt" -eq 2 ]; then
|
||||
echo "::notice::devcontainer build retry succeeded (attempt 2); investigate if this recurs across runs."
|
||||
fi
|
||||
|
|
|
|||
5
.github/workflows/ci-e2e.yml
vendored
5
.github/workflows/ci-e2e.yml
vendored
|
|
@ -20,6 +20,9 @@ jobs:
|
|||
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v3
|
||||
id: filter
|
||||
with:
|
||||
# Generated parser diffs can exceed GitHub's PR-files API limits.
|
||||
# Use the action's local Git comparison against the PR base instead.
|
||||
token: ''
|
||||
filters: |
|
||||
web:
|
||||
- 'gitnexus-web/**'
|
||||
|
|
@ -89,7 +92,7 @@ jobs:
|
|||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: e2e-results
|
||||
path: |
|
||||
|
|
|
|||
5
.github/workflows/ci-quality.yml
vendored
5
.github/workflows/ci-quality.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
@ -80,6 +80,7 @@ jobs:
|
|||
# 3. The concurrency group key starts with `${{ github.workflow }}` or
|
||||
# the literal `CI-` prefix (the documented ci.yml exception for
|
||||
# reusable-workflow-safe grouping).
|
||||
# The two EC2 workflows share an explicit resource lock through cleanup.
|
||||
# Reusability is detected by parsing each workflow's `on:` block, not an
|
||||
# allowlist, so new reusable workflows never produce false positives.
|
||||
workflow-convention:
|
||||
|
|
|
|||
42
.github/workflows/ci-report.yml
vendored
42
.github/workflows/ci-report.yml
vendored
|
|
@ -43,14 +43,16 @@ jobs:
|
|||
const path = require('path');
|
||||
const runId = context.payload.workflow_run.id;
|
||||
|
||||
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
// Sharded CI runs can upload more than one page of artifacts.
|
||||
const allArtifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
async function downloadArtifact(name, dest) {
|
||||
const match = allArtifacts.data.artifacts.find(a => a.name === name);
|
||||
const match = allArtifacts.find(a => a.name === name);
|
||||
if (!match) {
|
||||
core.warning(`Artifact "${name}" not found`);
|
||||
return false;
|
||||
|
|
@ -69,10 +71,13 @@ jobs:
|
|||
const temp = process.env.RUNNER_TEMP;
|
||||
await downloadArtifact('pr-meta', path.join(temp, 'dl'));
|
||||
await downloadArtifact('test-reports', path.join(temp, 'dl'));
|
||||
await downloadArtifact('test-dupes', path.join(temp, 'dl'));
|
||||
|
||||
- name: Extract artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
# With no artifacts, let the metadata step skip the report cleanly.
|
||||
mkdir -p "$RUNNER_TEMP/dl"
|
||||
cd "$RUNNER_TEMP/dl"
|
||||
# Extract each artifact into its own directory to avoid filename collisions
|
||||
for z in *.zip; do
|
||||
|
|
@ -159,13 +164,14 @@ jobs:
|
|||
|
||||
// Try each run until we find a downloadable test-reports artifact
|
||||
for (const run of runs.data.workflow_runs) {
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
const testReports = artifacts.find(a => a.name === 'test-reports');
|
||||
if (!testReports) {
|
||||
core.info(`Run ${run.id}: no test-reports artifact, trying next`);
|
||||
continue;
|
||||
|
|
@ -456,6 +462,34 @@ jobs:
|
|||
echo ""
|
||||
fi
|
||||
|
||||
# ── Possible duplicate tests (advisory, ci.yml test-dupes job) ──
|
||||
# The JSON is produced by PR code, so only whitelisted kinds and
|
||||
# strict path/line shapes reach the comment; free text is dropped.
|
||||
DUPES_FILE=$(find_first "$DIR/test-dupes" "test-dupes.json")
|
||||
if [ -n "$DUPES_FILE" ]; then
|
||||
jq -r '
|
||||
def path_ok: type == "string" and test("\\A[A-Za-z0-9._/@+-]{1,300}\\z");
|
||||
def line_ok: type == "number" and . >= 1 and . <= 1000000 and floor == .;
|
||||
def loc: "`\(.file):\(.line)`";
|
||||
[ .findings[]?
|
||||
| select(.kind | IN("same-test", "assertions-pinned", "same-fixture-setup", "copied-helper", "orphan-bench-gate"))
|
||||
| select((.file | path_ok) and (.line | line_ok))
|
||||
| .matches = ([(.matches // [])[]? | select((.file | path_ok) and (.line | line_ok))][:3])
|
||||
]
|
||||
| if length == 0 then empty else
|
||||
"### Possible duplicate tests",
|
||||
"",
|
||||
"Advisory only, never blocks merge: \(length) finding(s) on lines this PR changes. Run `npm run test:dupes` in `gitnexus/` for details; mark an intentional repeat with `// dupe-ok: <reason>`.",
|
||||
"",
|
||||
"| Kind | Changed | Already at |",
|
||||
"|------|---------|------------|",
|
||||
(.[:20][] | "| `\(.kind)` | \(loc) | \(if (.matches | length) == 0 then "no workflow runs it" else (.matches | map(loc) | join("<br>")) end) |"),
|
||||
(if length > 20 then "", "…and \(length - 20) more." else empty end),
|
||||
""
|
||||
end
|
||||
' "$DUPES_FILE" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "<sub>📋 [View full run](${RUN_URL}) · Generated by CI</sub>"
|
||||
echo "GITNEXUS_CI_REPORT_EOF_7f3a"
|
||||
|
|
|
|||
567
.github/workflows/ci-tests.yml
vendored
567
.github/workflows/ci-tests.yml
vendored
|
|
@ -7,6 +7,89 @@ permissions:
|
|||
contents: read
|
||||
|
||||
jobs:
|
||||
mcp-query-benchmark:
|
||||
name: MCP query performance
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- name: Pin comparison revision
|
||||
id: baseline
|
||||
shell: bash
|
||||
run: printf 'sha=%s\n' "$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT"
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ steps.baseline.outputs.sha }}
|
||||
path: baseline
|
||||
persist-credentials: false
|
||||
- name: Build baseline runtime
|
||||
working-directory: baseline/gitnexus
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npx tsx scripts/ensure-fts.ts
|
||||
- name: Install search extensions
|
||||
working-directory: gitnexus
|
||||
run: npx tsx scripts/ensure-fts.ts
|
||||
- name: Measure paired MCP requests
|
||||
working-directory: gitnexus
|
||||
run: node bench/mcp-query/measure.mjs --baseline-root ../baseline/gitnexus --out "$RUNNER_TEMP/mcp-query" --check
|
||||
- name: Publish performance summary
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f "$RUNNER_TEMP/mcp-query/report.md" ]]; then
|
||||
cat "$RUNNER_TEMP/mcp-query/report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- name: Retain MCP measurements
|
||||
if: always()
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: mcp-query-performance
|
||||
path: |
|
||||
${{ runner.temp }}/mcp-query/report.json
|
||||
${{ runner.temp }}/mcp-query/report.md
|
||||
${{ runner.temp }}/mcp-query/raw.jsonl
|
||||
${{ runner.temp }}/mcp-query/baseline.log
|
||||
${{ runner.temp }}/mcp-query/candidate.log
|
||||
${{ runner.temp }}/mcp-query/index.log
|
||||
retention-days: 30
|
||||
|
||||
tool-accuracy:
|
||||
name: release / deterministic tool accuracy
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
- name: Check fixed answers against real tools
|
||||
working-directory: gitnexus
|
||||
run: node --import tsx bench/tool-accuracy/run.ts --check --out "$RUNNER_TEMP/tool-accuracy"
|
||||
- name: Publish accuracy summary
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f "$RUNNER_TEMP/tool-accuracy/accuracy.md" ]]; then
|
||||
cat "$RUNNER_TEMP/tool-accuracy/accuracy.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- name: Upload release accuracy evidence
|
||||
if: always()
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: release-tool-accuracy
|
||||
path: |
|
||||
${{ runner.temp }}/tool-accuracy/accuracy.json
|
||||
${{ runner.temp }}/tool-accuracy/accuracy.md
|
||||
retention-days: 30
|
||||
|
||||
# Ubuntu full-suite coverage, sharded. Each shard writes a vitest blob report
|
||||
# (carrying its slice of V8 coverage) with thresholds forced OFF — a single
|
||||
# shard's partial coverage can't meet the gate. The coverage-merge job below
|
||||
|
|
@ -56,6 +139,11 @@ jobs:
|
|||
- name: Ensure FTS + VECTOR extensions installed
|
||||
run: npx tsx scripts/ensure-fts.ts
|
||||
working-directory: gitnexus
|
||||
- name: Download frozen timing snapshot
|
||||
uses: ./.github/actions/download-test-artifact
|
||||
with:
|
||||
name: shard-timing-input
|
||||
path: gitnexus/.vitest/timings
|
||||
- name: Run sharded tests with coverage (blob)
|
||||
# Shard via env var (not `${{ }}` inlined into the shell) so it isn't a
|
||||
# template-injection sink; shell: bash makes "$SHARD" expand uniformly.
|
||||
|
|
@ -77,7 +165,7 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
- name: Upload coverage blob
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: coverage-blob-${{ matrix.shard }}
|
||||
path: gitnexus/.vitest/blob/
|
||||
|
|
@ -107,16 +195,22 @@ jobs:
|
|||
with:
|
||||
build: 'true'
|
||||
- name: Download coverage blobs
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
pattern: coverage-blob-*
|
||||
path: gitnexus/.vitest/blob
|
||||
merge-multiple: true
|
||||
- name: Download frozen timing snapshot
|
||||
uses: ./.github/actions/download-test-artifact
|
||||
with:
|
||||
name: shard-timing-input
|
||||
path: gitnexus/.vitest/timings
|
||||
- name: Merge coverage + enforce thresholds
|
||||
run: >-
|
||||
npx vitest --mergeReports=.vitest/blob
|
||||
--reporter=default
|
||||
--reporter=./scripts/execution-reporter.ts
|
||||
--reporter=./scripts/shard-timings-reporter.ts
|
||||
--outputFile=test-results.json
|
||||
--coverage
|
||||
--coverage.reporter=json-summary
|
||||
|
|
@ -124,6 +218,12 @@ jobs:
|
|||
--coverage.reporter=text
|
||||
--coverage.thresholds.autoUpdate=false
|
||||
working-directory: gitnexus
|
||||
- name: Upload learned coverage timings
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: timings-coverage
|
||||
path: gitnexus/.vitest/timings/coverage.json
|
||||
include-hidden-files: true
|
||||
# gitnexus-shared already built by setup-gitnexus above
|
||||
- name: Install gitnexus-web dependencies
|
||||
run: npm ci
|
||||
|
|
@ -140,7 +240,7 @@ jobs:
|
|||
run: node --test docker-server.test.mjs
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: coverage-reports
|
||||
path: |
|
||||
|
|
@ -153,7 +253,8 @@ jobs:
|
|||
# Single source of truth for the platform-sensitive shard count. TOTAL below
|
||||
# generates both the shard index list (the matrix) and the /N denominator (job
|
||||
# name + --shard arg), so they can't drift — bump the shard count by editing
|
||||
# TOTAL alone. Checkout-free (ubuntu ships jq), so no credential surface.
|
||||
# TOTAL alone. Restore timing history once: independent restores in each shard
|
||||
# could select different histories while another run publishes an update.
|
||||
shard-plan:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
|
|
@ -162,10 +263,72 @@ jobs:
|
|||
cov_shards: ${{ steps.gen.outputs.cov_shards }}
|
||||
cov_total: ${{ steps.gen.outputs.cov_total }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Identify the first executing attempt
|
||||
id: execution
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CI_RUN_ID: ${{ github.run_id }}
|
||||
CI_RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
run: |
|
||||
initialize=false
|
||||
if [[ "$CI_RUN_ATTEMPT" == 1 ]]; then
|
||||
initialize=true
|
||||
elif [[ "$CI_RUN_ATTEMPT" == 2 ]]; then
|
||||
# Fork approval increments run_attempt even though no job ran.
|
||||
attempt="repos/$CI_REPOSITORY/actions/runs/$CI_RUN_ID/attempts/1"
|
||||
conclusion=$(gh api "$attempt" --jq .conclusion)
|
||||
if [[ "$conclusion" == action_required ]]; then
|
||||
jobs=$(gh api "$attempt/jobs" --jq .total_count)
|
||||
if [[ "$jobs" == 0 ]]; then initialize=true; fi
|
||||
fi
|
||||
fi
|
||||
printf 'initialize=%s\n' "$initialize" >> "$GITHUB_OUTPUT"
|
||||
- name: Reuse this run's timing input on a retry
|
||||
id: previous-input
|
||||
if: steps.execution.outputs.initialize != 'true'
|
||||
uses: ./.github/actions/download-test-artifact
|
||||
with:
|
||||
name: shard-timing-input
|
||||
path: .vitest/timings
|
||||
- name: Restore last trusted timing history
|
||||
if: steps.execution.outputs.initialize == 'true'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: .vitest/timings
|
||||
key: test-timings-v1-node22-vitest5-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: test-timings-v1-node22-vitest5-
|
||||
- name: Prepare timing snapshot
|
||||
if: steps.execution.outputs.initialize == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p .vitest/timings
|
||||
for profile in coverage windows-latest macos-latest; do
|
||||
file=".vitest/timings/$profile.json"
|
||||
if [ ! -f "$file" ]; then printf '{}\n' > "$file"; fi
|
||||
done
|
||||
sha256sum .vitest/timings/*.json >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Freeze timing input for every shard and retry
|
||||
if: steps.execution.outputs.initialize == 'true'
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: shard-timing-input
|
||||
path: .vitest/timings/*.json
|
||||
include-hidden-files: true
|
||||
retention-days: 30
|
||||
- id: gen
|
||||
run: |
|
||||
TOTAL=4 # cross-platform (windows/macOS) shards per OS
|
||||
COV_TOTAL=3 # ubuntu coverage shards (merged before thresholds)
|
||||
# Keep the recorded native workload below 17 minutes, leaving
|
||||
# headroom for runner variation under the 20-minute watchdog.
|
||||
TOTAL=11 # cross-platform (windows/macOS) shards per OS
|
||||
# Four coverage shards exceeded the 25-minute job limit while tests
|
||||
# were still passing. Split the full suite further; keep the timeout
|
||||
# and merged coverage thresholds unchanged.
|
||||
COV_TOTAL=8 # ubuntu coverage shards (merged before thresholds)
|
||||
if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then
|
||||
echo "shard totals must be >= 1" >&2; exit 1
|
||||
fi
|
||||
|
|
@ -187,17 +350,8 @@ jobs:
|
|||
matrix:
|
||||
# Ubuntu already covered by the coverage job above
|
||||
os: [windows-latest, macos-latest]
|
||||
# Shard the fixed file list across N runners per OS (N = TOTAL in the
|
||||
# shard-plan job). The suite is dominated by ~50 CLI/worker process
|
||||
# spawns and Windows is ~5x slower than macOS at those, so the unsharded
|
||||
# run crept past the 15-min watchdog in run-cross-platform.ts. vitest
|
||||
# shards by file COUNT, not runtime, so the heaviest spawn suites can
|
||||
# cluster on one shard. The busiest Windows shard has grown to the old
|
||||
# 15-minute watchdog (14m57s on the v1.6.10-rc.19 green run, one
|
||||
# observed timeout since — #2449), so the job env below raises the
|
||||
# per-shard watchdog to 20 minutes, still bounded by timeout-minutes.
|
||||
# Shard indices come from the shard-plan job (single source of truth):
|
||||
# its TOTAL drives this list and the /N in the job name + --shard arg.
|
||||
# Timing-based assignment uses the same frozen input for all N runners.
|
||||
# Keep the existing per-shard watchdog and matrix count.
|
||||
shard: ${{ fromJSON(needs.shard-plan.outputs.shards) }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 25
|
||||
|
|
@ -218,6 +372,7 @@ jobs:
|
|||
# failure, never a silent skip (same contract as GITNEXUS_REQUIRE_FTS).
|
||||
GITNEXUS_REQUIRE_VECTOR: '1'
|
||||
GITNEXUS_E2E_CLI: dist
|
||||
GITNEXUS_SHARD_PROFILE: ${{ matrix.os }}
|
||||
# #2449: hosted Windows runners intermittently push the busiest shard past
|
||||
# the default 15-minute watchdog. 20 minutes restores real headroom while
|
||||
# the 25-minute job timeout above still bounds a genuine hang.
|
||||
|
|
@ -248,6 +403,11 @@ jobs:
|
|||
- name: Ensure FTS + VECTOR extensions installed
|
||||
run: npx tsx scripts/ensure-fts.ts
|
||||
working-directory: gitnexus
|
||||
- name: Download frozen timing snapshot
|
||||
uses: ./.github/actions/download-test-artifact
|
||||
with:
|
||||
name: shard-timing-input
|
||||
path: gitnexus/.vitest/timings
|
||||
- name: Run platform-sensitive tests
|
||||
# Pass the shard through an env var (not `${{ }}` inlined into the shell)
|
||||
# so it isn't a template-injection sink (zizmor). shell: bash makes the
|
||||
|
|
@ -261,20 +421,87 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
- name: Upload platform execution report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: execution-${{ matrix.os }}-${{ matrix.shard }}
|
||||
path: gitnexus/${{ matrix.os }}-${{ matrix.shard }}.json
|
||||
if-no-files-found: error
|
||||
retention-days: 5
|
||||
- name: Upload platform timing blob
|
||||
if: always()
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: platform-blob-${{ matrix.os }}-${{ matrix.shard }}
|
||||
path: gitnexus/.vitest/blob/
|
||||
include-hidden-files: true
|
||||
|
||||
# Merge on the originating OS so absolute paths in Vitest blobs keep their
|
||||
# meaning. No tests are re-executed; the reporter validates the native subset.
|
||||
native-timings:
|
||||
name: ${{ matrix.os }} / shard timings
|
||||
needs: cross-platform
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
env:
|
||||
GITNEXUS_SHARD_PROFILE: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
lifecycle-scripts: 'false'
|
||||
- name: Download frozen timing snapshot
|
||||
uses: ./.github/actions/download-test-artifact
|
||||
with:
|
||||
name: shard-timing-input
|
||||
path: gitnexus/.vitest/timings
|
||||
- name: Download native blobs
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
pattern: platform-blob-${{ matrix.os }}-*
|
||||
path: gitnexus/.vitest/blob
|
||||
merge-multiple: true
|
||||
- name: Check native partition and learn timings
|
||||
run: npx vitest --mergeReports=.vitest/blob --reporter=default --reporter=./scripts/shard-timings-reporter.ts
|
||||
working-directory: gitnexus
|
||||
- name: Upload learned native timings
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: timings-${{ matrix.os }}
|
||||
path: gitnexus/.vitest/timings/${{ matrix.os }}.json
|
||||
include-hidden-files: true
|
||||
|
||||
# Only successful trusted main runs publish history. PRs consume it through
|
||||
# the planner; they never replace the baseline used by other PRs.
|
||||
save-shard-timings:
|
||||
needs: [coverage-merge, native-timings, test-completeness]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Download learned timings
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
pattern: timings-*
|
||||
path: .vitest/timings
|
||||
merge-multiple: true
|
||||
- name: Save timing history for later runs
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: .vitest/timings
|
||||
key: test-timings-v1-node22-vitest5-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
# Branch protection still requires these six names from the three-shard
|
||||
# matrix. Keep them as aggregate gates as the real matrix grows: every native
|
||||
# shard and the complete execution audit must pass before any gate succeeds.
|
||||
# shard, the complete execution audit, and the scope measurements must pass.
|
||||
# These jobs only verify dependency results; tests run in cross-platform above.
|
||||
protected-platform-checks:
|
||||
name: ${{ matrix.os }} (platform-sensitive) ${{ matrix.check }}/3
|
||||
needs: [cross-platform, test-completeness]
|
||||
needs: [cross-platform, test-completeness, scope-resolution-benchmark]
|
||||
if: always()
|
||||
permissions: {}
|
||||
strategy:
|
||||
|
|
@ -285,17 +512,19 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Require all native shards and the execution audit
|
||||
- name: Require native shards, execution audit, and scope measurements
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RESULT: ${{ needs.cross-platform.result }}
|
||||
EXECUTION_RESULT: ${{ needs.test-completeness.result }}
|
||||
SCOPE_BENCH_RESULT: ${{ needs.scope-resolution-benchmark.result }}
|
||||
run: |
|
||||
echo "Compatibility gate for the former three-shard check name."
|
||||
echo "All native shards: $NATIVE_RESULT"
|
||||
echo "Complete execution audit: $EXECUTION_RESULT"
|
||||
if [[ "$NATIVE_RESULT" != "success" || "$EXECUTION_RESULT" != "success" ]]; then
|
||||
echo "::error::Every native shard and the execution audit must succeed."
|
||||
echo "Scope-resolution measurements: $SCOPE_BENCH_RESULT"
|
||||
if [[ "$NATIVE_RESULT" != "success" || "$EXECUTION_RESULT" != "success" || "$SCOPE_BENCH_RESULT" != "success" ]]; then
|
||||
echo "::error::Every native shard, the execution audit, and scope-resolution measurements must succeed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -333,7 +562,7 @@ jobs:
|
|||
# exemption is revoked here and an ABI-broken Zig binding fails the
|
||||
# job instead of being accepted as a clean absence.
|
||||
- name: Run parser-loader ABI load-smoke (dynamic)
|
||||
run: npx vitest run test/unit/parser-loader-abi.test.ts
|
||||
run: npx vitest run test/unit/parser-loader-abi.test.ts test/unit/group/grpc-extractor.test.ts
|
||||
env:
|
||||
GITNEXUS_REQUIRE_ZIG: '1'
|
||||
working-directory: gitnexus
|
||||
|
|
@ -341,19 +570,21 @@ jobs:
|
|||
# End-to-end smoke test for the #1728 packaging fix: pack the published
|
||||
# tarball, install it globally into a temp prefix, and assert no junction
|
||||
# creation (the EPERM root cause) plus working CLI plus vendor cleanliness
|
||||
# (#836). Runs on windows-latest because that is the platform the fix
|
||||
# (#836) plus native grammar loading from the bundled runtime. Runs on windows-latest because that is the platform the fix
|
||||
# targets; the in-repo `npm ci` job above only exercises the dev-tree path
|
||||
# and skips the tarball reify step where the historical EPERM occurred.
|
||||
# Native Linux ARM64 also verifies the corrected bundled prebuilds (#3599).
|
||||
packaged-install-smoke:
|
||||
name: packaged install smoke (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
os: [windows-latest, macos-latest, ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Windows pack + web install regularly exceeds 15 minutes when setup also
|
||||
# runs prepare/postinstall/build before prepack compiles the same tree again.
|
||||
timeout-minutes: 20
|
||||
# Also exercise fresh npm, pnpm, and Bun installs on the three desktop OSes.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# persist-credentials: false — this job runs npm pack + npm install -g
|
||||
# from a tarball and never pushes back; the token in .git/config would
|
||||
|
|
@ -368,6 +599,18 @@ jobs:
|
|||
with:
|
||||
lifecycle-scripts: 'false'
|
||||
|
||||
# Reproduce #3599's runtime after the shared action selects Node 22.x.
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
if: matrix.os == 'ubuntu-24.04-arm'
|
||||
with:
|
||||
node-version: '22.23.1'
|
||||
package-manager-cache: false
|
||||
- name: Assert native ARM64 runtime
|
||||
if: matrix.os == 'ubuntu-24.04-arm'
|
||||
shell: bash
|
||||
run: |
|
||||
node -e "const assert = require('node:assert/strict'); assert.equal(process.platform, 'linux'); assert.equal(process.arch, 'arm64'); assert.equal(process.version, 'v22.23.1'); console.log(process.platform, process.arch, process.version)"
|
||||
|
||||
# `npm pack` runs prepack, which builds the web UI into gitnexus/web/
|
||||
# so the tarball matches what `npm publish` ships. Install those deps
|
||||
# here, in their own visible step, rather than letting build.js do it
|
||||
|
|
@ -382,6 +625,79 @@ jobs:
|
|||
run: npm pack
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Index the PR tarball with npm, pnpm, and Bun
|
||||
if: matrix.os != 'ubuntu-24.04-arm'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install --global pnpm@12.10.1 bun@1.3.14 --no-audit --no-fund
|
||||
node scripts/smoke-package-managers.mjs
|
||||
working-directory: gitnexus
|
||||
|
||||
# Block native compilation only in the consumer phase. A correct bundle
|
||||
# must install using prebuilds even when the runner has a toolchain.
|
||||
- name: Reject ARM64 consumer compilation
|
||||
if: matrix.os == 'ubuntu-24.04-arm'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TOOLS="$RUNNER_TEMP/gitnexus-no-compile"
|
||||
mkdir -p "$TOOLS"
|
||||
export GITNEXUS_COMPILER_SENTINEL_LOG="$RUNNER_TEMP/gitnexus-compile-attempts.log"
|
||||
cat > "$TOOLS/cc" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "Unexpected native compilation: $0 $*" >&2
|
||||
printf '%s\n' "$0 $*" >> "${GITNEXUS_COMPILER_SENTINEL_LOG:?}"
|
||||
exit 97
|
||||
EOF
|
||||
chmod +x "$TOOLS/cc"
|
||||
for tool in c++ gcc g++ clang clang++ make gmake; do
|
||||
cp "$TOOLS/cc" "$TOOLS/$tool"
|
||||
done
|
||||
# Prove the guard fails before relying on it, then clear its probe.
|
||||
if "$TOOLS/cc" --version; then
|
||||
echo 'Compiler sentinel unexpectedly succeeded' >&2
|
||||
exit 1
|
||||
else
|
||||
test "$?" -eq 97
|
||||
fi
|
||||
test -s "$GITNEXUS_COMPILER_SENTINEL_LOG"
|
||||
: > "$GITNEXUS_COMPILER_SENTINEL_LOG"
|
||||
echo "$TOOLS" >> "$GITHUB_PATH"
|
||||
{
|
||||
echo "GITNEXUS_COMPILER_SENTINEL_LOG=$GITNEXUS_COMPILER_SENTINEL_LOG"
|
||||
echo "CC=$TOOLS/cc"
|
||||
echo "CXX=$TOOLS/c++"
|
||||
echo "MAKE=$TOOLS/make"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
# Global installs do not exercise npm's bundled-dependency lockfile path
|
||||
# (#3563). Keep npm 10 here: newer npm can mask unresolved override refs.
|
||||
- name: Lock and install tarball in a fresh consumer project
|
||||
shell: bash
|
||||
env:
|
||||
SCARF_ANALYTICS: 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL=$(find "$PWD" -maxdepth 1 -name 'gitnexus-*.tgz' -print -quit)
|
||||
test -n "$TARBALL"
|
||||
for NPM_VERSION in 10.9.4 11.9.0; do
|
||||
CONSUMER=$(mktemp -d "$RUNNER_TEMP/gitnexus-consumer.XXXXXX")
|
||||
cp "$TARBALL" "$CONSUMER/gitnexus.tgz"
|
||||
(
|
||||
cd "$CONSUMER"
|
||||
echo '{"name":"gitnexus-consumer","version":"1.0.0","private":true,"devDependencies":{"gitnexus":"file:./gitnexus.tgz"}}' > package.json
|
||||
npm exec --yes --package="npm@$NPM_VERSION" -- npm --version
|
||||
npm exec --yes --package="npm@$NPM_VERSION" -- npm install --package-lock-only --ignore-scripts --no-audit --no-fund
|
||||
test ! -e node_modules
|
||||
cp package-lock.json expected-lock.json
|
||||
npm exec --yes --package="npm@$NPM_VERSION" -- npm ci --no-audit --no-fund
|
||||
cmp package-lock.json expected-lock.json
|
||||
node node_modules/gitnexus/dist/cli/index.js --version
|
||||
node node_modules/gitnexus/scripts/assert-native-parsers.mjs node_modules/gitnexus
|
||||
)
|
||||
done
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Install gitnexus tarball into isolated prefix
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
@ -398,6 +714,23 @@ jobs:
|
|||
echo "PREFIX=$PREFIX" >> "$GITHUB_ENV"
|
||||
working-directory: gitnexus
|
||||
|
||||
# npm can ignore failed optional-dependency scripts, so also inspect the
|
||||
# sentinel log after both installs rather than trusting exit status alone.
|
||||
- name: Assert no consumer compilation attempts
|
||||
if: always() && matrix.os == 'ubuntu-24.04-arm'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GITNEXUS_COMPILER_SENTINEL_LOG:-}" ] || [ ! -f "$GITNEXUS_COMPILER_SENTINEL_LOG" ]; then
|
||||
echo 'Compiler sentinel was not initialized' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -s "$GITNEXUS_COMPILER_SENTINEL_LOG" ]; then
|
||||
echo 'Unexpected native compilation during consumer installation:' >&2
|
||||
cat "$GITNEXUS_COMPILER_SENTINEL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Assert no junctions or vendor build artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
@ -421,6 +754,10 @@ jobs:
|
|||
# installed artifact, not just the source workflow that produced it.
|
||||
node "$INSTALLED/scripts/assert-web-assets.mjs" "$INSTALLED/web"
|
||||
|
||||
# Every bundled/vendored grammar must load and parse from the
|
||||
# installed tree (main thread + worker), not just the dev checkout.
|
||||
node "$INSTALLED/scripts/assert-native-parsers.mjs" "$INSTALLED"
|
||||
|
||||
# #836 invariant: no node_modules/ or build/ under any vendor/*.
|
||||
BAD=$(find "$INSTALLED/vendor" \( -name node_modules -o -name build \) -print 2>/dev/null || true)
|
||||
if [ -n "$BAD" ]; then
|
||||
|
|
@ -475,7 +812,7 @@ jobs:
|
|||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
|
@ -490,7 +827,7 @@ jobs:
|
|||
# Switch to the engines-floor Node AFTER building — native deps built on
|
||||
# 22.x load across the whole 22.x ABI line, and nothing installs after this
|
||||
# (so no package-manager cache is needed).
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
package-manager-cache: false
|
||||
|
|
@ -513,6 +850,24 @@ jobs:
|
|||
done
|
||||
working-directory: gitnexus
|
||||
|
||||
# Native embedding replacement measures real COPY, writes and cold reopens.
|
||||
# Keep it outside the 25-minute language benchmark job: four trials at each
|
||||
# size take several minutes even on the development baseline machine.
|
||||
embedding-identity-benchmark:
|
||||
name: embedding identity persistence benchmark
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
- name: Embedding identity persistence / scaling guards (#3569)
|
||||
run: node --import tsx bench/embedding-identities/measure.cjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
# ── Dedicated benchmark gate ─────────────────────────────────────
|
||||
# The cross-language `*-pipeline-benchmark.test.ts` suites are gated behind
|
||||
# GITNEXUS_BENCH (they generate synthetic codebases at scale), so the main
|
||||
|
|
@ -529,7 +884,8 @@ jobs:
|
|||
benchmarks:
|
||||
name: benchmarks (GITNEXUS_BENCH)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
# The callable size matrix runs 63 full analyses and 18 heap probes.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# persist-credentials: false — this job only runs npm + vitest benchmarks
|
||||
# and never pushes; the default-persisted token in .git/config would be at
|
||||
|
|
@ -548,6 +904,19 @@ jobs:
|
|||
node --import tsx bench/python-scope/import-target-fingerprint.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Callable declaration facts / time / memory baseline
|
||||
if: ${{ !cancelled() }}
|
||||
run: node --import tsx bench/method-declaration-facts/measure.mjs --check --output method-declaration-facts.json
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Upload callable declaration measurements
|
||||
if: always()
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: method-declaration-facts-benchmark
|
||||
path: gitnexus/method-declaration-facts.json
|
||||
retention-days: 5
|
||||
|
||||
- name: Java wildcard-static route constant guards (#3110)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: named-import control vs wildcard materialization;
|
||||
|
|
@ -571,6 +940,19 @@ jobs:
|
|||
run: node --import tsx bench/trpc-route-extractor/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Go route-fact correctness and linear scaling (#3750)
|
||||
if: ${{ !cancelled() }}
|
||||
run: node --expose-gc --import tsx bench/go-route-facts/measure.js --check --output go-route-facts.json
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Upload Go route-fact measurements
|
||||
if: always()
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: go-route-facts-benchmark
|
||||
path: gitnexus/go-route-facts.json
|
||||
retention-days: 5
|
||||
|
||||
- name: Cross-language scope-capture fingerprint + scaling guards
|
||||
# Runs even after an earlier guard fails (#2895). Every step here was
|
||||
# fail-fast, so the FIRST failing --check aborted the job and every guard
|
||||
|
|
@ -586,6 +968,16 @@ jobs:
|
|||
run: node --import tsx bench/scope-capture/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Rename output construction scaling guard
|
||||
if: ${{ !cancelled() }}
|
||||
run: node --import tsx bench/rename/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Generated embedding input budget / scaling guards (#3607)
|
||||
if: ${{ !cancelled() }}
|
||||
run: node --import tsx bench/embedding-inputs/measure.cjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Callable-value-flow target-index guards (#2693)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts buildGraphTargetIndex resolves an unchanged target
|
||||
|
|
@ -670,7 +1062,7 @@ jobs:
|
|||
if: ${{ !cancelled() }}
|
||||
# Build-free: same baseline approach as parse-dispatch-rounds —
|
||||
# exact declared/SDK/undeclared floors plus a fingerprint, then
|
||||
# ratio timing only (resolveSwiftImportTarget, swiftPackageStrategy,
|
||||
# ratio timing only (resolveSwiftImportTarget,
|
||||
# parseSwiftPackageManifest 4n/n). Pins declaration-only resolve,
|
||||
# https:// factory survival, and #2931 segment-boundary membership.
|
||||
# See bench/swift-package-imports/measure.mjs.
|
||||
|
|
@ -893,6 +1285,18 @@ jobs:
|
|||
run: node --expose-gc --import tsx bench/cfg/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Inline handler capture / visibility guards (#3489)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: pins inline/returned definitions and private-name refusals,
|
||||
# then guards capture and fallback-cache scaling.
|
||||
run: node --import tsx bench/inline-taint/measure.cjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Staged publication durability / scaling guards (#3591)
|
||||
if: ${{ !cancelled() }}
|
||||
run: node --import tsx bench/staged-publication/measure.cjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Emit-persistence throughput / byte-identity guards (#2203)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts streamAllCSVsToDisk output is byte-identical
|
||||
|
|
@ -913,6 +1317,11 @@ jobs:
|
|||
run: node --import tsx bench/emit-persistence/measure-streaming.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Directory symlink coverage / scaling guards (#3608)
|
||||
if: ${{ !cancelled() }}
|
||||
run: node --import tsx bench/symlink-coverage/measure.cjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
|
|
@ -921,11 +1330,54 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
- name: Upload benchmark execution report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: execution-benchmarks
|
||||
path: gitnexus/benchmarks.json
|
||||
if-no-files-found: error
|
||||
retention-days: 5
|
||||
|
||||
# Fresh-process measurements have their own runner and time budget so they
|
||||
# neither contend with nor consume the existing benchmark gate's allowance.
|
||||
scope-resolution-benchmark:
|
||||
name: scope resolution time / memory (all languages)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
- name: Measure all languages and check graph and binding baselines
|
||||
run: node --import tsx bench/scope-resolution/measure.mts --check --output scope-resolution-benchmark.json
|
||||
working-directory: gitnexus
|
||||
# #3695 probes share this runner and artifact; the language matrix runs once.
|
||||
- name: Check ordinary-call signature allocation
|
||||
shell: bash
|
||||
run: node --expose-gc --import tsx bench/scope-resolution/call-signature-memory.mts 500000 --check | tee scope-resolution-call-signature-memory.json
|
||||
working-directory: gitnexus
|
||||
- name: Measure dense Java and check its graph baseline
|
||||
shell: bash
|
||||
env:
|
||||
GITNEXUS_DEBUG_HEAP: '1'
|
||||
run: node --import tsx bench/scope-resolution/dense-java.mts 300 20 10 --check 2>&1 | tee scope-resolution-dense-java.log
|
||||
working-directory: gitnexus
|
||||
- name: Check Java overload and ingestion scaling
|
||||
run: node bench/java-overload-resolution/measure.js --check --output java-overload-resolution-benchmark.json
|
||||
working-directory: gitnexus
|
||||
- name: Upload scope-resolution measurements
|
||||
if: always()
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: scope-resolution-benchmark
|
||||
path: |
|
||||
gitnexus/scope-resolution-benchmark.json
|
||||
gitnexus/scope-resolution-call-signature-memory.json
|
||||
gitnexus/scope-resolution-dense-java.log
|
||||
gitnexus/java-overload-resolution-benchmark.json
|
||||
retention-days: 5
|
||||
|
||||
# Locked eval suite. setup-uv and uv itself are immutable so CI exercises
|
||||
|
|
@ -939,7 +1391,7 @@ jobs:
|
|||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
- uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
|
|
@ -949,11 +1401,10 @@ jobs:
|
|||
working-directory: eval
|
||||
- name: Upload pytest execution report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: execution-pytest-locked
|
||||
path: eval/pytest-locked.xml
|
||||
if-no-files-found: error
|
||||
retention-days: 5
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
|
|
@ -966,11 +1417,10 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
- name: Upload preflight execution report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: execution-preflight
|
||||
path: gitnexus/preflight.json
|
||||
if-no-files-found: error
|
||||
retention-days: 5
|
||||
|
||||
test-completeness:
|
||||
|
|
@ -994,12 +1444,12 @@ jobs:
|
|||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- name: Download coverage and web reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
name: coverage-reports
|
||||
- name: Download all execution receipts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
pattern: execution-*
|
||||
path: execution-reports
|
||||
|
|
@ -1016,7 +1466,7 @@ jobs:
|
|||
run: python3 eval/check_test_execution.py execution-reports eval/pytest-results.json
|
||||
- name: Upload complete test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: test-reports
|
||||
path: |
|
||||
|
|
@ -1025,7 +1475,6 @@ jobs:
|
|||
gitnexus/test-results.json
|
||||
gitnexus-web/web-test-results.json
|
||||
eval/pytest-results.json
|
||||
if-no-files-found: error
|
||||
retention-days: 5
|
||||
|
||||
# Native Linux ownership and Bubblewrap boundary. The environment flag makes
|
||||
|
|
@ -1046,14 +1495,16 @@ jobs:
|
|||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
# Oracle controls replay the immutable historical task sources.
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
gitnexus/package-lock.json
|
||||
gitnexus-shared/package-lock.json
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
- uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
|
|
@ -1091,28 +1542,45 @@ jobs:
|
|||
npm ci
|
||||
npm run build
|
||||
working-directory: gitnexus
|
||||
# Release evaluation grades tasks with dependencies built at the pinned
|
||||
# task commit; prove the hidden oracles against that same toolchain.
|
||||
- name: Build the pinned task toolchain that grades release evaluation
|
||||
working-directory: eval
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(uv run --locked --extra dev python -m workflow_bench.release_report task-sha)"
|
||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
git -C .. worktree add --detach "${RUNNER_TEMP}/tasks-base" "$sha"
|
||||
uv run --locked --extra dev python -m workflow_bench.release_build \
|
||||
--repo "${RUNNER_TEMP}/tasks-base"
|
||||
- name: Prove process-tree and sandbox containment
|
||||
env:
|
||||
CLAUDE_CANARY_BIN: ${{ runner.temp }}/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude
|
||||
GITNEXUS_ORACLE_TASK_DEPENDENCIES: ${{ runner.temp }}/tasks-base
|
||||
run: >-
|
||||
uv run --locked --extra dev python -m pytest
|
||||
tests/test_process_control.py
|
||||
tests/test_proposer_sandbox.py
|
||||
tests/test_cli_shim.py
|
||||
tests/test_workflow_bench_sessions.py
|
||||
tests/test_ce_plugin_runtime.py
|
||||
tests/test_offline_sweep_integration.py
|
||||
tests/test_mock_provider.py
|
||||
tests/test_mcp_tool_loading.py
|
||||
tests/test_release_evaluation_smoke.py
|
||||
tests/test_release_controls.py
|
||||
tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant
|
||||
tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout
|
||||
tests/test_oracle_assets.py::test_shipped_oracles_reject_task_base_and_accept_reference_fixes
|
||||
tests/test_oracle_assets.py::test_in_flight_retry_oracle_rejects_startup_only_repairs
|
||||
-q --junitxml=pytest-ubuntu.xml
|
||||
working-directory: eval
|
||||
- name: Upload pytest execution report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: execution-pytest-ubuntu
|
||||
path: eval/pytest-ubuntu.xml
|
||||
if-no-files-found: error
|
||||
retention-days: 5
|
||||
|
||||
# Native Windows Job Object canary. POSIX-only tests skip by platform, while
|
||||
|
|
@ -1125,7 +1593,7 @@ jobs:
|
|||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
- uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
|
|
@ -1139,9 +1607,8 @@ jobs:
|
|||
working-directory: eval
|
||||
- name: Upload pytest execution report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: ./.github/actions/upload-test-artifact
|
||||
with:
|
||||
name: execution-pytest-windows
|
||||
path: eval/pytest-windows.xml
|
||||
if-no-files-found: error
|
||||
retention-days: 5
|
||||
|
|
|
|||
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
|
|
@ -48,6 +48,37 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
# ── Advisory duplicate-test scan ─────────────────────────────────
|
||||
# Comment-only: ci-report.yml renders this JSON into the sticky PR
|
||||
# comment. Kept out of `ci-status.needs` and marked continue-on-error,
|
||||
# so neither a finding nor a scanner crash can block a merge.
|
||||
# fetch-depth 2: HEAD is the PR merge commit, HEAD^1 the base tip.
|
||||
test-dupes:
|
||||
name: Duplicate test scan
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
lifecycle-scripts: 'false'
|
||||
- name: Scan lines this PR changes
|
||||
run: npm run test:dupes -- --base HEAD^1 --json ../test-dupes/test-dupes.json
|
||||
working-directory: gitnexus
|
||||
- name: Upload scan result
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: test-dupes
|
||||
path: test-dupes/
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ── 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
|
||||
|
|
@ -85,7 +116,7 @@ jobs:
|
|||
cp pr-meta/e2e_result pr-meta/e2e-result
|
||||
|
||||
- name: Upload PR metadata
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: pr-meta
|
||||
path: pr-meta/
|
||||
|
|
|
|||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
|
@ -71,6 +71,10 @@ jobs:
|
|||
# deliberately contain use-before-init / unused-variable shapes).
|
||||
- '**/test/fixtures/**'
|
||||
- '**/test/**/fixtures/**'
|
||||
# Parsed-only accuracy corpus includes deliberate command injection
|
||||
# and broken Python imports/returns. It is never executed or packed;
|
||||
# retain security analysis of the surrounding benchmark harness.
|
||||
- 'gitnexus/bench/tool-accuracy/fixtures/**'
|
||||
# GET /api/grep intentionally builds RegExp from the query string
|
||||
# (literal=1 escapes). ReDoS is handled by worker terminate() —
|
||||
# see SECURITY.md. Inline codeql[] comments do not clear the
|
||||
|
|
@ -87,6 +91,6 @@ jobs:
|
|||
- '.github/scripts/fetch-lbug-fts-artifacts.mjs'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
|
|
|||
4
.github/workflows/commit-fork-prebuilds.yml
vendored
4
.github/workflows/commit-fork-prebuilds.yml
vendored
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
steps:
|
||||
# Pinned to v8.0.1 (same SHA used across this repo's workflows).
|
||||
- name: Download prebuild artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
path: prebuilds-in
|
||||
|
||||
- name: Download PR meta
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: pr-meta
|
||||
|
|
|
|||
75
.github/workflows/devcontainer-publish.yml
vendored
Normal file
75
.github/workflows/devcontainer-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
name: Publish Development Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.devcontainer/**'
|
||||
- 'gitnexus-web/package-lock.json'
|
||||
- '.github/workflows/devcontainer-publish.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
profile: [core, ui]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Verify development tooling before publishing
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
node --test .devcontainer/*.test.cjs
|
||||
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Generate toolchain-only configuration
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: |
|
||||
if [ "$PROFILE" = ui ]; then
|
||||
node .devcontainer/profile.cjs image-build --ui
|
||||
else
|
||||
node .devcontainer/profile.cjs image-build
|
||||
fi
|
||||
- name: Build both architectures, cache and publish
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="ghcr.io/${GITHUB_REPOSITORY,,}-devcontainer"
|
||||
suffix=""
|
||||
if [ "$PROFILE" = ui ]; then suffix="-ui"; fi
|
||||
tag="${image}:sha-${GITHUB_SHA}-run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${suffix}"
|
||||
cache="${image}:buildcache-${PROFILE}"
|
||||
npx --yes @devcontainers/cli@0.87.0 build \
|
||||
--workspace-folder . --config .devcontainer/generated/devcontainer.json \
|
||||
--frozen-lockfile --platform linux/amd64,linux/arm64 \
|
||||
--image-name "$tag" --push true \
|
||||
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
|
||||
--cache-from "$cache" --cache-to "type=registry,ref=${cache},mode=max"
|
||||
docker buildx imagetools create --tag "${image}:main${suffix}" "$tag"
|
||||
echo "Published ${tag}" >> "$GITHUB_STEP_SUMMARY"
|
||||
150
.github/workflows/docker.yml
vendored
150
.github/workflows/docker.yml
vendored
|
|
@ -51,8 +51,88 @@ concurrency:
|
|||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-gate:
|
||||
name: Verify Docker release evidence
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
sha: ${{ steps.version.outputs.sha }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Verify publishing mode and version tag
|
||||
id: version
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run == true && 'true' || 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
# Reusable calls inherit the caller's event. An explicit tag is what
|
||||
# distinguishes an RC publish call from a direct manual dry run.
|
||||
if [[ "$EVENT_NAME" == 'pull_request' ]] || \
|
||||
[[ "$EVENT_NAME" == 'workflow_dispatch' && -z "$INPUT_TAG" && "$INPUT_DRY_RUN" == 'true' ]]; then
|
||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ -n "$INPUT_TAG" ]]; then
|
||||
TAG="$INPUT_TAG"
|
||||
elif [[ "$EVENT_NAME" == 'push' && "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
else
|
||||
echo "::error::Docker publication requires a version tag; direct manual dispatch is dry-run only."
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$ ]]; then
|
||||
echo "::error::Docker publication requires a v-prefixed semver tag."
|
||||
exit 1
|
||||
fi
|
||||
TAG_VERSION="${BASH_REMATCH[1]}"
|
||||
if ! TAG_SHA=$(git rev-parse --verify "refs/tags/$TAG^{commit}" 2>/dev/null) || \
|
||||
[[ "$TAG_SHA" != "$(git rev-parse HEAD)" ]]; then
|
||||
echo "::error::The checkout must be the commit referenced by the version tag."
|
||||
exit 1
|
||||
fi
|
||||
PKG_VERSION=$(node -p "require('./gitnexus/package.json').version")
|
||||
if [[ "$TAG_VERSION" != "$PKG_VERSION" ]]; then
|
||||
echo "::error::Tag version does not match gitnexus/package.json version."
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Version verified: $PKG_VERSION"
|
||||
# One cheap run gates both image builds. Never consume an arbitrary
|
||||
# accuracy artifact from another workflow or repeat the full CI suite.
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
with:
|
||||
build: 'true'
|
||||
- name: Check fixed answers before Docker publication
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
working-directory: gitnexus
|
||||
run: node --import tsx bench/tool-accuracy/run.ts --check --out "$RUNNER_TEMP/tool-accuracy"
|
||||
- name: Publish accuracy summary
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f "$RUNNER_TEMP/tool-accuracy/accuracy.md" ]]; then
|
||||
cat "$RUNNER_TEMP/tool-accuracy/accuracy.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- name: Require paired agent evidence before stable Docker publication
|
||||
if: steps.version.outputs.publish == 'true' && !contains(steps.version.outputs.version, '-')
|
||||
uses: ./.github/actions/require-agent-release-evidence
|
||||
|
||||
build-push:
|
||||
name: Build & Push ${{ matrix.image.name }}
|
||||
needs: release-gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
|
|
@ -83,60 +163,12 @@ jobs:
|
|||
slug: gitnexus
|
||||
|
||||
steps:
|
||||
# Only the workflow_call path requires a non-empty `inputs.tag` — callers
|
||||
# (publish.yml in RC mode) must pass the RC tag explicitly. On direct
|
||||
# tag pushes the tag comes from `github.ref`, so `inputs.tag` is always
|
||||
# empty and validating it here would break every real release (#1064).
|
||||
# The downstream "Verify tag matches gitnexus/package.json version" step
|
||||
# handles both event types by falling back to GITHUB_REF.
|
||||
- name: Validate tag input
|
||||
if: github.event_name == 'workflow_call'
|
||||
shell: bash
|
||||
env:
|
||||
TAG_INPUT: ${{ inputs.tag }}
|
||||
run: |
|
||||
if [ -z "${TAG_INPUT}" ]; then
|
||||
echo "::error::No tag provided to docker.yml — refusing to build/push."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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.
|
||||
# Build the immutable revision that passed the evidence gate, even if
|
||||
# someone moves the version tag while this run is in progress.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
# ── Lock the docker image version to the npm package version ──────────
|
||||
# Mirrors the check in publish.yml: refuse to build unless the git tag
|
||||
# exactly matches `gitnexus/package.json`'s version. This guarantees
|
||||
# `ghcr.io/<owner>/gitnexus:X.Y.Z` always corresponds to the same
|
||||
# `gitnexus@X.Y.Z` published to npm — no drift, no surprises.
|
||||
- name: Verify tag matches gitnexus/package.json version
|
||||
id: version
|
||||
if: github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request'
|
||||
shell: bash
|
||||
env:
|
||||
# For workflow_call the tag comes from the caller input; for push events
|
||||
# it is derived from GITHUB_REF (set to empty so the else-branch fires).
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
if [ -n "$INPUT_TAG" ]; then
|
||||
TAG_VERSION="${INPUT_TAG#v}"
|
||||
else
|
||||
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
fi
|
||||
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "::error::Tag does not follow semver: v$TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
PKG_VERSION=$(node -p "require('./gitnexus/package.json').version")
|
||||
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
|
||||
echo "::error::Tag version (v$TAG_VERSION) does not match gitnexus/package.json version ($PKG_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Version verified: $PKG_VERSION"
|
||||
ref: ${{ needs.release-gate.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
# Required for multi-platform (linux/arm64) emulation.
|
||||
- name: Set up QEMU
|
||||
|
|
@ -149,7 +181,7 @@ jobs:
|
|||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
if: needs.release-gate.outputs.publish == 'true'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
|
|
@ -164,7 +196,7 @@ jobs:
|
|||
# access token, NOT the account password) with write access to the
|
||||
# `akonlabs/gitnexus` and `akonlabs/gitnexus-web` repos.
|
||||
- name: Log in to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
if: needs.release-gate.outputs.publish == 'true'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
|
@ -202,7 +234,7 @@ jobs:
|
|||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag != '' }}
|
||||
type=raw,value=${{ needs.release-gate.outputs.version }},enable=${{ inputs.tag != '' }}
|
||||
|
||||
# Transient 502s from GHCR / Docker Hub / GHA cache during multi-platform
|
||||
# exports are retried inside `.github/actions/docker-build-push-retry`
|
||||
|
|
@ -216,7 +248,7 @@ jobs:
|
|||
context: .
|
||||
file: ${{ matrix.image.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
push: ${{ needs.release-gate.outputs.publish == 'true' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ matrix.image.slug }}
|
||||
|
|
@ -235,7 +267,7 @@ jobs:
|
|||
# Do NOT relax to `@.*` — that accepts signatures from any ref, including
|
||||
# unprotected branches and PRs, and defeats the supply-chain guarantee.
|
||||
- name: Sign image with Cosign (keyless)
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
if: needs.release-gate.outputs.publish == 'true'
|
||||
env:
|
||||
# Cosign v2 (installed by sigstore/cosign-installer above) makes
|
||||
# keyless the default. COSIGN_EXPERIMENTAL is a v1-only opt-in flag
|
||||
|
|
@ -257,7 +289,7 @@ jobs:
|
|||
# is identical across registries (same build, same push), so consumers
|
||||
# pulling from either GHCR or Docker Hub see the same provenance.
|
||||
- name: Generate build provenance attestation (GHCR)
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
if: needs.release-gate.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
|
||||
|
|
@ -265,7 +297,7 @@ jobs:
|
|||
push-to-registry: true
|
||||
|
||||
- name: Generate build provenance attestation (Docker Hub)
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
if: needs.release-gate.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: docker.io/akonlabs/${{ matrix.image.slug }}
|
||||
|
|
|
|||
10
.github/workflows/gitnexus-review-agent.yml
vendored
10
.github/workflows/gitnexus-review-agent.yml
vendored
|
|
@ -361,7 +361,7 @@ jobs:
|
|||
- name: Set up pinned Node.js
|
||||
id: setup-node
|
||||
if: steps.context.outputs.ready == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
|
||||
|
|
@ -1294,7 +1294,7 @@ jobs:
|
|||
steps.claude-recheck.outcome == 'success'
|
||||
# Use the low-level base action: the high-level GitHub action can restore
|
||||
# project configuration from a moving base branch before invoking Claude.
|
||||
uses: anthropics/claude-code-action/base-action@e0cf66d1d257526b5d07f141838c338921cb8455 # v1
|
||||
uses: anthropics/claude-code-action/base-action@aa8a7738e48bf30a40ee499ce818bef081c5ab5e # v1
|
||||
env:
|
||||
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1'
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0'
|
||||
|
|
@ -1447,7 +1447,7 @@ jobs:
|
|||
if: >-
|
||||
steps.precheck.outputs.repair_reason != '' &&
|
||||
steps.repair-recheck.outcome == 'success'
|
||||
uses: anthropics/claude-code-action/base-action@e0cf66d1d257526b5d07f141838c338921cb8455 # v1
|
||||
uses: anthropics/claude-code-action/base-action@aa8a7738e48bf30a40ee499ce818bef081c5ab5e # v1
|
||||
env:
|
||||
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1'
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0'
|
||||
|
|
@ -2353,7 +2353,7 @@ jobs:
|
|||
- name: Upload bounded review artifact
|
||||
id: upload
|
||||
if: always() && steps.context.outputs.authorized == 'true' && steps.context.outputs.pr_number != ''
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: ${{ runner.temp }}/gitnexus-review-artifact/review.json
|
||||
|
|
@ -2398,7 +2398,7 @@ jobs:
|
|||
needs.analyze.outputs.authorized == 'true' &&
|
||||
needs.analyze.outputs.pr_number != ''
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: ${{ needs.analyze.outputs.artifact_name }}
|
||||
path: ${{ runner.temp }}/gitnexus-review-publish
|
||||
|
|
|
|||
186
.github/workflows/gitnexus-skill-evolution.yml
vendored
186
.github/workflows/gitnexus-skill-evolution.yml
vendored
|
|
@ -35,6 +35,14 @@
|
|||
# security group, and IAM setup are documented privately, not in this
|
||||
# repo — publishing the exact topology of a real, live AWS account
|
||||
# isn't safe to do in a public repo even without literal secrets.
|
||||
# Hosted jobs start and stop the existing instance through its protected
|
||||
# OIDC role. Reuse GITNEXUS_EVOLUTION_AWS_ROLE_ARN and
|
||||
# GITNEXUS_EVOLUTION_EC2_INSTANCE_ID secrets, plus the
|
||||
# GITNEXUS_EVOLUTION_AWS_REGION and GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC
|
||||
# variables (the actual weekly stop as DAY HH:MM UTC). These historical
|
||||
# configuration names are assumed; verify their private environment values.
|
||||
# Keep the external stop watchdog enabled;
|
||||
# it remains the fallback if GitHub cleanup is interrupted.
|
||||
# Accepted tradeoff: the box is stopped between runs (an EventBridge
|
||||
# schedule starts it ~15min before the Saturday cron and stops it 24h
|
||||
# later) but is not destroyed/recreated per run, so it isn't fully
|
||||
|
|
@ -74,6 +82,16 @@
|
|||
# Wall clock is quantised by ceil(cells_per_task / workers), and a review
|
||||
# task is 9 cells cold, so 4 costs host contention for exactly the wall
|
||||
# clock of 3. The next step up that buys anything is 5 (3 waves -> 2).
|
||||
#
|
||||
# Shared runner lock: this workflow and release-evaluation.yml use the one
|
||||
# concurrency group gitnexus-evolution-runner, so neither can stop the instance
|
||||
# during the other's run. GitHub keeps only ONE pending run per group, even with
|
||||
# cancel-in-progress: false: a newer queued run of either workflow cancels the
|
||||
# older pending run ("higher priority waiting request"). A release evaluation
|
||||
# dispatched for a stable publish can therefore vanish while it waits. Do not
|
||||
# queue evolution behind a pending release evaluation, and confirm the
|
||||
# evaluation's "Start the dedicated EC2 runner" job actually ran before relying
|
||||
# on its evidence; if the run shows Cancelled, dispatch it again.
|
||||
name: GitNexus skill evolution
|
||||
|
||||
on:
|
||||
|
|
@ -140,18 +158,137 @@ on:
|
|||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
runner_only:
|
||||
description: 'Start, probe, and stop the runner without paid model calls'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
group: gitnexus-evolution-runner
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
start-runner:
|
||||
name: Start the dedicated EC2 runner
|
||||
if: >-
|
||||
github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
|
||||
runs-on: ubuntu-latest
|
||||
environment: gitnexus-evolution
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
run_attempt: ${{ github.run_attempt }}
|
||||
cleanup_required: ${{ steps.boot.outputs.cleanup_required }}
|
||||
stop_deadline_epoch: ${{ steps.boot.outputs.stop_deadline_epoch }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/ec2-runner
|
||||
id: boot
|
||||
with:
|
||||
operation: start
|
||||
role-arn: ${{ secrets.GITNEXUS_EVOLUTION_AWS_ROLE_ARN }}
|
||||
instance-id: ${{ secrets.GITNEXUS_EVOLUTION_EC2_INSTANCE_ID }}
|
||||
region: ${{ vars.GITNEXUS_EVOLUTION_AWS_REGION }}
|
||||
stop-schedule: ${{ vars.GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC }}
|
||||
|
||||
check-runner:
|
||||
needs: [start-runner]
|
||||
name: Bound the wait for the evolution runner
|
||||
if: >-
|
||||
github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
|
||||
runs-on: ubuntu-latest
|
||||
environment: gitnexus-evolution
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # Cancel only this run if its dedicated runner stays offline.
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# EC2 health does not prove the Actions service can accept a job.
|
||||
- name: Verify the current run's native pickup probe
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 .github/scripts/evolution-runner-ready.py
|
||||
# A queued self-hosted job has no pickup timeout. Cancel this run so an
|
||||
# offline probe cannot remain queued indefinitely and block later runs.
|
||||
- name: Cancel this run when runner readiness fails
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh api --method POST "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
||||
|
||||
runner-ready:
|
||||
needs: [start-runner]
|
||||
name: Verify the runner service is online
|
||||
if: >-
|
||||
github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
|
||||
# Partial reruns reuse successful startup outputs after cleanup stopped EC2.
|
||||
runs-on: ${{ fromJSON(needs.start-runner.outputs.run_attempt == github.run_attempt && '["self-hosted", "linux", "x64", "gitnexus-evolution"]' || '["ubuntu-latest"]') }}
|
||||
environment: gitnexus-evolution
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Require startup in this run attempt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ '${{ needs.start-runner.outputs.run_attempt }}' != "$GITHUB_RUN_ATTEMPT" ]]; then
|
||||
echo '::error::The EC2 startup belongs to an earlier attempt. Select Re-run all jobs to start the runner again.'
|
||||
exit 1
|
||||
fi
|
||||
- name: Verify runner pickup
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -r /proc/uptime
|
||||
git --version
|
||||
uname -sm
|
||||
|
||||
watch-evolve-pickup:
|
||||
name: Bound pickup of the paid evolution job
|
||||
needs: [start-runner, check-runner, runner-ready]
|
||||
if: inputs.runner_only != true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # Cancel this run if evolve stays queued after the probe.
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Require pickup of this run's paid evolution job
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 .github/scripts/evolution-runner-ready.py --job-name "Propose, benchmark, and gate skill candidates"
|
||||
- name: Cancel this run when evolution pickup fails
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh api --method POST "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
||||
|
||||
evolve:
|
||||
name: Propose, benchmark, and gate skill candidates
|
||||
if: >-
|
||||
github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
inputs.runner_only != true &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
|
|
@ -159,7 +296,8 @@ jobs:
|
|||
vars.GITNEXUS_EVOLUTION_WORKERS == '3'
|
||||
)
|
||||
)
|
||||
runs-on: [self-hosted, linux, x64, gitnexus-evolution]
|
||||
needs: [start-runner, check-runner, runner-ready]
|
||||
runs-on: ${{ fromJSON(needs.start-runner.outputs.run_attempt == github.run_attempt && '["self-hosted", "linux", "x64", "gitnexus-evolution"]' || '["ubuntu-latest"]') }}
|
||||
# Gate promotion runs on a protected Environment. An admin must attach a
|
||||
# deployment-branch rule (main only) and ideally scope the model and App
|
||||
# secrets to it — server-side enforcement a dispatched non-main ref cannot bypass by
|
||||
|
|
@ -199,6 +337,13 @@ jobs:
|
|||
PROVIDER: ${{ inputs.provider || 'openai' }}
|
||||
INCLUDE_EXPENSIVE: ${{ inputs.include_expensive && '1' || '' }}
|
||||
steps:
|
||||
- name: Require startup in this run attempt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ '${{ needs.start-runner.outputs.run_attempt }}' != "$GITHUB_RUN_ATTEMPT" ]]; then
|
||||
echo '::error::The EC2 startup belongs to an earlier attempt. Select Re-run all jobs to start the runner again.'
|
||||
exit 1
|
||||
fi
|
||||
- name: Require the benchmark auth secret
|
||||
env:
|
||||
HAS_ANTHROPIC: ${{ secrets.GITNEXUS_BENCH_ANTHROPIC_API_KEY != '' || secrets.GITNEXUS_BENCH_AUTH_TOKEN != '' }}
|
||||
|
|
@ -257,7 +402,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
cache: npm
|
||||
|
|
@ -265,7 +410,7 @@ jobs:
|
|||
gitnexus/package-lock.json
|
||||
gitnexus-shared/package-lock.json
|
||||
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
- uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
|
|
@ -492,7 +637,9 @@ jobs:
|
|||
CE_PLUGIN_VERSION: 3.24.0
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./workflow_bench/run-evolution.sh --apply
|
||||
remaining=$(( ${{ needs.start-runner.outputs.stop_deadline_epoch }} - $(date +%s) - 5400 ))
|
||||
(( remaining > 0 )) || { echo '::error::No time remains before the stop and evidence reserve.'; exit 1; }
|
||||
timeout --signal=TERM --kill-after=60 "${remaining}s" ./workflow_bench/run-evolution.sh --apply
|
||||
working-directory: eval
|
||||
|
||||
- name: Upload benchmark evidence
|
||||
|
|
@ -500,7 +647,7 @@ jobs:
|
|||
# goes, so a killed or failed generation still has evidence worth
|
||||
# keeping — and that is exactly the run whose evidence is needed.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: gitnexus-evolution-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
# Addressed directly rather than carried from the sweep step: that is
|
||||
|
|
@ -554,7 +701,7 @@ jobs:
|
|||
- name: Mint GitHub App token
|
||||
id: app-token
|
||||
if: steps.promotion.outputs.promoted == 'true'
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
# `client-id` supersedes the deprecated `app-id` in v3.x (the action
|
||||
# accepts the numeric App ID here, as publish.yml does). Request only
|
||||
|
|
@ -621,3 +768,28 @@ jobs:
|
|||
--head "${branch}" \
|
||||
--title 'feat(skills): promoted evolution overlay' \
|
||||
--body-file "${RUNNER_TEMP}/pr-body.md"
|
||||
|
||||
stop-runner:
|
||||
name: Stop and verify the dedicated EC2 runner
|
||||
needs: [start-runner, check-runner, runner-ready, watch-evolve-pickup, evolve]
|
||||
if: >-
|
||||
always() && github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
needs.start-runner.outputs.cleanup_required == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: gitnexus-evolution
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/ec2-runner
|
||||
with:
|
||||
operation: stop
|
||||
role-arn: ${{ secrets.GITNEXUS_EVOLUTION_AWS_ROLE_ARN }}
|
||||
instance-id: ${{ secrets.GITNEXUS_EVOLUTION_EC2_INSTANCE_ID }}
|
||||
region: ${{ vars.GITNEXUS_EVOLUTION_AWS_REGION }}
|
||||
stop-schedule: ${{ vars.GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC }}
|
||||
|
|
|
|||
22
.github/workflows/grammar-update-monitor.yml
vendored
22
.github/workflows/grammar-update-monitor.yml
vendored
|
|
@ -2,19 +2,19 @@ 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
|
||||
# with the bundled tree-sitter@0.25.1 (LANGUAGE_VERSION 13–15, #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.
|
||||
# ABI-INCOMPATIBLE updates (upstreams that move past the runtime's ABI range) are
|
||||
# reported as a notice + job summary, NOT applied, so the monitor never opens
|
||||
# doomed PRs. A grammar with a policy `hold` (today: tree-sitter-swift) is
|
||||
# MONITORED but report-only: an available update is surfaced (notice + summary)
|
||||
# but never auto-bumped — a maintainer re-vendors it deliberately once the hold's
|
||||
# condition is met.
|
||||
#
|
||||
# The vendored set + per-grammar upstream coords + the tree-sitter-c hold live in
|
||||
# The vendored set + per-grammar upstream coords + policy holds live in
|
||||
# .github/vendored-grammars.json — the SHARED source of truth this monitor and
|
||||
# tree-sitter-upgrade-readiness.yml both read, so the two workflows can never
|
||||
# disagree about which grammars are vendored (#858). This monitor additionally
|
||||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ jobs:
|
|||
- name: Mint GitHub App token
|
||||
id: app-token
|
||||
if: steps.relapp.outputs.configured == 'true'
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
|
@ -125,7 +125,7 @@ jobs:
|
|||
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).`,
|
||||
`Verified ABI **${r.abi}** — compatible with the bundled \`tree-sitter@0.25.1\` (13–15).`,
|
||||
'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.',
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ jobs:
|
|||
|
||||
- name: Upload mutation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: impact-pdg-mutation-report
|
||||
path: gitnexus/mutation-report.json
|
||||
|
|
|
|||
394
.github/workflows/mutation-runner-validation.yml
vendored
Normal file
394
.github/workflows/mutation-runner-validation.yml
vendored
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
name: Mutation Runner Validation
|
||||
|
||||
# Validates the repo-local Stryker test runner for Vitest 5 and its validation gates
|
||||
# (gitnexus/tools/mutation/, TESTING.md "Mutation testing tooling"), and the test
|
||||
# scenario map built on it (gitnexus/tools/scenario-map/).
|
||||
#
|
||||
# pull_request gate 2: the conformance suite, one job per profile; and the scenario-map
|
||||
# controls run. Runs only when the tooling (tools/mutation, tools/scenario-map,
|
||||
# tools/test-dupes), its fixtures (mutation-scenario-map among them), the
|
||||
# gitnexus package manifest or lockfile (Vitest and Stryker versions), the base
|
||||
# Vitest config, the gitnexus-shared package manifest (its exports drive the
|
||||
# mutation config's aliases) or the setup-gitnexus action (the jobs' install
|
||||
# path) change. The paths are workflow-level, so a scenario-map change also runs
|
||||
# gate 2, an accepted extra cost.
|
||||
# workflow_dispatch gate 2 plus gate 1 (fidelity), gate 3 (oracle), gate 4 (controls) and
|
||||
# the oracle cost benchmark, each selectable by an input.
|
||||
#
|
||||
# Advisory: every job is continue-on-error and none is in ci.yml's ci-status.needs. Nothing here
|
||||
# blocks a merge. Gate 3's full sample takes hours, so it never runs on pull requests.
|
||||
#
|
||||
# Gate 3 (`oracle` input):
|
||||
# compare one job: a fresh vitest5 runner run per frozen manifest in
|
||||
# gitnexus/test/fixtures/mutation-oracle/, then one compare over all three, so the
|
||||
# strata are counted across the scopes combined, as the freeze counted them. Cheap:
|
||||
# one Stryker run per scope.
|
||||
# refreeze re-freezes manifests from scratch, one job per scope (`refreeze_scope`). The
|
||||
# full three-scope sample does not fit one 6-hour job on 4 vCPU (benchmark: 6.18 h
|
||||
# at best), so it is sharded by scope: shared about 4.5 h, schema-constants about
|
||||
# 45 min, middleware about 1 h. The new manifest is uploaded as an artifact for a
|
||||
# maintainer to review and commit; CI never commits it. Local equivalent, from
|
||||
# gitnexus/ (the work dir must lie outside the checkout; a freeze is resumable):
|
||||
# npm run test:mutation:oracle:freeze -- \
|
||||
# --from-manifest test/fixtures/mutation-oracle/<scope>.json \
|
||||
# --seed <new seed> --work <dir outside the checkout>
|
||||
#
|
||||
# Gate 4 runs as one job: both profiles (A: three concurrency-1 runs in parallel, B: three
|
||||
# concurrency-3 runs in turn) took about 81 min locally under load. Split jobs would have to
|
||||
# ship every run directory, prepared copies included, to a `--compare-only` job at the same
|
||||
# absolute paths, because the staleness check hashes the prepared inputs.
|
||||
#
|
||||
# Every tool writes outside the checkout (`--out`/`--work` under $RUNNER_TEMP): a prepared copy
|
||||
# must not lie inside it. setup-gitnexus installs Node 22, the Node major recorded in the frozen
|
||||
# manifests; another major makes gate 3 report "re-freeze required". Timings were measured on a
|
||||
# loaded WSL2 machine with the checkout on a 9p mount; ubuntu-latest (4 vCPU, local disk) will
|
||||
# differ, so the timeouts leave margin.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.github/workflows/mutation-runner-validation.yml'
|
||||
- 'gitnexus/tools/mutation/**'
|
||||
- 'gitnexus/tools/scenario-map/**'
|
||||
- 'gitnexus/tools/test-dupes/**'
|
||||
- 'gitnexus/test/fixtures/mutation-*/**'
|
||||
- 'gitnexus/package.json'
|
||||
- 'gitnexus/package-lock.json'
|
||||
- 'gitnexus/vitest.config.ts'
|
||||
- 'gitnexus-shared/package.json'
|
||||
- '.github/actions/setup-gitnexus/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fidelity:
|
||||
description: 'Gate 1: execution-path fidelity on the pure-JS category'
|
||||
type: boolean
|
||||
default: true
|
||||
oracle:
|
||||
description: 'Gate 3: compare the frozen manifests, re-freeze them, or skip'
|
||||
type: choice
|
||||
options: [compare, refreeze, skip]
|
||||
default: compare
|
||||
refreeze_scope:
|
||||
description: 'Oracle scope to re-freeze (oracle = refreeze)'
|
||||
type: choice
|
||||
options: [all, shared, schema-constants, middleware]
|
||||
default: all
|
||||
seed:
|
||||
description: 'New sample seed for a re-freeze (required when oracle = refreeze)'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
controls:
|
||||
description: 'Gate 4: planted controls and determinism'
|
||||
type: boolean
|
||||
default: true
|
||||
bench:
|
||||
description: 'Oracle cost benchmark'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
|
||||
# A new push supersedes a PR run; manual dispatches (hours long) are never cancelled.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
|
||||
jobs:
|
||||
# ── Gate 2: runner conformance suite ───────────────────────────────
|
||||
conformance:
|
||||
name: Gate 2 conformance (${{ matrix.profile }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# The profile names of gitnexus/test/fixtures/mutation-conformance/expected.json.
|
||||
profile: [concurrency-2-reuse-1, concurrency-1-reuse-unlimited]
|
||||
steps:
|
||||
# persist-credentials: false — the job uploads artifacts and never pushes.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Full install, as the test jobs do: tsx, Stryker and Vitest are devDependencies.
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- name: Run the conformance suite
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: npm run test:mutation:conformance -- --profile "$PROFILE" --out "$RUNNER_TEMP/mutation/conformance"
|
||||
- name: Upload conformance results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: mutation-conformance-${{ matrix.profile }}
|
||||
path: |
|
||||
${{ runner.temp }}/mutation/conformance/observed.json
|
||||
${{ runner.temp }}/mutation/conformance/*/*/results.json
|
||||
${{ runner.temp }}/mutation/conformance/*/*/stryker.log
|
||||
retention-days: 14
|
||||
|
||||
# ── Gate 1: execution-path fidelity and envelope ───────────────────
|
||||
fidelity:
|
||||
name: Gate 1 fidelity
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.fidelity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
# The envelope goes to the artifact, not over the committed
|
||||
# test/fixtures/mutation-oracle/envelope.json.
|
||||
- name: Run gate 1
|
||||
run: |
|
||||
npm run test:mutation:fidelity -- \
|
||||
--out "$RUNNER_TEMP/mutation/fidelity" \
|
||||
--envelope "$RUNNER_TEMP/mutation/envelope/envelope.json"
|
||||
- name: Upload gate 1 report and envelope
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: mutation-gate1
|
||||
path: |
|
||||
${{ runner.temp }}/mutation/fidelity/*.json
|
||||
${{ runner.temp }}/mutation/envelope/envelope.json
|
||||
retention-days: 14
|
||||
|
||||
# ── Gate 3: compare a fresh runner run with the frozen manifests ───
|
||||
oracle-compare:
|
||||
name: Gate 3 oracle compare
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.oracle == 'compare'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- name: Run the runner on every oracle scope and compare
|
||||
run: |
|
||||
work="$RUNNER_TEMP/mutation/gate3"
|
||||
mkdir -p "$work"
|
||||
pairs=()
|
||||
for name in shared schema-constants middleware; do
|
||||
manifest="test/fixtures/mutation-oracle/$name.json"
|
||||
if [ ! -f "$manifest" ]; then
|
||||
echo "::error::$manifest is missing; freeze it first"
|
||||
exit 2
|
||||
fi
|
||||
jq '.scope' "$manifest" > "$work/$name.scope.json"
|
||||
# Exit 1 means results with integrity flags; gate 3 reports those as differences.
|
||||
status=0
|
||||
npm run test:mutation:run -- --scope "$work/$name.scope.json" \
|
||||
--out "$work/$name" --concurrency 4 || status=$?
|
||||
if [ "$status" -gt 1 ]; then exit "$status"; fi
|
||||
pairs+=(--scope "$manifest,$work/$name/results.json")
|
||||
done
|
||||
npm run test:mutation:oracle -- "${pairs[@]}" --out "$work/gate3-report.json"
|
||||
- name: Upload gate 3 report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: mutation-gate3
|
||||
path: |
|
||||
${{ runner.temp }}/mutation/gate3/gate3-report.json
|
||||
${{ runner.temp }}/mutation/gate3/*.scope.json
|
||||
${{ runner.temp }}/mutation/gate3/*/results.json
|
||||
${{ runner.temp }}/mutation/gate3/*/stryker.log
|
||||
retention-days: 14
|
||||
|
||||
# ── Gate 3: re-freeze manifests, sharded by scope ──────────────────
|
||||
oracle-refreeze:
|
||||
name: Gate 3 re-freeze (${{ matrix.scope }})
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.oracle == 'refreeze'
|
||||
runs-on: ubuntu-latest
|
||||
# Projected: shared ~4.5 h with Stryker runs and setup; the others ~1 h. 6 h job cap.
|
||||
timeout-minutes: ${{ matrix.scope == 'shared' && 355 || 150 }}
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
scope: ${{ fromJSON(inputs.refreeze_scope == 'all' && '["shared","schema-constants","middleware"]' || format('["{0}"]', inputs.refreeze_scope)) }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- name: Check the seed
|
||||
env:
|
||||
SCOPE: ${{ matrix.scope }}
|
||||
SEED: ${{ inputs.seed }}
|
||||
run: |
|
||||
manifest="test/fixtures/mutation-oracle/$SCOPE.json"
|
||||
if [ -z "$SEED" ]; then
|
||||
echo "::error::a re-freeze needs a new sample seed (the seed input)"
|
||||
exit 2
|
||||
fi
|
||||
frozen="$(jq -r '.sample.seed' "$manifest")"
|
||||
if [ "$SEED" = "$frozen" ]; then
|
||||
echo "::error::$manifest was frozen with seed $frozen; give a new seed"
|
||||
exit 2
|
||||
fi
|
||||
# --manifest lies outside the checkout, so the manifest records the default
|
||||
# test/fixtures/mutation-oracle/<scope>.json path, and only a freeze without problems
|
||||
# writes it. <work>/manifest.json is written either way and lists any problems.
|
||||
- name: Re-freeze the scope
|
||||
env:
|
||||
SCOPE: ${{ matrix.scope }}
|
||||
SEED: ${{ inputs.seed }}
|
||||
run: |
|
||||
out="$RUNNER_TEMP/mutation/refreeze"
|
||||
mkdir -p "$out"
|
||||
npm run test:mutation:oracle:freeze -- \
|
||||
--from-manifest "test/fixtures/mutation-oracle/$SCOPE.json" \
|
||||
--seed "$SEED" --work "$out/work" --manifest "$out/$SCOPE.json"
|
||||
- name: Upload the re-frozen manifest
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: mutation-oracle-refreeze-${{ matrix.scope }}
|
||||
path: |
|
||||
${{ runner.temp }}/mutation/refreeze/${{ matrix.scope }}.json
|
||||
${{ runner.temp }}/mutation/refreeze/work/manifest.json
|
||||
${{ runner.temp }}/mutation/refreeze/work/freeze-work.json
|
||||
${{ runner.temp }}/mutation/refreeze/work/command/oracle-command-run.json
|
||||
${{ runner.temp }}/mutation/refreeze/work/*/stryker.log
|
||||
retention-days: 30
|
||||
|
||||
# ── Gate 4: planted controls and determinism ───────────────────────
|
||||
controls:
|
||||
name: Gate 4 controls
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.controls
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- name: Run gate 4
|
||||
run: npm run test:mutation:controls -- --work "$RUNNER_TEMP/mutation/controls"
|
||||
- name: Upload gate 4 report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: mutation-gate4
|
||||
path: |
|
||||
${{ runner.temp }}/mutation/controls/controls-report.json
|
||||
${{ runner.temp }}/mutation/controls/runs/*/*/results.json
|
||||
${{ runner.temp }}/mutation/controls/runs/*/*/stryker.log
|
||||
retention-days: 14
|
||||
|
||||
# ── Oracle cost benchmark ──────────────────────────────────────────
|
||||
bench:
|
||||
name: Oracle cost benchmark
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.bench
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
# Per oracle scope: the oracle's command-runner run and a vitest5 runner run, then the
|
||||
# benchmark over all three. Concurrency 4 matches the recorded numbers.
|
||||
- name: Run the oracle cost benchmark
|
||||
run: |
|
||||
work="$RUNNER_TEMP/mutation/bench"
|
||||
mkdir -p "$work"
|
||||
scopes=()
|
||||
for name in shared schema-constants middleware; do
|
||||
manifest="test/fixtures/mutation-oracle/$name.json"
|
||||
if [ ! -f "$manifest" ]; then
|
||||
echo "::error::$manifest is missing; freeze it first"
|
||||
exit 2
|
||||
fi
|
||||
jq '.scope' "$manifest" > "$work/$name.scope.json"
|
||||
npm run test:mutation:oracle:command -- --scope "$work/$name.scope.json" \
|
||||
--out "$work/$name-command" --concurrency 4
|
||||
# Exit 1 means results with integrity flags; the benchmark only reads which
|
||||
# mutants are static and which tests cover the timed-out ones.
|
||||
status=0
|
||||
npm run test:mutation:run -- --scope "$work/$name.scope.json" \
|
||||
--out "$work/$name-vitest5" --concurrency 4 || status=$?
|
||||
if [ "$status" -gt 1 ]; then exit "$status"; fi
|
||||
scopes+=(--scope "$work/$name-command/oracle-command-run.json,$work/$name-vitest5/results.json")
|
||||
done
|
||||
npm run test:mutation:bench -- "${scopes[@]}" --out "$work/report" --concurrency 4
|
||||
- name: Upload benchmark report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: mutation-oracle-bench
|
||||
path: |
|
||||
${{ runner.temp }}/mutation/bench/report/oracle-cost.json
|
||||
${{ runner.temp }}/mutation/bench/report/oracle-cost.txt
|
||||
${{ runner.temp }}/mutation/bench/*-command/oracle-command-run.json
|
||||
${{ runner.temp }}/mutation/bench/*/stryker.log
|
||||
retention-days: 14
|
||||
|
||||
# ── Scenario map controls ──────────────────────────────────────────
|
||||
# The PR check of test-scenario-map.yml's pipeline (plan, value flow, one shard, merge) over the
|
||||
# controls fixture (gitnexus/test/fixtures/mutation-scenario-map/), mutation scope `all`, then
|
||||
# every expected.json mutation and fingerprint verdict and rule compared; exit 1 means a pair
|
||||
# differs. build: 'true' because the value-flow stage's PDG index
|
||||
# spawns analyze workers from dist/. About 30 min locally.
|
||||
scenario-map-controls:
|
||||
name: Scenario map controls
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
- name: Run the scenario-map controls
|
||||
env:
|
||||
GITNEXUS_WORKER_READY_TIMEOUT_MS: '120000'
|
||||
run: npm run test:scenario-map -- controls --work "$RUNNER_TEMP/scenario-map-controls"
|
||||
- name: Upload the controls report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: scenario-map-controls-${{ github.run_attempt }}
|
||||
path: |
|
||||
${{ runner.temp }}/scenario-map-controls/scenario-map-report.json
|
||||
${{ runner.temp }}/scenario-map-controls/scenario-map-report.md
|
||||
${{ runner.temp }}/scenario-map-controls/issue-body.md
|
||||
${{ runner.temp }}/scenario-map-controls/plan.json
|
||||
${{ runner.temp }}/scenario-map-controls/value-flow.json
|
||||
${{ runner.temp }}/scenario-map-controls/shard-0.json
|
||||
${{ runner.temp }}/scenario-map-controls/value-flow/pdg/analyze.log
|
||||
retention-days: 14
|
||||
2
.github/workflows/pr-autofix-apply.yml
vendored
2
.github/workflows/pr-autofix-apply.yml
vendored
|
|
@ -315,7 +315,7 @@ jobs:
|
|||
# expired) from "patch file zero bytes" (genuinely empty patch).
|
||||
- name: Download autofix artifact
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: autofix
|
||||
|
|
|
|||
2
.github/workflows/pr-autofix-publish.yml
vendored
2
.github/workflows/pr-autofix-publish.yml
vendored
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
# Pinned to v8.0.1. Verify SHA via:
|
||||
# gh api repos/actions/download-artifact/git/refs/tags/v8.0.1
|
||||
- name: Download autofix artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: autofix
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
|
|
|||
4
.github/workflows/pr-autofix.yml
vendored
4
.github/workflows/pr-autofix.yml
vendored
|
|
@ -59,7 +59,7 @@ jobs:
|
|||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
@ -138,7 +138,7 @@ jobs:
|
|||
# Pinned to v7.0.1. Verify SHA via:
|
||||
# gh api repos/actions/upload-artifact/git/refs/tags/v7.0.1
|
||||
- name: Upload autofix artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: autofix
|
||||
path: autofix-out/
|
||||
|
|
|
|||
66
.github/workflows/pr-cost-evaluation.yml
vendored
Normal file
66
.github/workflows/pr-cost-evaluation.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
name: PR cost evaluation request
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
request:
|
||||
if: >-
|
||||
github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.event.label.name == 'run-cost-evaluation' &&
|
||||
github.run_attempt == 1
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: read
|
||||
outputs:
|
||||
pr: ${{ steps.request.outputs.pr }}
|
||||
sha: ${{ steps.request.outputs.sha }}
|
||||
comparison_run_id: ${{ steps.request.outputs.comparison_run_id }}
|
||||
run_attempt: ${{ steps.request.outputs.run_attempt }}
|
||||
steps:
|
||||
# pull_request_target uses trusted base code; never check out the PR here.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- name: Request one pinned comparison from trusted main
|
||||
id: request
|
||||
working-directory: eval
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m workflow_bench.pr_evaluation dispatch-label \
|
||||
--repo "$GITHUB_REPOSITORY" --event "$GITHUB_EVENT_PATH" >> "$GITHUB_OUTPUT"
|
||||
echo "run_attempt=$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
evaluate:
|
||||
needs: [request]
|
||||
if: needs.request.outputs.run_attempt == github.run_attempt
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/release-evaluation.yml
|
||||
# Named forwarding also binds the callee's environment-only secrets.
|
||||
secrets:
|
||||
GITNEXUS_EVOLUTION_AWS_ROLE_ARN: ${{ secrets.GITNEXUS_EVOLUTION_AWS_ROLE_ARN }}
|
||||
GITNEXUS_EVOLUTION_EC2_INSTANCE_ID: ${{ secrets.GITNEXUS_EVOLUTION_EC2_INSTANCE_ID }}
|
||||
GITNEXUS_BENCH_OPENAI_API_KEY: ${{ secrets.GITNEXUS_BENCH_OPENAI_API_KEY }}
|
||||
GITNEXUS_BENCH_ANTHROPIC_API_KEY: ${{ secrets.GITNEXUS_BENCH_ANTHROPIC_API_KEY }}
|
||||
with:
|
||||
candidate_pr: ${{ needs.request.outputs.pr }}
|
||||
expected_pr_sha: ${{ needs.request.outputs.sha }}
|
||||
comparison_run_id: ${{ needs.request.outputs.comparison_run_id }}
|
||||
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
|
|
@ -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@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
||||
- uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
|
|
|||
50
.github/workflows/publish.yml
vendored
50
.github/workflows/publish.yml
vendored
|
|
@ -287,6 +287,7 @@ jobs:
|
|||
# id-token: write — npm provenance attestation.
|
||||
contents: write
|
||||
id-token: write
|
||||
actions: read # Fetch exact-revision agent evidence from the trusted main workflow.
|
||||
outputs:
|
||||
# Two distinct step IDs feed this output; exactly one fires per run.
|
||||
vtag: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
|
||||
|
|
@ -314,7 +315,7 @@ jobs:
|
|||
- name: Mint GitHub App token (RC)
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
# `client-id` is the renamed input that supersedes the deprecated
|
||||
# `app-id` in v3.x. The action accepts the App's numeric ID or
|
||||
|
|
@ -369,7 +370,7 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
# Node 24 ships with npm >= 11.5.x, which is the minimum that
|
||||
# supports npm Trusted Publishing OIDC. Node 22 ships with npm
|
||||
|
|
@ -540,20 +541,29 @@ jobs:
|
|||
fi
|
||||
fi
|
||||
|
||||
# 4. Counter: 1 + max existing N for `${BASE}-rc.*`, else 1.
|
||||
# 4. Counter: reserve versions present in either npm or Git.
|
||||
# npm can accept a publish while its metadata still omits that
|
||||
# version ("Your package is being processed"). Checkout fetched
|
||||
# all tags, including detached release commits, so those tags
|
||||
# keep the next run from allocating an already-used version.
|
||||
GIT_RC_TAGS="$(git tag --list "v${BASE}-rc.*")"
|
||||
cat > /tmp/next_rc.mjs <<'NODESCRIPT'
|
||||
const base = process.env.BASE;
|
||||
const prefix = base + "-rc.";
|
||||
let v;
|
||||
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
|
||||
if (!Array.isArray(v)) v = [v];
|
||||
const ns = v
|
||||
const tagged = process.env.GIT_RC_TAGS.split("\n")
|
||||
.filter(Boolean).map(tag => tag.slice(1));
|
||||
const ns = [...v, ...tagged]
|
||||
.filter(s => typeof s === "string" && s.startsWith(prefix))
|
||||
.map(s => parseInt(s.slice(prefix.length), 10))
|
||||
.map(s => s.slice(prefix.length))
|
||||
.filter(s => /^\d+$/.test(s))
|
||||
.map(Number)
|
||||
.filter(n => Number.isInteger(n) && n >= 0);
|
||||
process.stdout.write(String(ns.length ? Math.max(...ns) + 1 : 1));
|
||||
NODESCRIPT
|
||||
NEXT_N="$(BASE="$BASE" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/next_rc.mjs)"
|
||||
NEXT_N="$(BASE="$BASE" VERSIONS_JSON="$VERSIONS_JSON" GIT_RC_TAGS="$GIT_RC_TAGS" node /tmp/next_rc.mjs)"
|
||||
RC_VERSION="${BASE}-rc.${NEXT_N}"
|
||||
echo "Computed rc: $RC_VERSION"
|
||||
|
||||
|
|
@ -614,6 +624,17 @@ jobs:
|
|||
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
|
||||
run: npm publish --dry-run --tag "$NPM_TAG"
|
||||
|
||||
# Every release carries this run's fixed-answer accuracy. Paid paired
|
||||
# agent runs gate only stable releases; RCs are measured on a schedule.
|
||||
- name: Download this release's tool accuracy
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
with:
|
||||
name: release-tool-accuracy
|
||||
path: ${{ runner.temp }}/release-evidence
|
||||
- name: Require paired agent evidence for this stable commit
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
uses: ./.github/actions/require-agent-release-evidence
|
||||
|
||||
# ── Acquire the "rc lock" BEFORE publishing (idempotency anchor) ─────
|
||||
# We create two refs and push atomically:
|
||||
# v<RC_VERSION> → annotated tag on a detached release commit whose
|
||||
|
|
@ -842,6 +863,12 @@ jobs:
|
|||
echo "$NOTES" > /tmp/release-notes.md
|
||||
echo "fallback=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
{
|
||||
printf '\n'
|
||||
cat "$RUNNER_TEMP/release-evidence/accuracy.md"
|
||||
printf '\n'
|
||||
cat "$RUNNER_TEMP/release-evidence/agent-evaluation.md"
|
||||
} >> /tmp/release-notes.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v2
|
||||
|
|
@ -853,11 +880,11 @@ jobs:
|
|||
|| steps.vtag-gate.outputs.vtag }}
|
||||
prerelease: ${{ needs.route.outputs.mode == 'rc' }}
|
||||
make_latest: ${{ needs.route.outputs.mode == 'stable' && 'true' || 'false' }}
|
||||
# Stable: prefer CHANGELOG body, fall back to auto-generated.
|
||||
# RC: always auto-generated + the prerelease body block below.
|
||||
body_path: >-
|
||||
${{ needs.route.outputs.mode == 'stable' && steps.changelog.outputs.fallback == 'false'
|
||||
&& '/tmp/release-notes.md' || '' }}
|
||||
# Stable: CHANGELOG body (or auto-generated notes) plus measured
|
||||
# evidence. RC: auto-generated + the prerelease body block below.
|
||||
body_path: ${{ needs.route.outputs.mode == 'stable' && '/tmp/release-notes.md' || '' }}
|
||||
files: ${{ runner.temp }}/release-evidence/*
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: >-
|
||||
${{ needs.route.outputs.mode == 'rc'
|
||||
|| steps.changelog.outputs.fallback == 'true' }}
|
||||
|
|
@ -928,6 +955,7 @@ jobs:
|
|||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
|
|
|||
758
.github/workflows/release-evaluation.yml
vendored
Normal file
758
.github/workflows/release-evaluation.yml
vendored
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
# Compare the exact candidate with the last stable release using trusted main's
|
||||
# harness. Share the existing evolution runner, protected settings, and power lock.
|
||||
#
|
||||
# The shared group keeps only ONE pending run, even with cancel-in-progress:
|
||||
# false: a newer queued skill-evolution or release-evaluation run cancels this
|
||||
# run while it is pending. Before relying on a dispatched evaluation for a
|
||||
# stable publish, confirm its "Start the dedicated EC2 runner" job ran; if the
|
||||
# run shows Cancelled, dispatch it again once the group is free.
|
||||
name: Release evaluation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 5 * * 3' # Wednesday 05:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
runner_only:
|
||||
description: Start, probe, and stop EC2 without paid model calls
|
||||
type: boolean
|
||||
default: false
|
||||
diagnostic_trial:
|
||||
description: 'Two serial cells, $5 CLI stop each; requires a full candidate SHA, gpt-5.6-sol/xhigh, and no other experiment flags'
|
||||
type: boolean
|
||||
default: false
|
||||
mcp_tool_loading:
|
||||
description: Add candidate deferred-MCP experiment (12 extra paid cells)
|
||||
type: boolean
|
||||
default: false
|
||||
candidate_ref:
|
||||
description: 'Full commit SHA or version tag to evaluate; latest-rc resolves the newest published RC'
|
||||
type: string
|
||||
default: latest-rc
|
||||
model:
|
||||
description: 'Named model for both arms'
|
||||
type: string
|
||||
default: gpt-5.6-sol
|
||||
effort:
|
||||
description: 'Reasoning effort for both arms'
|
||||
type: choice
|
||||
default: xhigh
|
||||
options: [low, medium, high, xhigh, max]
|
||||
cli_arm:
|
||||
description: 'Also run baseline_cli (same tools via a gitnexus shell command, no MCP catalog) on the candidate; adds 12 paid cells, descriptive only, never gates'
|
||||
type: boolean
|
||||
default: false
|
||||
# PR preview inputs are available only to the maintainer-authorized label
|
||||
# workflow. Manual dispatch cannot select an unmerged PR runtime.
|
||||
workflow_call:
|
||||
secrets:
|
||||
GITNEXUS_EVOLUTION_AWS_ROLE_ARN:
|
||||
required: true
|
||||
GITNEXUS_EVOLUTION_EC2_INSTANCE_ID:
|
||||
required: true
|
||||
GITNEXUS_BENCH_OPENAI_API_KEY:
|
||||
required: true
|
||||
GITNEXUS_BENCH_ANTHROPIC_API_KEY:
|
||||
required: false
|
||||
inputs:
|
||||
candidate_pr:
|
||||
type: string
|
||||
required: true
|
||||
expected_pr_sha:
|
||||
type: string
|
||||
required: true
|
||||
comparison_run_id:
|
||||
type: string
|
||||
required: true
|
||||
model:
|
||||
type: string
|
||||
default: gpt-5.6-sol
|
||||
effort:
|
||||
type: string
|
||||
default: xhigh
|
||||
|
||||
concurrency:
|
||||
group: gitnexus-evolution-runner
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
start-runner:
|
||||
name: Start the dedicated EC2 runner
|
||||
if: >-
|
||||
github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request_target' && inputs.candidate_pr != '') ||
|
||||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
|
||||
runs-on: ubuntu-latest
|
||||
environment: gitnexus-evolution
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
run_attempt: ${{ github.run_attempt }}
|
||||
cleanup_required: ${{ steps.boot.outputs.cleanup_required }}
|
||||
stop_deadline_epoch: ${{ steps.boot.outputs.stop_deadline_epoch }}
|
||||
steps:
|
||||
- name: Validate diagnostic trial authorization scope
|
||||
env:
|
||||
TRIAL: ${{ inputs.diagnostic_trial }}
|
||||
RUNNER_ONLY: ${{ inputs.runner_only }}
|
||||
CLI_ARM: ${{ inputs.cli_arm }}
|
||||
DEFERRED_ARM: ${{ inputs.mcp_tool_loading }}
|
||||
CANDIDATE_REF: ${{ inputs.candidate_ref }}
|
||||
MODEL: ${{ inputs.model }}
|
||||
EFFORT: ${{ inputs.effort }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TRIAL" == true ]]; then
|
||||
if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch || "$GITHUB_RUN_ATTEMPT" != 1 ||
|
||||
"$RUNNER_ONLY" == true || "$CLI_ARM" == true || "$DEFERRED_ARM" == true ||
|
||||
"$MODEL" != gpt-5.6-sol || "$EFFORT" != xhigh || ! "$CANDIDATE_REF" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo '::error::Trial requires a fresh manual dispatch, a full candidate SHA, gpt-5.6-sol/xhigh, and no other experiment flags. Paid trials are never retried automatically.'
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/ec2-runner
|
||||
id: boot
|
||||
with:
|
||||
operation: start
|
||||
role-arn: ${{ secrets.GITNEXUS_EVOLUTION_AWS_ROLE_ARN }}
|
||||
instance-id: ${{ secrets.GITNEXUS_EVOLUTION_EC2_INSTANCE_ID }}
|
||||
region: ${{ vars.GITNEXUS_EVOLUTION_AWS_REGION }}
|
||||
stop-schedule: ${{ vars.GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC }}
|
||||
|
||||
check-runner:
|
||||
needs: [start-runner]
|
||||
name: Bound the wait for the evaluation runner
|
||||
if: >-
|
||||
github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request_target' && inputs.candidate_pr != '') ||
|
||||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
|
||||
runs-on: ubuntu-latest
|
||||
environment: gitnexus-evolution
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # Cancel only this run if its dedicated runner stays offline.
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# EC2 health does not prove the Actions service can accept a job.
|
||||
- name: Verify the current run's native pickup probe
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 .github/scripts/evolution-runner-ready.py
|
||||
# A queued self-hosted job has no pickup timeout. Cancel this run so an
|
||||
# offline probe cannot remain queued indefinitely and block later runs.
|
||||
- name: Cancel this run when runner readiness fails
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh api --method POST "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
||||
|
||||
runner-ready:
|
||||
needs: [start-runner]
|
||||
name: Verify the runner service is online
|
||||
if: >-
|
||||
github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request_target' && inputs.candidate_pr != '') ||
|
||||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
|
||||
# Partial reruns reuse successful startup outputs after cleanup stopped EC2.
|
||||
runs-on: ${{ fromJSON(needs.start-runner.outputs.run_attempt == github.run_attempt && '["self-hosted", "linux", "x64", "gitnexus-evolution"]' || '["ubuntu-latest"]') }}
|
||||
environment: gitnexus-evolution
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Require startup in this run attempt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ '${{ needs.start-runner.outputs.run_attempt }}' != "$GITHUB_RUN_ATTEMPT" ]]; then
|
||||
echo '::error::The EC2 startup belongs to an earlier attempt. Select Re-run all jobs to start the runner again.'
|
||||
exit 1
|
||||
fi
|
||||
- name: Verify runner pickup
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -r /proc/uptime
|
||||
git --version
|
||||
uname -sm
|
||||
|
||||
watch-evaluate-pickup:
|
||||
name: Bound pickup of the paid evaluation job
|
||||
needs: [start-runner, check-runner, runner-ready]
|
||||
if: inputs.runner_only != true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # Cancel this run if evaluate stays queued after the probe.
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Require pickup of this run's paid evaluation job
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 .github/scripts/evolution-runner-ready.py --job-name "Evaluate candidate and stable quality"
|
||||
- name: Cancel this run when evaluation pickup fails
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh api --method POST "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
||||
|
||||
evaluate:
|
||||
name: Evaluate candidate and stable quality
|
||||
needs: [start-runner, check-runner, runner-ready]
|
||||
if: >-
|
||||
github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
inputs.runner_only != true &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request_target' && inputs.candidate_pr != '') ||
|
||||
(vars.GITNEXUS_EVOLUTION_ENABLED == 'true' && vars.GITNEXUS_EVOLUTION_WORKERS == '3'))
|
||||
runs-on: ${{ fromJSON(needs.start-runner.outputs.run_attempt == github.run_attempt && '["self-hosted", "linux", "x64", "gitnexus-evolution"]' || '["ubuntu-latest"]') }}
|
||||
# Reuse the main-only protected model-secret environment. No tag/fork code
|
||||
# runs as the harness, and no release or PR write token is available here.
|
||||
environment: gitnexus-evolution
|
||||
timeout-minutes: 1260
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read # Authenticate and download the explicitly selected historical measurement.
|
||||
pull-requests: read
|
||||
env:
|
||||
INPUT_REF: ${{ inputs.candidate_ref || 'latest-rc' }}
|
||||
INPUT_PR: ${{ inputs.candidate_pr }}
|
||||
INPUT_PR_SHA: ${{ inputs.expected_pr_sha }}
|
||||
COMPARISON_RUN_ID: ${{ inputs.comparison_run_id }}
|
||||
MODEL: ${{ inputs.model || 'gpt-5.6-sol' }}
|
||||
EFFORT: ${{ inputs.effort || 'xhigh' }}
|
||||
# Manual dispatch only; schedules and PR previews never pay for the extra arm.
|
||||
CLI_ARM: ${{ inputs.cli_arm == true && inputs.candidate_pr == '' && 'true' || 'false' }}
|
||||
steps:
|
||||
- name: Require startup in this run attempt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ '${{ needs.start-runner.outputs.run_attempt }}' != "$GITHUB_RUN_ATTEMPT" ]]; then
|
||||
echo '::error::The EC2 startup belongs to an earlier attempt. Select Re-run all jobs to start the runner again.'
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- name: Resolve immutable runtime revision
|
||||
id: resolve
|
||||
working-directory: eval
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DIAGNOSTIC_TRIAL: ${{ inputs.diagnostic_trial }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$INPUT_PR" ]]; then
|
||||
[[ "$COMPARISON_RUN_ID" =~ ^[1-9][0-9]*$ ]] || { echo '::error::PR preview requires comparison_run_id.'; exit 1; }
|
||||
python3 -m workflow_bench.pr_evaluation resolve \
|
||||
--repo "$GITHUB_REPOSITORY" --pr "$INPUT_PR" --sha "$INPUT_PR_SHA" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
[[ -z "$INPUT_PR_SHA" && -z "$COMPARISON_RUN_ID" ]] || { echo '::error::PR preview inputs require candidate_pr.'; exit 1; }
|
||||
# Resolve last stable once; both runtimes use the same trusted tasks.
|
||||
if [[ "${DIAGNOSTIC_TRIAL:-false}" != true ]]; then
|
||||
stable="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq '.tag_name')"
|
||||
[[ "$stable" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo '::error::No stable release found.'; exit 1; }
|
||||
stable_sha="$(git rev-parse --verify "$stable^{commit}")"
|
||||
echo "stable_sha=$stable_sha" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
ref="$INPUT_REF"
|
||||
if [[ "$ref" == 'latest-rc' ]]; then
|
||||
ref="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq \
|
||||
'[.[] | select(.prerelease and (.draft | not)) | select(.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+-rc\\.[0-9]+$"))] | sort_by(.published_at) | last | .tag_name // empty')"
|
||||
fi
|
||||
if ! [[ "$ref" =~ ^[0-9a-f]{40}$ || "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
|
||||
echo '::error::No published RC found, or candidate_ref is not a full SHA/version tag.'
|
||||
exit 1
|
||||
fi
|
||||
sha="$(git rev-parse --verify "$ref^{commit}")"
|
||||
if [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
# Manual stable candidates must already be reviewed on main.
|
||||
git merge-base --is-ancestor "$sha" HEAD
|
||||
else
|
||||
# Versioned RC trees are published descendants, not ancestors of
|
||||
# main. Accept their official published release rather than PR refs.
|
||||
gh api "repos/$GITHUB_REPOSITORY/releases/tags/$ref" --jq '.draft == false' | grep -qx true
|
||||
fi
|
||||
echo "Runtime: $sha"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ steps.resolve.outputs.sha }}
|
||||
path: candidate
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
if: inputs.candidate_pr == '' && inputs.diagnostic_trial != true
|
||||
with:
|
||||
ref: ${{ steps.resolve.outputs.stable_sha }}
|
||||
path: stable
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- name: Initialize this run's measurement directory
|
||||
run: |
|
||||
rm -rf "$RUNNER_TEMP/release-evaluation"
|
||||
mkdir -p "$RUNNER_TEMP/release-evaluation/public"
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
cache: npm
|
||||
cache-dependency-path: candidate/gitnexus/package-lock.json
|
||||
- uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
enable-cache: true
|
||||
cache-dependency-glob: eval/uv.lock
|
||||
- name: Authenticate historical PR comparison evidence
|
||||
if: inputs.candidate_pr != ''
|
||||
working-directory: eval
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
uv run --locked --extra dev python -m workflow_bench.pr_evaluation baseline \
|
||||
--repo "$GITHUB_REPOSITORY" --run-id "$COMPARISON_RUN_ID" \
|
||||
--harness-sha "$GITHUB_SHA" --env-out "$GITHUB_ENV" \
|
||||
--out "$RUNNER_TEMP/release-evaluation/previous.json"
|
||||
# Both runtimes grade the pinned task source against one dependency tree
|
||||
# built from that commit's lockfile, never the runtime under test's.
|
||||
- name: Resolve pinned task revision
|
||||
id: tasks
|
||||
working-directory: eval
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(uv run --locked --extra dev python -m workflow_bench.release_report task-sha)"
|
||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::The release task set has no single pinned commit.'; exit 1; }
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ steps.tasks.outputs.sha }}
|
||||
path: tasks-base
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- name: Install sandbox runtime and pinned Claude CLI
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get -o DPkg::Lock::Timeout=600 update
|
||||
sudo apt-get -o DPkg::Lock::Timeout=600 install --yes --no-install-recommends bubblewrap ripgrep socat
|
||||
apparmor_userns=/proc/sys/kernel/apparmor_restrict_unprivileged_userns
|
||||
if [[ -r "$apparmor_userns" ]] && [[ "$(<"$apparmor_userns")" == '1' ]]; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
cli="$RUNNER_TEMP/claude-canary"
|
||||
install -d -m 0700 "$cli"
|
||||
install -m 0600 .github/claude-canary-runtime/package{,-lock}.json "$cli/"
|
||||
npm ci --prefix "$cli" --audit=false --fund=false
|
||||
test "$("$cli/node_modules/@anthropic-ai/claude-code-linux-x64/claude" --version)" = '2.1.214 (Claude Code)'
|
||||
- name: Build pinned runtime and task dependencies
|
||||
working-directory: eval
|
||||
env:
|
||||
DIAGNOSTIC_TRIAL: ${{ inputs.diagnostic_trial }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for checkout in tasks-base stable candidate; do
|
||||
if [[ ( -n "$INPUT_PR" || "${DIAGNOSTIC_TRIAL:-false}" == true ) && "$checkout" == stable ]]; then continue; fi
|
||||
uv run --locked --extra dev python -m workflow_bench.release_build \
|
||||
--repo "$GITHUB_WORKSPACE/$checkout"
|
||||
done
|
||||
- name: Prepare pinned task set
|
||||
working-directory: eval
|
||||
env:
|
||||
DIAGNOSTIC_TRIAL: ${{ inputs.diagnostic_trial }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for runtime in stable candidate; do
|
||||
if [[ ( -n "$INPUT_PR" || "${DIAGNOSTIC_TRIAL:-false}" == true ) && "$runtime" == stable ]]; then continue; fi
|
||||
uv run --locked --extra dev python -m workflow_bench.release_report prepare \
|
||||
--repo "$GITHUB_WORKSPACE/$runtime" --task-repo "$GITHUB_WORKSPACE/tasks-base" \
|
||||
--out "$RUNNER_TEMP/release-evaluation/$runtime" \
|
||||
--model "$MODEL" --effort "$EFFORT" --runs 3
|
||||
done
|
||||
- name: Prepare the capped diagnostic trial
|
||||
if: inputs.diagnostic_trial == true
|
||||
working-directory: eval
|
||||
env:
|
||||
RUNTIME_SHA: ${{ steps.resolve.outputs.sha }}
|
||||
run: |
|
||||
uv run --locked --extra dev python -m workflow_bench.diagnostic_experiments \
|
||||
--tasks "$RUNNER_TEMP/release-evaluation/candidate/tasks.yaml" \
|
||||
--repository "$GITHUB_WORKSPACE/tasks-base" --runtime "$GITHUB_WORKSPACE/candidate" \
|
||||
--harness-sha "$GITHUB_SHA" --runtime-sha "$RUNTIME_SHA" \
|
||||
--model "$MODEL" --effort "$EFFORT" --seed 3762 --repeats 1 \
|
||||
--variants no_graph mcp_eager --task-ids trivial-status-json-alias \
|
||||
--max-budget-usd 5 --bind-task-dependencies --out "$RUNNER_TEMP/release-evaluation/trial"
|
||||
- name: Require historical task dependencies before paid PR sessions
|
||||
if: inputs.candidate_pr != ''
|
||||
working-directory: eval
|
||||
run: |
|
||||
uv run --locked --extra dev python -m workflow_bench.pr_evaluation dependencies \
|
||||
--tasks "$RUNNER_TEMP/release-evaluation/candidate/tasks.yaml" \
|
||||
--baseline "$RUNNER_TEMP/release-evaluation/previous.json"
|
||||
- name: Require real containment before paid sessions
|
||||
working-directory: eval
|
||||
env:
|
||||
GITNEXUS_REQUIRE_BWRAP_CANARY: '1'
|
||||
GITNEXUS_REQUIRE_CLAUDE_CANARY: '1'
|
||||
CLAUDE_CANARY_BIN: ${{ runner.temp }}/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude
|
||||
run: >-
|
||||
uv run --locked --extra dev python -m pytest tests/test_proposer_sandbox.py tests/test_cli_shim.py
|
||||
tests/test_workflow_bench_sessions.py::test_real_cli_background_completion_is_one_complete_measurement -q
|
||||
- name: Materialize both release graphs before paid sessions
|
||||
timeout-minutes: 120
|
||||
working-directory: eval
|
||||
env:
|
||||
DIAGNOSTIC_TRIAL: ${{ inputs.diagnostic_trial }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${DIAGNOSTIC_TRIAL:-false}" == true ]]; then
|
||||
uv run --locked --extra dev python -m workflow_bench.release_preflight \
|
||||
--tasks "$RUNNER_TEMP/release-evaluation/trial/cell-0000.tasks.json" \
|
||||
--grading-controls \
|
||||
--gitnexus-root "$GITHUB_WORKSPACE/candidate" \
|
||||
--claude-bin "$RUNNER_TEMP/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude"
|
||||
exit 0
|
||||
fi
|
||||
for runtime in stable candidate; do
|
||||
if [[ -n "$INPUT_PR" && "$runtime" == stable ]]; then continue; fi
|
||||
uv run --locked --extra dev python -m workflow_bench.release_preflight \
|
||||
--tasks "$RUNNER_TEMP/release-evaluation/$runtime/tasks.yaml" \
|
||||
--grading-controls \
|
||||
--gitnexus-root "$GITHUB_WORKSPACE/$runtime" \
|
||||
--claude-bin "$RUNNER_TEMP/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude"
|
||||
done
|
||||
- name: Require offline deferred-tool compatibility before paid sessions
|
||||
if: inputs.mcp_tool_loading == true
|
||||
working-directory: eval
|
||||
env:
|
||||
GITNEXUS_REQUIRE_CLAUDE_CANARY: '1'
|
||||
CLAUDE_CANARY_BIN: ${{ runner.temp }}/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude
|
||||
run: >-
|
||||
uv run --locked --extra dev python -m pytest tests/test_mcp_tool_loading.py -q
|
||||
- name: Recheck PR opt-in and pinned head immediately before paid sessions
|
||||
if: inputs.candidate_pr != ''
|
||||
working-directory: eval
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 -m workflow_bench.pr_evaluation resolve \
|
||||
--repo "$GITHUB_REPOSITORY" --pr "$INPUT_PR" --sha "$INPUT_PR_SHA"
|
||||
- name: Run repeated paired agent sessions
|
||||
timeout-minutes: 1140
|
||||
working-directory: eval
|
||||
env:
|
||||
GITNEXUS_BENCH_OPENAI_API_KEY: ${{ secrets.GITNEXUS_BENCH_OPENAI_API_KEY }}
|
||||
GITNEXUS_BENCH_ANTHROPIC_API_KEY: ${{ secrets.GITNEXUS_BENCH_ANTHROPIC_API_KEY }}
|
||||
DIAGNOSTIC_TRIAL: ${{ inputs.diagnostic_trial }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${DIAGNOSTIC_TRIAL:-false}" == true ]]; then
|
||||
remaining=$(( ${{ needs.start-runner.outputs.stop_deadline_epoch }} - $(date +%s) - 5400 ))
|
||||
[[ "$GITHUB_RUN_ATTEMPT" == 1 ]] || { echo '::error::Paid trials cannot be rerun, including failed-job retries.'; exit 1; }
|
||||
(( remaining > 0 )) || { echo '::error::No time remains before the stop and evidence reserve.'; exit 1; }
|
||||
(( remaining <= 3600 )) || remaining=3600
|
||||
timeout --signal=TERM --kill-after=60 "${remaining}s" \
|
||||
uv run --locked --extra dev python -m workflow_bench.diagnostic_trial --execute \
|
||||
--plan "$RUNNER_TEMP/release-evaluation/trial/plan.json" \
|
||||
--runtime "$GITHUB_WORKSPACE/candidate" \
|
||||
--claude-bin "$RUNNER_TEMP/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude"
|
||||
exit 0
|
||||
fi
|
||||
arms=(baseline_nomcp baseline)
|
||||
if [[ -n "$INPUT_PR" ]]; then arms=(baseline); fi
|
||||
for runtime in stable candidate; do
|
||||
if [[ -n "$INPUT_PR" && "$runtime" == stable ]]; then continue; fi
|
||||
remaining=$(( ${{ needs.start-runner.outputs.stop_deadline_epoch }} - $(date +%s) - 5400 ))
|
||||
(( remaining > 0 )) || { echo '::error::No time remains before the stop and evidence reserve.'; exit 1; }
|
||||
GITNEXUS_BENCH_PROVIDER_USAGE="$RUNNER_TEMP/release-evaluation/$runtime/provider-usage.jsonl" \
|
||||
GITNEXUS_BENCH_SWEEP_ID="release-${{ github.run_id }}-${{ github.run_attempt }}-$runtime" \
|
||||
timeout --signal=TERM --kill-after=60 "${remaining}s" \
|
||||
uv run --locked --extra dev python -m workflow_bench.runner \
|
||||
--tasks "$RUNNER_TEMP/release-evaluation/$runtime/tasks.yaml" \
|
||||
--gitnexus-root "$GITHUB_WORKSPACE/$runtime" \
|
||||
--arms "${arms[@]}" --runs 3 --include-expensive --mcp-tool-loading eager \
|
||||
--workers 3 --timeout 3600 --model "$MODEL" --effort "$EFFORT" \
|
||||
--claude-bin "$RUNNER_TEMP/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude" \
|
||||
--out "$RUNNER_TEMP/release-evaluation/$runtime/raw"
|
||||
done
|
||||
# Descriptive only: its own output directory leaves the paired evidence
|
||||
# that release_report and release_gate read exactly as without the opt-in.
|
||||
# Trials reject these opt-ins before startup. Preserve the exact gates
|
||||
# recognized by the historical measurement-protocol validator.
|
||||
- name: Run the opt-in CLI-only arm on the candidate
|
||||
if: env.CLI_ARM == 'true'
|
||||
continue-on-error: true
|
||||
timeout-minutes: 600
|
||||
working-directory: eval
|
||||
env:
|
||||
GITNEXUS_BENCH_OPENAI_API_KEY: ${{ secrets.GITNEXUS_BENCH_OPENAI_API_KEY }}
|
||||
GITNEXUS_BENCH_ANTHROPIC_API_KEY: ${{ secrets.GITNEXUS_BENCH_ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
remaining=$(( ${{ needs.start-runner.outputs.stop_deadline_epoch }} - $(date +%s) - 5400 ))
|
||||
(( remaining > 0 )) || { echo '::warning::No time remains for the CLI-only arm.'; exit 1; }
|
||||
GITNEXUS_BENCH_PROVIDER_USAGE="$RUNNER_TEMP/release-evaluation/candidate/cli-provider-usage.jsonl" \
|
||||
GITNEXUS_BENCH_SWEEP_ID="release-${{ github.run_id }}-${{ github.run_attempt }}-cli" \
|
||||
timeout --signal=TERM --kill-after=60 "${remaining}s" \
|
||||
uv run --locked --extra dev python -m workflow_bench.runner \
|
||||
--tasks "$RUNNER_TEMP/release-evaluation/candidate/tasks.yaml" \
|
||||
--gitnexus-root "$GITHUB_WORKSPACE/candidate" \
|
||||
--arms baseline_cli --runs 3 --include-expensive \
|
||||
--workers 3 --timeout 3600 --model "$MODEL" --effort "$EFFORT" \
|
||||
--claude-bin "$RUNNER_TEMP/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude" \
|
||||
--out "$RUNNER_TEMP/release-evaluation/candidate/cli-raw"
|
||||
- name: Measure candidate with deferred MCP descriptions
|
||||
if: inputs.mcp_tool_loading == true && inputs.candidate_pr == ''
|
||||
timeout-minutes: 360
|
||||
working-directory: eval
|
||||
env:
|
||||
GITNEXUS_BENCH_OPENAI_API_KEY: ${{ secrets.GITNEXUS_BENCH_OPENAI_API_KEY }}
|
||||
GITNEXUS_BENCH_ANTHROPIC_API_KEY: ${{ secrets.GITNEXUS_BENCH_ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
remaining=$(( ${{ needs.start-runner.outputs.stop_deadline_epoch }} - $(date +%s) - 5400 ))
|
||||
(( remaining > 0 )) || { echo '::error::No time remains for deferred sessions before the evidence reserve.'; exit 1; }
|
||||
GITNEXUS_BENCH_PROVIDER_USAGE="$RUNNER_TEMP/release-evaluation/candidate/deferred-provider-usage.jsonl" \
|
||||
GITNEXUS_BENCH_SWEEP_ID="release-${{ github.run_id }}-${{ github.run_attempt }}-deferred" \
|
||||
timeout --signal=TERM --kill-after=60 "${remaining}s" \
|
||||
uv run --locked --extra dev python -m workflow_bench.runner \
|
||||
--tasks "$RUNNER_TEMP/release-evaluation/candidate/tasks.yaml" \
|
||||
--gitnexus-root "$GITHUB_WORKSPACE/candidate" \
|
||||
--arms baseline --mcp-tool-loading deferred --runs 3 --include-expensive \
|
||||
--workers 3 --timeout 3600 --model "$MODEL" --effort "$EFFORT" \
|
||||
--claude-bin "$RUNNER_TEMP/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude" \
|
||||
--out "$RUNNER_TEMP/release-evaluation/candidate/deferred"
|
||||
- name: Summarize complete or incomplete measurements
|
||||
if: ${{ always() && steps.resolve.outputs.sha != '' && inputs.diagnostic_trial != true }}
|
||||
working-directory: eval
|
||||
run: |
|
||||
if [[ -n "$INPUT_PR" ]]; then
|
||||
uv run --locked --extra dev python -m workflow_bench.pr_evaluation summarize \
|
||||
--results "$RUNNER_TEMP/release-evaluation/candidate/raw/results.jsonl" \
|
||||
--metadata "$RUNNER_TEMP/release-evaluation/candidate/metadata.json" \
|
||||
--baseline "$RUNNER_TEMP/release-evaluation/previous.json" \
|
||||
--sha '${{ steps.resolve.outputs.sha }}' --pr "$INPUT_PR" --run-id "$COMPARISON_RUN_ID" \
|
||||
--out "$RUNNER_TEMP/release-evaluation/public"
|
||||
exit 0
|
||||
fi
|
||||
failed=0
|
||||
for runtime in stable candidate; do
|
||||
if [[ -f "$RUNNER_TEMP/release-evaluation/$runtime/metadata.json" ]]; then
|
||||
uv run --locked --extra dev python -m workflow_bench.release_report summarize \
|
||||
--results "$RUNNER_TEMP/release-evaluation/$runtime/raw/results.jsonl" \
|
||||
--metadata "$RUNNER_TEMP/release-evaluation/$runtime/metadata.json" \
|
||||
--out "$RUNNER_TEMP/release-evaluation/$runtime/public" || failed=1
|
||||
else
|
||||
echo "::error::$runtime evaluation preparation failed; no measurements exist."
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
- name: Require complete paired evidence and no task regression
|
||||
if: ${{ always() && steps.resolve.outputs.sha != '' && inputs.candidate_pr == '' && inputs.diagnostic_trial != true }}
|
||||
working-directory: eval
|
||||
run: |
|
||||
uv run --locked --extra dev python -m workflow_bench.release_gate \
|
||||
--candidate "$RUNNER_TEMP/release-evaluation/candidate/public/agent-evaluation.json" \
|
||||
--candidate-sha '${{ steps.resolve.outputs.sha }}' \
|
||||
--stable "$RUNNER_TEMP/release-evaluation/stable/public/agent-evaluation.json" \
|
||||
--stable-sha '${{ steps.resolve.outputs.stable_sha }}' \
|
||||
--out "$RUNNER_TEMP/release-evaluation/public"
|
||||
- name: Summarize matched MCP loading experiment
|
||||
if: ${{ always() && inputs.mcp_tool_loading == true && steps.resolve.outputs.sha != '' && inputs.candidate_pr == '' && inputs.diagnostic_trial != true }}
|
||||
working-directory: eval
|
||||
run: |
|
||||
uv run --locked --extra dev python -m workflow_bench.mcp_loading_report \
|
||||
--eager "$RUNNER_TEMP/release-evaluation/candidate/raw/results.jsonl" \
|
||||
--deferred "$RUNNER_TEMP/release-evaluation/candidate/deferred/results.jsonl" \
|
||||
--metadata "$RUNNER_TEMP/release-evaluation/candidate/metadata.json" \
|
||||
--out "$RUNNER_TEMP/release-evaluation/public"
|
||||
- name: Collect sanitized evidence and publish summary
|
||||
if: ${{ always() && steps.resolve.outputs.sha != '' && inputs.diagnostic_trial != true }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
public="$RUNNER_TEMP/release-evaluation/public"
|
||||
mkdir -p "$public"
|
||||
if [[ -n "$INPUT_PR" ]]; then
|
||||
if [[ -f "$public/pr-cost-comparison.md" ]]; then cat "$public/pr-cost-comparison.md" >> "$GITHUB_STEP_SUMMARY"; fi
|
||||
exit 0
|
||||
fi
|
||||
for extension in json md; do
|
||||
candidate="$RUNNER_TEMP/release-evaluation/candidate/public/agent-evaluation.$extension"
|
||||
stable="$RUNNER_TEMP/release-evaluation/stable/public/agent-evaluation.$extension"
|
||||
if [[ -f "$candidate" ]]; then cp "$candidate" "$public/agent-evaluation.$extension"; fi
|
||||
if [[ -f "$stable" ]]; then cp "$stable" "$public/stable-agent-evaluation.$extension"; fi
|
||||
done
|
||||
for report in release-quality-gate agent-evaluation mcp-tool-loading; do
|
||||
if [[ -f "$public/$report.md" ]]; then cat "$public/$report.md" >> "$GITHUB_STEP_SUMMARY"; fi
|
||||
done
|
||||
- name: Summarize the opt-in CLI-only arm
|
||||
if: ${{ always() && env.CLI_ARM == 'true' && steps.resolve.outputs.sha != '' && inputs.diagnostic_trial != true }}
|
||||
continue-on-error: true
|
||||
working-directory: eval
|
||||
run: |
|
||||
set -euo pipefail
|
||||
candidate="$RUNNER_TEMP/release-evaluation/candidate"
|
||||
uv run --locked --extra dev python -m workflow_bench.cli_arm_report \
|
||||
--paired "$candidate/raw/results.jsonl" --cli "$candidate/cli-raw/results.jsonl" \
|
||||
--out "$RUNNER_TEMP/release-evaluation/public"
|
||||
cat "$RUNNER_TEMP/release-evaluation/public/cli-arm-comparison.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Summarize time, cost, and tool measurements
|
||||
if: ${{ always() && steps.resolve.outputs.sha != '' && inputs.diagnostic_trial != true }}
|
||||
working-directory: eval
|
||||
env:
|
||||
DEFERRED_ARM: ${{ inputs.mcp_tool_loading }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="$RUNNER_TEMP/release-evaluation"
|
||||
public="$root/public"
|
||||
mkdir -p "$public"
|
||||
failed=0
|
||||
for variant in stable candidate cli deferred; do
|
||||
case "$variant" in
|
||||
stable)
|
||||
if [[ -n "$INPUT_PR" ]]; then continue; fi
|
||||
results="$root/stable/raw/results.jsonl"
|
||||
ledger="$root/stable/provider-usage.jsonl" ;;
|
||||
candidate)
|
||||
results="$root/candidate/raw/results.jsonl"
|
||||
ledger="$root/candidate/provider-usage.jsonl" ;;
|
||||
cli)
|
||||
if [[ "$CLI_ARM" != true ]]; then continue; fi
|
||||
results="$root/candidate/cli-raw/results.jsonl"
|
||||
ledger="$root/candidate/cli-provider-usage.jsonl" ;;
|
||||
deferred)
|
||||
if [[ "$DEFERRED_ARM" != true || -n "$INPUT_PR" ]]; then continue; fi
|
||||
results="$root/candidate/deferred/results.jsonl"
|
||||
ledger="$root/candidate/deferred-provider-usage.jsonl" ;;
|
||||
esac
|
||||
if ! uv run --locked --extra dev python -m workflow_bench.cost_diagnostics \
|
||||
--results "$results" --provider-usage "$ledger" --allow-incomplete \
|
||||
--out "$public/$variant-diagnostics.json" --markdown-out "$public/$variant-diagnostics.md"; then
|
||||
printf '\nDetailed measurements for %s could not be validated; inspect the step log.\n' "$variant" >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
{
|
||||
printf '\n<details><summary>Detailed measurements: %s</summary>\n\n' "$variant"
|
||||
cat "$public/$variant-diagnostics.md"
|
||||
printf '\n</details>\n'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
done
|
||||
exit "$failed"
|
||||
- name: Summarize the diagnostic trial
|
||||
if: ${{ always() && inputs.diagnostic_trial == true && steps.resolve.outputs.sha != '' }}
|
||||
working-directory: eval
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="$RUNNER_TEMP/release-evaluation"
|
||||
mkdir -p "$root/public"
|
||||
printf "\n## Two-cell diagnostic trial\n\nOne task, one repetition per arm. \$5 CLI budget stop per cell; provider billing can exceed the planned \$10. This trial checks telemetry and is not release-quality evidence.\n" >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=0
|
||||
for cell in cell-0000 cell-0001; do
|
||||
if ! uv run --locked --extra dev python -m workflow_bench.cost_diagnostics \
|
||||
--results "$root/trial/results/$cell/results.jsonl" \
|
||||
--provider-usage "$root/trial/results/$cell.provider.jsonl" --allow-incomplete \
|
||||
--out "$root/public/trial-$cell-diagnostics.json" \
|
||||
--markdown-out "$root/public/trial-$cell-diagnostics.md"; then
|
||||
printf '\nMeasurements for %s could not be validated.\n' "$cell" >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
cat "$root/public/trial-$cell-diagnostics.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
done
|
||||
exit "$failed"
|
||||
- name: Upload sanitized diagnostic trial measurements
|
||||
if: ${{ always() && inputs.diagnostic_trial == true && steps.resolve.outputs.sha != '' }}
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: diagnostic-trial-${{ steps.resolve.outputs.sha }}-${{ github.run_id }}
|
||||
path: |
|
||||
${{ runner.temp }}/release-evaluation/public/trial-*-diagnostics.json
|
||||
${{ runner.temp }}/release-evaluation/public/trial-*-diagnostics.md
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Upload sanitized release evidence
|
||||
if: ${{ always() && steps.resolve.outputs.sha != '' && inputs.candidate_pr == '' && inputs.diagnostic_trial != true }}
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-agent-evaluation-${{ steps.resolve.outputs.sha }}
|
||||
path: |
|
||||
${{ runner.temp }}/release-evaluation/public/agent-evaluation.json
|
||||
${{ runner.temp }}/release-evaluation/public/agent-evaluation.md
|
||||
${{ runner.temp }}/release-evaluation/public/stable-agent-evaluation.json
|
||||
${{ runner.temp }}/release-evaluation/public/stable-agent-evaluation.md
|
||||
${{ runner.temp }}/release-evaluation/public/release-quality-gate.json
|
||||
${{ runner.temp }}/release-evaluation/public/release-quality-gate.md
|
||||
${{ runner.temp }}/release-evaluation/public/mcp-tool-loading.json
|
||||
${{ runner.temp }}/release-evaluation/public/mcp-tool-loading.md
|
||||
${{ runner.temp }}/release-evaluation/public/*-diagnostics.json
|
||||
${{ runner.temp }}/release-evaluation/public/*-diagnostics.md
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Upload the opt-in CLI-only arm comparison
|
||||
if: ${{ always() && env.CLI_ARM == 'true' && steps.resolve.outputs.sha != '' && inputs.diagnostic_trial != true }}
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-cli-arm-${{ steps.resolve.outputs.sha }}
|
||||
path: |
|
||||
${{ runner.temp }}/release-evaluation/public/cli-arm-comparison.json
|
||||
${{ runner.temp }}/release-evaluation/public/cli-arm-comparison.md
|
||||
${{ runner.temp }}/release-evaluation/public/cli-diagnostics.json
|
||||
${{ runner.temp }}/release-evaluation/public/cli-diagnostics.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
- name: Upload sanitized PR cost comparison
|
||||
if: ${{ always() && steps.resolve.outputs.sha != '' && inputs.candidate_pr != '' && inputs.diagnostic_trial != true }}
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: pr-cost-comparison-${{ steps.resolve.outputs.sha }}
|
||||
path: |
|
||||
${{ runner.temp }}/release-evaluation/public/pr-cost-comparison.json
|
||||
${{ runner.temp }}/release-evaluation/public/pr-cost-comparison.md
|
||||
${{ runner.temp }}/release-evaluation/public/*-diagnostics.json
|
||||
${{ runner.temp }}/release-evaluation/public/*-diagnostics.md
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
stop-runner:
|
||||
name: Stop and verify the dedicated EC2 runner
|
||||
needs: [start-runner, check-runner, runner-ready, watch-evaluate-pickup, evaluate]
|
||||
if: >-
|
||||
always() && github.repository == 'abhigyanpatwari/GitNexus' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
needs.start-runner.outputs.cleanup_required == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: gitnexus-evolution
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/ec2-runner
|
||||
with:
|
||||
operation: stop
|
||||
role-arn: ${{ secrets.GITNEXUS_EVOLUTION_AWS_ROLE_ARN }}
|
||||
instance-id: ${{ secrets.GITNEXUS_EVOLUTION_EC2_INSTANCE_ID }}
|
||||
region: ${{ vars.GITNEXUS_EVOLUTION_AWS_REGION }}
|
||||
stop-schedule: ${{ vars.GITNEXUS_EVOLUTION_STOP_SCHEDULE_UTC }}
|
||||
6
.github/workflows/scorecard.yml
vendored
6
.github/workflows/scorecard.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Run Scorecard
|
||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
|
@ -46,13 +46,13 @@ jobs:
|
|||
publish_results: true
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
2
.github/workflows/skill-sync.yml
vendored
2
.github/workflows/skill-sync.yml
vendored
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
|
|
|||
762
.github/workflows/test-scenario-map.yml
vendored
Normal file
762
.github/workflows/test-scenario-map.yml
vendored
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
name: Test Scenario Map
|
||||
|
||||
# Advisory report on the validated pure-JS unit tests of gitnexus/ (R13, R14, KTD13, KTD14,
|
||||
# KTD17; docs/plans/2026-10-25-1700-feat-scenario-fingerprint-plan.md): duplicate tests (the same
|
||||
# execution path, input data and expectation: the scenario fingerprint), and, with mutation
|
||||
# evidence, tests that kill no evaluated mutant and per-file gaps. The CLI is
|
||||
# gitnexus/tools/scenario-map/scenario-map.ts; its header documents the stages, flags,
|
||||
# work-directory layout and exit codes. Local equivalent, from gitnexus/ (the work dir must lie
|
||||
# outside the checkout; `npm run build` first: the fingerprint's integration tests load dist/):
|
||||
# npm run test:scenario-map -- all --work <dir> --mutation none
|
||||
#
|
||||
# Triggers and mutation scope (`--mutation`, types.ts `MutationScope`):
|
||||
# schedule weekly, `none`: the fingerprint only, minutes, no shard;
|
||||
# workflow_dispatch input `mutation`: `none` (default), `possible-duplicates` or `all` (the
|
||||
# full Phase 1 run, hours over up to 20 shards);
|
||||
# pull_request TEMPORARY, remove before merge: the `scenario-map-test` label, added by
|
||||
# magyargergo on PR #3686 only, runs the whole suite (no diff scope) with
|
||||
# mutation `possible-duplicates`, to test every stage on CI before merge.
|
||||
# When a maintainer adds the `scenario-map` label to a pull request:
|
||||
# `possible-duplicates`, the deep run for a suspicious change, diff-scoped
|
||||
# (`--changed-since` on the coverage slices: only pairs with a test of a changed
|
||||
# test file; Layer 1 over those files, the files sharing their
|
||||
# checks-and-context keys and every file with a test of no static key). A PR
|
||||
# run writes no tracking issue and no baseline; its report is an artifact and
|
||||
# the job summary.
|
||||
#
|
||||
# Jobs, each one CLI stage over one work directory ($RUNNER_TEMP/scenario-map) exchanged as
|
||||
# artifacts:
|
||||
# coverage one matrix job per Layer 1 slice (4, fail-fast off): the fingerprint's unit and
|
||||
# integration test files, split like the CI test shards.
|
||||
# plan eligibility, Layer 0, the prepared copy and run manifest, Layer 1 merged from the
|
||||
# slices (a slice that did not report leaves its files unrecorded), Layer 2,
|
||||
# candidates and the provisional shard plan.
|
||||
# value-flow for scope `all` the PDG index (`analyze --pdg` spawns workers from dist/) and the
|
||||
# static value-flow check; the trace run and the final shard plan
|
||||
# (a narrower scope re-plans its target after the trace). It runs
|
||||
# before the shards, so a shard isolates only the pairs value flow leaves able to be
|
||||
# strong (the CLI's deviation from KTD14's parallel value-flow job).
|
||||
# shards prints the final shard count (the re-plan's, or the provisional plan's when value
|
||||
# flow left no usable result); it sizes the shard matrix, and 0 skips the shards.
|
||||
# shard one matrix job per shard (fail-fast off): Layer 3 group runs, row deletion and
|
||||
# isolation until a 300-minute deadline (KTD17). Results upload even on failure.
|
||||
# merge always runs once a plan exists: merges whatever the shards and value flow left
|
||||
# (a missing shard leaves the run partial and its pairs Inconclusive, R11), compares
|
||||
# against the newest `scenario-map-baseline` artifact of an earlier completed run of
|
||||
# this workflow on the default branch, and writes the report. Only a complete
|
||||
# default-branch run uploads a new baseline (KTD13).
|
||||
# upsert-issue default branch only: a complete run replaces the tracking issue's body unless
|
||||
# the issue already records a newer run; a partial run comments and leaves the body
|
||||
# alone. The issue is found by label in any state and never closed.
|
||||
#
|
||||
# Advisory: every job is continue-on-error, so nothing here fails the run or blocks a merge. Exit
|
||||
# code 1 of a stage means "finished with incomplete results"; it is a warning, the job carries on
|
||||
# and merge reports the run as partial. Exit code 2 (usage or setup error) fails the step.
|
||||
# The full matrix can hold up to 20 runners for about 5.5 hours.
|
||||
#
|
||||
# Artifact names carry the run attempt. A downstream job takes the plan and value-flow artifact
|
||||
# names from those jobs' outputs, so re-running one job reuses the earlier attempt's inputs; merge
|
||||
# copies shard results oldest attempt first, so a later attempt of a shard wins.
|
||||
#
|
||||
# The controls run (the U17 fixture, every expected mutation and fingerprint verdict) is a PR job
|
||||
# in mutation-runner-validation.yml.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays 04:17 UTC, outside the busy CI hours.
|
||||
- cron: '17 4 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mutation:
|
||||
description: 'Mutation evidence: none (fingerprint only), possible-duplicates, or all'
|
||||
type: choice
|
||||
options: [none, possible-duplicates, all]
|
||||
default: none
|
||||
seed:
|
||||
description: 'Seed of every sampled choice, the spot audit included (default: the run id)'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
shards:
|
||||
description: 'Number of mutation shards (default: the fewest that fit, at most 20)'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
# The deep run of one pull request: only the `scenario-map` label starts it (the plan job's
|
||||
# `if`), and the TEMPORARY `scenario-map-test` label on PR #3686 by magyargergo; any other
|
||||
# label event leaves every job skipped.
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention".
|
||||
# Weekly and manual runs share the tracking issue and the baseline, so they share one slot and run
|
||||
# one at a time; each pull request has its own slot. A run that holds hours of shard work is never
|
||||
# cancelled. (GitHub keeps one pending run: a third run queued behind a pending one replaces it.)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'default' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
|
||||
env:
|
||||
# Stryker concurrency: the plan's cost estimate and the shards' runs (ubuntu-latest: 4 vCPUs).
|
||||
SCENARIO_MAP_CONCURRENCY: '4'
|
||||
# One shard's budget in the plan and its deadline in the shard job (KTD17). The shard job's
|
||||
# timeout-minutes (330) leaves room for setup and the last uploads.
|
||||
SCENARIO_MAP_DEADLINE_MINUTES: '300'
|
||||
|
||||
jobs:
|
||||
# ── Coverage: Layer 1 in slices ────────────────────────────────────
|
||||
coverage:
|
||||
name: Coverage ${{ matrix.index }}
|
||||
# TEMPORARY: the `scenario-map-test` clause (PR #3686 testing); remove before merge.
|
||||
if: github.event_name != 'pull_request' || github.event.label.name == 'scenario-map' || (github.event.label.name == 'scenario-map-test' && github.event.sender.login == 'magyargergo' && github.event.pull_request.number == 3686)
|
||||
runs-on: ubuntu-latest
|
||||
# Four slices balanced like the CI test shards (test/helpers/shard-balance.ts): the whole unit
|
||||
# and integration suite runs with coverage in three 7-8 minute shards of the test workflow
|
||||
# (ci.yml run 30014110469). Layer 1 repeats only test bodies and snapshots per test (about 5%
|
||||
# of an integration file whose work sits in beforeAll), and the prefilter drops a quarter.
|
||||
timeout-minutes: 75
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
index: [0, 1, 2, 3]
|
||||
steps:
|
||||
# A pull request's merge commit needs its first parent (the base) for the diff scope.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 1 }}
|
||||
# Integration tests load dist/ (the built CLI and its worker scripts).
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
- name: Run the slice
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
INDEX: ${{ matrix.index }}
|
||||
OF: ${{ strategy.job-total }}
|
||||
# The plan's scope (its "Choose the mutation scope" step): unless `all`, Layer 1 skips the
|
||||
# files whose tests all have a checks-and-context key no other test shares.
|
||||
MUTATION: ${{ github.event_name == 'pull_request' && 'possible-duplicates' || inputs.mutation || 'none' }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
run: |
|
||||
args=(--index "$INDEX" --of "$OF" --mutation "$MUTATION"
|
||||
--work "$RUNNER_TEMP/scenario-map" --concurrency "$SCENARIO_MAP_CONCURRENCY")
|
||||
# The merge commit's first parent is the base: the pull request's own changes. The
|
||||
# TEMPORARY `scenario-map-test` run covers the whole suite.
|
||||
if [ "$EVENT" = "pull_request" ] && [ "$LABEL" = "scenario-map" ]; then
|
||||
args+=(--changed-since 'HEAD^1')
|
||||
fi
|
||||
status=0
|
||||
npm run test:scenario-map -- coverage "${args[@]}" || status=$?
|
||||
if [ "$status" -eq 1 ]; then
|
||||
echo "::warning::a test file of slice $INDEX failed or wrote no record; the report counts it"
|
||||
elif [ "$status" -ne 0 ]; then
|
||||
exit "$status"
|
||||
fi
|
||||
- name: Upload the slice
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: scenario-map-coverage-${{ github.run_attempt }}-${{ matrix.index }}
|
||||
path: ${{ runner.temp }}/scenario-map/coverage-slice-${{ matrix.index }}.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# ── Plan: Layers 0-2, candidates, shard plan, run manifest ─────────
|
||||
plan:
|
||||
name: Plan
|
||||
needs: coverage
|
||||
# TEMPORARY: the `scenario-map-test` clause (PR #3686 testing); remove before merge.
|
||||
if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.label.name == 'scenario-map' || (github.event.label.name == 'scenario-map-test' && github.event.sender.login == 'magyargergo' && github.event.pull_request.number == 3686)) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
artifact: ${{ steps.artifact.outputs.name }}
|
||||
mutation: ${{ steps.scope.outputs.mutation }}
|
||||
# Scheduled runs always run on the default branch; their event payload has no repository.
|
||||
default_branch: ${{ github.event_name == 'schedule' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
||||
steps:
|
||||
# persist-credentials: false — the jobs upload artifacts and never push.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Layer 1 comes from the slices: the plan runs no test and needs no dist/.
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- name: Choose the mutation scope
|
||||
id: scope
|
||||
env:
|
||||
MUTATION: ${{ github.event_name == 'pull_request' && 'possible-duplicates' || inputs.mutation || 'none' }}
|
||||
run: echo "mutation=$MUTATION" >> "$GITHUB_OUTPUT"
|
||||
# No slice at all fails the plan (nothing to merge); a missing slice leaves its files
|
||||
# unrecorded in the report.
|
||||
- name: Download the coverage slices
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: scenario-map-coverage-*
|
||||
path: ${{ runner.temp }}/scenario-map-coverage
|
||||
- name: Place the coverage slices
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
root="$RUNNER_TEMP/scenario-map-coverage"
|
||||
dest="$RUNNER_TEMP/scenario-map-slices"
|
||||
mkdir -p "$dest"
|
||||
found=("$root"/scenario-map-coverage-*)
|
||||
# download-artifact unpacks a pattern that matches ONE artifact into the path itself.
|
||||
if [ "${#found[@]}" -eq 0 ] && [ -d "$root" ]; then found=("$root"); fi
|
||||
# scenario-map-coverage-<attempt>-<index>: oldest attempt first, so a later attempt wins.
|
||||
while IFS= read -r dir; do
|
||||
[ -n "$dir" ] || continue
|
||||
for file in "$dir"/coverage-slice-*.json; do cp "$file" "$dest/"; done
|
||||
done < <(printf '%s\n' "${found[@]}" | sort -V)
|
||||
ls "$dest"
|
||||
- name: Plan the run
|
||||
env:
|
||||
# Full-suite Layer 2 exceeds Node's default ~4 GiB heap. Budget 8 GiB
|
||||
# for this step, leaving room on the 16 GB runner for native memory.
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
SEED: ${{ inputs.seed || github.run_id }}
|
||||
SHARDS: ${{ inputs.shards }}
|
||||
MUTATION: ${{ steps.scope.outputs.mutation }}
|
||||
run: |
|
||||
# The slices carry Layer 1, and a pull request's diff scope with it.
|
||||
args=(--work "$RUNNER_TEMP/scenario-map" --seed "$SEED" --mutation "$MUTATION"
|
||||
--concurrency "$SCENARIO_MAP_CONCURRENCY"
|
||||
--deadline-minutes "$SCENARIO_MAP_DEADLINE_MINUTES"
|
||||
--coverage "$RUNNER_TEMP/scenario-map-slices")
|
||||
if [ -n "$SHARDS" ]; then args+=(--shards "$SHARDS"); fi
|
||||
npm run test:scenario-map -- plan "${args[@]}"
|
||||
- name: Upload the plan
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: scenario-map-plan-${{ github.run_attempt }}
|
||||
path: |
|
||||
${{ runner.temp }}/scenario-map/plan.json
|
||||
${{ runner.temp }}/scenario-map/scenario-run-manifest.json
|
||||
${{ runner.temp }}/scenario-map/layer0.json
|
||||
${{ runner.temp }}/scenario-map/layer2.json
|
||||
${{ runner.temp }}/scenario-map/consolidation.json
|
||||
${{ runner.temp }}/scenario-map/consolidation.html
|
||||
${{ runner.temp }}/scenario-map/fixtures.json
|
||||
${{ runner.temp }}/scenario-map/coverage/coverage.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
- name: Name the plan artifact
|
||||
id: artifact
|
||||
env:
|
||||
NAME: scenario-map-plan-${{ github.run_attempt }}
|
||||
run: echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Value flow: PDG static check, trace run, final shard plan ──────
|
||||
value-flow:
|
||||
name: Value flow
|
||||
needs: plan
|
||||
if: ${{ !cancelled() && needs.plan.outputs.artifact != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Full-suite tracing runs candidate files twice, including their runnable siblings.
|
||||
# The 31k-case inventory exceeded 50 minutes; leave room for both passes and uploads.
|
||||
timeout-minutes: 100
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
artifact: ${{ steps.artifact.outputs.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# The trace runs integration tests, and the PDG index of scope `all` spawns workers: dist/.
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
- name: Download the plan
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: ${{ needs.plan.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/scenario-map
|
||||
- name: Run value flow
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
GITNEXUS_WORKER_READY_TIMEOUT_MS: '120000'
|
||||
run: |
|
||||
status=0
|
||||
npm run test:scenario-map -- value-flow --work "$RUNNER_TEMP/scenario-map" || status=$?
|
||||
if [ "$status" -eq 1 ]; then
|
||||
echo "::warning::value flow is degraded, or its trace did not run or was invalid; the report says so"
|
||||
elif [ "$status" -ne 0 ]; then
|
||||
exit "$status"
|
||||
fi
|
||||
- name: Upload the value-flow result
|
||||
id: upload
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: scenario-map-value-flow-${{ github.run_attempt }}
|
||||
path: |
|
||||
${{ runner.temp }}/scenario-map/value-flow.json
|
||||
${{ runner.temp }}/scenario-map/value-flow/pdg/analyze.log
|
||||
retention-days: 14
|
||||
# Named only when value-flow.json was uploaded: without it, shards and merge fall back to
|
||||
# the provisional plan and merge reports value flow missing.
|
||||
- name: Name the value-flow artifact
|
||||
id: artifact
|
||||
if: always() && steps.upload.outcome == 'success'
|
||||
env:
|
||||
NAME: scenario-map-value-flow-${{ github.run_attempt }}
|
||||
run: |
|
||||
if [ -f "$RUNNER_TEMP/scenario-map/value-flow.json" ]; then
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Shard count: sizes the matrix ──────────────────────────────────
|
||||
shards:
|
||||
name: Shard count
|
||||
needs: [plan, value-flow]
|
||||
# Scope `none` plans no shard.
|
||||
if: ${{ !cancelled() && needs.plan.outputs.artifact != '' && needs.plan.outputs.mutation != 'none' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
count: ${{ steps.count.outputs.count }}
|
||||
matrix: ${{ steps.count.outputs.matrix }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- name: Download the plan
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: ${{ needs.plan.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/scenario-map
|
||||
- name: Download the value-flow result
|
||||
if: needs.value-flow.outputs.artifact != ''
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: ${{ needs.value-flow.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/scenario-map
|
||||
# --silent: the stage prints the count and nothing else; npm's banner would precede it.
|
||||
- name: Read the final shard count
|
||||
id: count
|
||||
run: |
|
||||
count="$(npm run --silent test:scenario-map -- shards --work "$RUNNER_TEMP/scenario-map")"
|
||||
if ! [[ "$count" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::the shards stage printed '$count', not a shard count"
|
||||
exit 1
|
||||
fi
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
echo "matrix=$(jq -cn --argjson n "$count" '[range($n)]')" >> "$GITHUB_OUTPUT"
|
||||
echo "Final shard plan: $count shard(s)"
|
||||
|
||||
# ── Layer 3 shards ─────────────────────────────────────────────────
|
||||
shard:
|
||||
name: Shard ${{ matrix.index }}
|
||||
needs: [plan, value-flow, shards]
|
||||
if: ${{ !cancelled() && needs.shards.outputs.matrix != '' && needs.shards.outputs.count != '0' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 330
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Leave runner slots for PR CI on the default plan (about 9 shards at concurrency 4).
|
||||
max-parallel: 10
|
||||
matrix:
|
||||
index: ${{ fromJSON(needs.shards.outputs.matrix) }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- name: Download the plan
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: ${{ needs.plan.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/scenario-map
|
||||
- name: Download the value-flow result
|
||||
if: needs.value-flow.outputs.artifact != ''
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: ${{ needs.value-flow.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/scenario-map
|
||||
# The shard verifies its own prepared copy against the run manifest; a mismatch runs
|
||||
# nothing and leaves its groups' pairs Inconclusive. The step timeout sits inside the job's,
|
||||
# so a shard that overruns its deadline still uploads what it finished.
|
||||
- name: Run the shard
|
||||
timeout-minutes: 315
|
||||
env:
|
||||
INDEX: ${{ matrix.index }}
|
||||
COUNT: ${{ needs.shards.outputs.count }}
|
||||
run: |
|
||||
status=0
|
||||
npm run test:scenario-map -- shard --work "$RUNNER_TEMP/scenario-map" \
|
||||
--index "$INDEX" --of "$COUNT" \
|
||||
--deadline-minutes "$SCENARIO_MAP_DEADLINE_MINUTES" \
|
||||
--concurrency "$SCENARIO_MAP_CONCURRENCY" || status=$?
|
||||
if [ "$status" -eq 1 ]; then
|
||||
echo "::warning::shard $INDEX finished with incomplete results; the report says which"
|
||||
elif [ "$status" -ne 0 ]; then
|
||||
exit "$status"
|
||||
fi
|
||||
# Every group result is written as soon as it exists: upload whatever the shard finished,
|
||||
# even after a failure.
|
||||
- name: Upload the shard results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: scenario-map-shard-${{ github.run_attempt }}-${{ matrix.index }}
|
||||
path: |
|
||||
${{ runner.temp }}/scenario-map/shard-${{ matrix.index }}.json
|
||||
${{ runner.temp }}/scenario-map/row-deletion-shard-${{ matrix.index }}.json
|
||||
${{ runner.temp }}/scenario-map/observations/
|
||||
${{ runner.temp }}/scenario-map/batches/
|
||||
${{ runner.temp }}/scenario-map/isolation/
|
||||
${{ runner.temp }}/scenario-map/groups/*/stryker/stryker.log
|
||||
${{ runner.temp }}/scenario-map/groups/*/*/stryker/stryker.log
|
||||
retention-days: 14
|
||||
|
||||
# ── Merge: verdicts and the report ─────────────────────────────────
|
||||
merge:
|
||||
name: Merge and report
|
||||
needs: [plan, value-flow, shard]
|
||||
if: ${{ always() && needs.plan.outputs.artifact != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read # list and download an earlier run's scenario-map-baseline artifact
|
||||
outputs:
|
||||
artifact: ${{ steps.artifact.outputs.name }}
|
||||
complete: ${{ steps.merge.outputs.complete }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- name: Download the plan
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: ${{ needs.plan.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/scenario-map
|
||||
- name: Download the value-flow result
|
||||
if: needs.value-flow.outputs.artifact != ''
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: ${{ needs.value-flow.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/scenario-map
|
||||
# No shard result at all is a valid, partial outcome: merge reports every shard missing.
|
||||
- name: Download the shard results
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: scenario-map-shard-*
|
||||
path: ${{ runner.temp }}/scenario-map-shards
|
||||
- name: Place the shard results
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
work="$RUNNER_TEMP/scenario-map"
|
||||
root="$RUNNER_TEMP/scenario-map-shards"
|
||||
found=("$root"/scenario-map-shard-*)
|
||||
# download-artifact unpacks a pattern that matches ONE artifact into the path itself, not
|
||||
# into a directory named after it (a one-shard plan): the path is then that artifact.
|
||||
if [ "${#found[@]}" -eq 0 ] && [ -d "$root" ]; then found=("$root"); fi
|
||||
# scenario-map-shard-<attempt>-<index>: oldest attempt first, so a later attempt wins.
|
||||
while IFS= read -r dir; do
|
||||
[ -n "$dir" ] || continue
|
||||
echo "Using $(basename "$dir")"
|
||||
cp -R "$dir/." "$work/"
|
||||
done < <(printf '%s\n' "${found[@]}" | sort -V)
|
||||
# KTD13: the newest non-expired `scenario-map-baseline` of an earlier completed run of THIS
|
||||
# workflow on the default branch; only complete default-branch runs upload one. Never a
|
||||
# repository-wide artifact listing: it holds fork pull_request runs too, and a fork branch
|
||||
# may carry the default branch's name. Only schedule and workflow_dispatch runs count (a
|
||||
# labelled pull request run never does), and each run's head repository is checked as well. None found: "first run" banner. A
|
||||
# failure here must not cost the run its report: merge then runs without a baseline, and a
|
||||
# baseline merge cannot read gets the incompatible-baseline banner.
|
||||
- name: Fetch the baseline
|
||||
id: baseline
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch || github.ref_name }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/scenario-map-baseline"
|
||||
mkdir -p "$dir"
|
||||
if ! gh api --method GET \
|
||||
"repos/$GITHUB_REPOSITORY/actions/workflows/test-scenario-map.yml/runs" \
|
||||
-f branch="$DEFAULT_BRANCH" -f status=completed -f per_page=20 > "$dir/runs.json"; then
|
||||
echo "::warning::could not list the earlier runs; the report shows no baseline"
|
||||
exit 0
|
||||
fi
|
||||
id=""
|
||||
# Newest first: this workflow's scheduled or manual runs other than this one, whose
|
||||
# repository and head repository are this repository.
|
||||
while IFS= read -r run_id; do
|
||||
[ -n "$run_id" ] || continue
|
||||
if ! gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?name=scenario-map-baseline" \
|
||||
< /dev/null > "$dir/artifacts.json"; then
|
||||
continue
|
||||
fi
|
||||
id="$(jq -r '[.artifacts[] | select(.expired | not)]
|
||||
| sort_by(.created_at) | last | .id // empty' "$dir/artifacts.json")"
|
||||
if [ -n "$id" ]; then
|
||||
echo "Baseline run: $run_id"
|
||||
break
|
||||
fi
|
||||
done < <(jq -r --argjson run "$GITHUB_RUN_ID" --argjson repo "$GITHUB_REPOSITORY_ID" '
|
||||
[.workflow_runs[]
|
||||
| select(.id != $run
|
||||
and (.event == "schedule" or .event == "workflow_dispatch")
|
||||
and .repository.id == $repo
|
||||
and .head_repository.id == $repo)]
|
||||
| sort_by(.created_at) | reverse | .[].id' "$dir/runs.json")
|
||||
if [ -z "$id" ]; then
|
||||
echo "No earlier scenario-map-baseline artifact: first run, no baseline."
|
||||
exit 0
|
||||
fi
|
||||
if ! gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$id/zip" > "$dir/baseline.zip" \
|
||||
|| ! unzip -q -o "$dir/baseline.zip" -d "$dir/report" \
|
||||
|| [ ! -f "$dir/report/scenario-map-report.json" ]; then
|
||||
echo "::warning::could not fetch baseline artifact $id; the report shows no baseline"
|
||||
exit 0
|
||||
fi
|
||||
echo "Baseline: artifact $id"
|
||||
echo "report=$dir/report/scenario-map-report.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Merge
|
||||
id: merge
|
||||
env:
|
||||
BASELINE: ${{ steps.baseline.outputs.report }}
|
||||
ON_DEFAULT_BRANCH: ${{ needs.plan.outputs.default_branch }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
work="$RUNNER_TEMP/scenario-map"
|
||||
args=(--work "$work" --run-id "$GITHUB_RUN_ID" --run-attempt "$RUN_ATTEMPT"
|
||||
--run-url "$RUN_URL" --ref "$REF")
|
||||
if [ -n "$BASELINE" ]; then args+=(--baseline "$BASELINE"); fi
|
||||
if [ "$ON_DEFAULT_BRANCH" = 'true' ]; then args+=(--default-branch); fi
|
||||
status=0
|
||||
npm run test:scenario-map -- merge "${args[@]}" || status=$?
|
||||
if [ "$status" -eq 1 ]; then
|
||||
echo "::warning::partial report: some shard or value-flow result is missing"
|
||||
elif [ "$status" -ne 0 ]; then
|
||||
exit "$status"
|
||||
fi
|
||||
complete="$(jq -r '.completeness.complete == true' "$work/scenario-map-report.json")"
|
||||
echo "complete=$complete" >> "$GITHUB_OUTPUT"
|
||||
- name: Upload the report
|
||||
id: upload
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: scenario-map-report-${{ github.run_attempt }}
|
||||
path: |
|
||||
${{ runner.temp }}/scenario-map/scenario-map-report.json
|
||||
${{ runner.temp }}/scenario-map/scenario-map-report.md
|
||||
${{ runner.temp }}/scenario-map/issue-body.md
|
||||
retention-days: 90
|
||||
# The tracking-issue view on the run page: a pull request run's only rendered report.
|
||||
- name: Summarise the report
|
||||
if: always()
|
||||
run: |
|
||||
body="$RUNNER_TEMP/scenario-map/issue-body.md"
|
||||
if [ -f "$body" ]; then tail -n +2 "$body" >> "$GITHUB_STEP_SUMMARY"; fi
|
||||
- name: Name the report artifact
|
||||
id: artifact
|
||||
if: always() && steps.upload.outcome == 'success'
|
||||
env:
|
||||
NAME: scenario-map-report-${{ github.run_attempt }}
|
||||
run: |
|
||||
if [ -f "$RUNNER_TEMP/scenario-map/scenario-map-report.json" ]; then
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# overwrite: a re-run attempt replaces this run's own baseline instead of failing on it.
|
||||
- name: Upload the baseline
|
||||
if: steps.merge.outputs.complete == 'true' && needs.plan.outputs.default_branch == 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: scenario-map-baseline
|
||||
path: ${{ runner.temp }}/scenario-map/scenario-map-report.json
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
overwrite: true
|
||||
|
||||
# ── Tracking issue (R13) ───────────────────────────────────────────
|
||||
# The only job with a write scope, and only on the default branch. No install scripts run here
|
||||
# (lifecycle-scripts: 'false'): tsx alone runs the decision.
|
||||
upsert-issue:
|
||||
name: Upsert tracking issue
|
||||
needs: [plan, merge]
|
||||
if: ${{ !cancelled() && needs.plan.outputs.default_branch == 'true' && needs.merge.outputs.artifact != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write # find, open, update or comment on the tracking issue (and its label)
|
||||
env:
|
||||
LABEL: test-scenario-map
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
lifecycle-scripts: 'false'
|
||||
- name: Download the report
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: ${{ needs.merge.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/scenario-map-report
|
||||
- name: Find the tracking issue
|
||||
id: find
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: process.env.LABEL,
|
||||
state: 'all',
|
||||
per_page: 100,
|
||||
});
|
||||
// Any state; the oldest one when several carry the label.
|
||||
const tracking = issues
|
||||
.filter((issue) => !issue.pull_request)
|
||||
.sort((a, b) => a.number - b.number)[0];
|
||||
if (tracking === undefined) {
|
||||
core.info('No tracking issue yet.');
|
||||
return;
|
||||
}
|
||||
const issueDir = path.join(process.env.RUNNER_TEMP, 'scenario-map-issue');
|
||||
fs.mkdirSync(issueDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(issueDir, 'existing-body.md'), tracking.body ?? '');
|
||||
core.setOutput('number', String(tracking.number));
|
||||
core.info(`Tracking issue #${tracking.number} (${tracking.state}).`);
|
||||
# The decision is issue-body.ts's own R13/KTD13 rule (shouldUpdateIssue): a complete
|
||||
# default-branch run updates unless the body's first-line run marker records a newer run.
|
||||
- name: Decide the update
|
||||
id: decide
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ steps.find.outputs.number }}
|
||||
run: |
|
||||
node --import tsx --input-type=module <<'EOF'
|
||||
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { shouldUpdateIssue } from './tools/scenario-map/report/issue-body.ts';
|
||||
import { readReportFields } from './tools/scenario-map/report/json.ts';
|
||||
import { completenessLine, degradedLine, runLink } from './tools/scenario-map/report/markdown.ts';
|
||||
|
||||
const { ISSUE_NUMBER, GITHUB_OUTPUT, RUNNER_TEMP } = process.env;
|
||||
const reportDir = path.join(RUNNER_TEMP, 'scenario-map-report');
|
||||
const issueDir = path.join(RUNNER_TEMP, 'scenario-map-issue');
|
||||
const report = await readReportFields(
|
||||
path.join(reportDir, 'scenario-map-report.json'), ['run', 'completeness'],
|
||||
);
|
||||
const existing = ISSUE_NUMBER
|
||||
? readFileSync(path.join(issueDir, 'existing-body.md'), 'utf8')
|
||||
: null;
|
||||
let action = 'skip';
|
||||
if (report.completeness.complete) {
|
||||
if (shouldUpdateIssue(report, existing)) action = existing === null ? 'create' : 'update';
|
||||
else console.log('The issue already records a newer run: body left alone.');
|
||||
} else if (existing !== null) {
|
||||
action = 'comment';
|
||||
const lines = [
|
||||
`Partial ${runLink(report.run)}: the issue body is left unchanged; only a complete run updates it.`,
|
||||
'',
|
||||
`- ${completenessLine(report)}`,
|
||||
`- ${degradedLine(report)}`,
|
||||
'',
|
||||
"The run's report artifact holds every result it produced.",
|
||||
];
|
||||
mkdirSync(issueDir, { recursive: true });
|
||||
writeFileSync(path.join(issueDir, 'comment.md'), `${lines.join('\n')}\n`);
|
||||
} else {
|
||||
console.log('Partial run and no tracking issue yet: nothing to comment on.');
|
||||
}
|
||||
appendFileSync(GITHUB_OUTPUT, `action=${action}\n`);
|
||||
console.log(`Tracking issue action: ${action}`);
|
||||
EOF
|
||||
# Bodies are read from files, never passed through env or outputs. Never closes the issue.
|
||||
- name: Apply the update
|
||||
if: steps.decide.outputs.action != 'skip'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
ACTION: ${{ steps.decide.outputs.action }}
|
||||
ISSUE_NUMBER: ${{ steps.find.outputs.number }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { owner, repo } = context.repo;
|
||||
const label = process.env.LABEL;
|
||||
const read = (dir, file) =>
|
||||
fs.readFileSync(path.join(process.env.RUNNER_TEMP, dir, file), 'utf8');
|
||||
switch (process.env.ACTION) {
|
||||
case 'create': {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: label });
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
color: 'c5def5',
|
||||
description: 'Tracking issue of the weekly test scenario map',
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title: 'Test scenario map: repeat candidates, weak tests and gaps',
|
||||
body: read('scenario-map-report', 'issue-body.md'),
|
||||
labels: [label],
|
||||
});
|
||||
core.info(`Opened tracking issue #${data.number}.`);
|
||||
break;
|
||||
}
|
||||
case 'update':
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: Number(process.env.ISSUE_NUMBER),
|
||||
body: read('scenario-map-report', 'issue-body.md'),
|
||||
});
|
||||
core.info(`Updated the body of #${process.env.ISSUE_NUMBER}.`);
|
||||
break;
|
||||
case 'comment':
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: Number(process.env.ISSUE_NUMBER),
|
||||
body: read('scenario-map-issue', 'comment.md'),
|
||||
});
|
||||
core.info(`Commented on #${process.env.ISSUE_NUMBER}.`);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown action ${process.env.ACTION}`);
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
name: Tree-sitter Upgrade Readiness
|
||||
name: Tree-sitter Compatibility Workarounds
|
||||
|
||||
# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks:
|
||||
# 1. Peer-dep compatibility — can each NPM-installed grammar install cleanly
|
||||
# with tree-sitter@0.25.0 without --legacy-peer-deps?
|
||||
# Tracks the workarounds GitNexus carries for its bundled tree-sitter runtime:
|
||||
# 1. Peer-dep patches — which npm grammars still reject the runtime, so
|
||||
# prepare-tree-sitter-bundle.cjs must keep patching their manifests at pack
|
||||
# time, and which patches a grammar bump would let us drop.
|
||||
# 2. Vendored grammars — each grammar in .github/vendored-grammars.json
|
||||
# (c/swift/kotlin/dart/proto/objc) is classified by its vendored ABI, read
|
||||
# straight from gitnexus/vendor/<name>/src/parser.c (NOT node_modules,
|
||||
|
|
@ -30,6 +31,7 @@ on:
|
|||
- '.github/scripts/test_check_tree_sitter_upgrade_readiness.py'
|
||||
- '.github/vendored-grammars.json'
|
||||
- '.github/workflows/tree-sitter-upgrade-readiness.yml'
|
||||
- 'gitnexus/scripts/prepare-tree-sitter-bundle.cjs'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
|
@ -100,7 +102,7 @@ jobs:
|
|||
- name: Annotate PR with readiness status
|
||||
if: github.event_name == 'pull_request' && steps.readiness.outputs.exit_code != '0'
|
||||
run: |
|
||||
echo "::warning::Tree-sitter 0.25 upgrade has blockers. See job output for the full readiness report."
|
||||
echo "::notice::Tree-sitter compatibility workarounds remain. See job output for the full report."
|
||||
|
||||
# Issue mutation is isolated here so `issues: write` is only ever granted on the
|
||||
# scheduled run (never on PRs). Consumes the report + exit_code via job outputs.
|
||||
|
|
@ -113,18 +115,20 @@ jobs:
|
|||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Upsert tracking issue on blockers
|
||||
- name: Upsert tracking issue while workarounds remain
|
||||
if: needs.report.outputs.exit_code != '0'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
REPORT: ${{ needs.report.outputs.report }}
|
||||
with:
|
||||
script: |
|
||||
const title = 'Tree-sitter 0.25 upgrade readiness';
|
||||
const title = 'Tree-sitter compatibility workarounds';
|
||||
// Adopt the issue opened under the pre-0.25 title instead of opening a twin.
|
||||
const titles = [title, 'Tree-sitter 0.25 upgrade readiness'];
|
||||
const report = process.env.REPORT;
|
||||
const body = report + '\n\n' +
|
||||
'<sub>Generated daily by `.github/workflows/tree-sitter-upgrade-readiness.yml`. ' +
|
||||
'Closes automatically when all blockers are resolved.</sub>';
|
||||
'Closes automatically when no workarounds remain.</sub>';
|
||||
const { data: open } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
|
@ -132,7 +136,7 @@ jobs:
|
|||
labels: 'tree-sitter-drift',
|
||||
per_page: 10,
|
||||
});
|
||||
const existing = open.find(i => i.title === title);
|
||||
const existing = open.find(i => titles.includes(i.title));
|
||||
if (existing) {
|
||||
// Extract ready/total count for the changelog comment.
|
||||
// The two report.match() regexes below are mirrored as
|
||||
|
|
@ -156,7 +160,7 @@ jobs:
|
|||
);
|
||||
const blockerMatch = requireMatch(
|
||||
'blocker count',
|
||||
report.match(/\*\*Blocked\*\* — (\d+) grammars? /),
|
||||
report.match(/\*\*Workarounds\*\* — (\d+) grammars? /),
|
||||
);
|
||||
const ready = readyMatch[1];
|
||||
const total = readyMatch[2];
|
||||
|
|
@ -188,7 +192,7 @@ jobs:
|
|||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let comment = `**${today}:** ${ready}/${total} npm-installed ready. ${blockers} blocker(s) remaining.`;
|
||||
let comment = `**${today}:** ${ready}/${total} npm-installed accept the runtime. ${blockers} workaround(s) remaining.`;
|
||||
if (changes.length > 0) {
|
||||
comment += '\n\nChanges:\n' + changes.map(c => `- ${c}`).join('\n');
|
||||
} else {
|
||||
|
|
@ -205,6 +209,7 @@ jobs:
|
|||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: existing.number,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
core.info(`Updated existing issue #${existing.number}`);
|
||||
|
|
@ -224,7 +229,9 @@ jobs:
|
|||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const title = 'Tree-sitter 0.25 upgrade readiness';
|
||||
const title = 'Tree-sitter compatibility workarounds';
|
||||
// Adopt the issue opened under the pre-0.25 title instead of opening a twin.
|
||||
const titles = [title, 'Tree-sitter 0.25 upgrade readiness'];
|
||||
const { data: open } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
|
@ -232,13 +239,13 @@ jobs:
|
|||
labels: 'tree-sitter-drift',
|
||||
per_page: 10,
|
||||
});
|
||||
const existing = open.find(i => i.title === title);
|
||||
const existing = open.find(i => titles.includes(i.title));
|
||||
if (existing) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: existing.number,
|
||||
body: 'All grammars are now compatible with tree-sitter@0.25. Upgrade is ready! Closing automatically.',
|
||||
body: 'No compatibility workarounds remain: every grammar runs on the bundled tree-sitter runtime as published. Closing automatically.',
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
|
|
|
|||
2
.github/workflows/triage-sweep.yml
vendored
2
.github/workflows/triage-sweep.yml
vendored
|
|
@ -66,7 +66,7 @@ jobs:
|
|||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
|
|
|
|||
2
.github/workflows/trivy.yml
vendored
2
.github/workflows/trivy.yml
vendored
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
|
|
|||
4
.github/workflows/workflow-lint.yml
vendored
4
.github/workflows/workflow-lint.yml
vendored
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
|
|
|||
10
.github/zizmor.yml
vendored
10
.github/zizmor.yml
vendored
|
|
@ -51,6 +51,16 @@ rules:
|
|||
# context) holds no write permissions. Header comment documents.
|
||||
- pr-labeler.yml
|
||||
|
||||
# pull_request_target is required for a label request on fork PRs to
|
||||
# call the protected evaluator on main. This hosted handler checks
|
||||
# out only github.sha (the trusted base commit), never PR code, and
|
||||
# verifies the labeling actor's Maintain/Admin role plus the live
|
||||
# label/head. Its token is read-only. The evaluator job calls trusted
|
||||
# reusable workflow code under the existing main-only protected
|
||||
# environment; no PR source is executed by this request handler.
|
||||
# Tests lock down base checkout, maintainer gate and changed-head denial.
|
||||
- pr-cost-evaluation.yml
|
||||
|
||||
# Note: cache-poisoning is NOT exempted. The two prior findings in
|
||||
# publish.yml and the former release-candidate.yml were fixed structurally
|
||||
# by dropping `cache: npm` from those workflows (matches the pattern used
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -34,6 +34,10 @@ coverage/
|
|||
.vitest/
|
||||
.tmp-test/
|
||||
gitnexus/.tmp-test/
|
||||
# Mutation testing (gitnexus/tools/mutation). Prepared copies and run directories live
|
||||
# outside the checkout; these catch a Stryker run started inside it.
|
||||
.stryker-tmp/
|
||||
gitnexus/reports/mutation/
|
||||
|
||||
# Misc
|
||||
*.local
|
||||
|
|
@ -87,6 +91,7 @@ gitnexus/test/fixtures/lang-resolution/**/bin
|
|||
GitNexus.sln
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
.devcontainer/generated/
|
||||
|
||||
# Vendored tree-sitter grammar build artifacts (created at install time,
|
||||
# never committed). See docs/plans/2026-04-15-002-fix-tree-sitter-proto-vendor-deps-plan.md
|
||||
|
|
|
|||
|
|
@ -2,3 +2,7 @@
|
|||
c9fdab17f25ebaf332fba6e6ba55ee328f20fe66:README.md:curl-auth-header:348
|
||||
# Synthetic Kotlin Actuator fixture value from PR #3107; no credential was present.
|
||||
3951079300a18b14e79f5b5f5dd778ae19ced6e3:gitnexus/test/integration/spring-actuator-kotlin-runtime-pipeline.test.ts:generic-api-key:8
|
||||
# Vitest provide/inject key name in the mutation runner protocol from PR #3686; no credential was present.
|
||||
bf4fe7666212c1e2a82aea8309095f63e4b23dcc:gitnexus/tools/mutation/runner/protocol.ts:generic-api-key:11
|
||||
# The previous wording of the comment above quoted that key name and was itself flagged; no credential was present.
|
||||
02989dd688f863248993122879818ffc4cc95e11:.gitleaksignore:generic-api-key:5
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ dist/
|
|||
coverage/
|
||||
gitnexus/vendor/
|
||||
gitnexus/test/fixtures/
|
||||
# Line/column-sensitive callable identity and taint regression.
|
||||
gitnexus/test/integration/cfg/fixtures/inline-taint-repo/
|
||||
gitnexus-web/test/fixtures/
|
||||
gitnexus-web/playwright-report/
|
||||
gitnexus-web/test-results/
|
||||
|
|
|
|||
113
AGENTS.md
113
AGENTS.md
|
|
@ -1,18 +1,18 @@
|
|||
<!-- version: 1.17.0 -->
|
||||
<!-- Last updated: 2026-09-24 -->
|
||||
<!-- version: 1.18.0 -->
|
||||
<!-- Last updated: 2026-10-17 -->
|
||||
|
||||
Last reviewed: 2026-09-24
|
||||
Last reviewed: 2026-10-17
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
## Scope
|
||||
|
||||
| Boundary | Rule |
|
||||
|----------|------|
|
||||
| **Reads** | `gitnexus/`, `gitnexus-web/`, `eval/`, plugin packages, `.github/`, `.gitnexus/`, docs. |
|
||||
| **Writes** | Only paths required for the change; keep diffs minimal. Update lockfiles when deps change. |
|
||||
| **Executes** | `npm`, `npx`, `node` under `gitnexus/` and `gitnexus-web/`; `uv run` for Python under `eval/`; documented CI/dev workflows. |
|
||||
| **Off-limits** | Real `.env` / secrets, production credentials, unrelated repos, destructive git ops without confirmation. |
|
||||
| Boundary | Rule |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Reads** | `gitnexus/`, `gitnexus-web/`, `eval/`, plugin packages, `.github/`, `.gitnexus/`, docs. |
|
||||
| **Writes** | Only paths required for the change; keep diffs minimal. Update lockfiles when deps change. |
|
||||
| **Executes** | `npm`, `npx`, `node` under `gitnexus/` and `gitnexus-web/`; `uv run` for Python under `eval/`; documented CI/dev workflows. |
|
||||
| **Off-limits** | Real `.env` / secrets, production credentials, unrelated repos, destructive git ops without confirmation. |
|
||||
|
||||
## Model Configuration
|
||||
|
||||
|
|
@ -22,11 +22,12 @@ Last reviewed: 2026-09-24
|
|||
## Execution Sequence (complex tasks)
|
||||
|
||||
For multi-step work, state up front:
|
||||
|
||||
1. Which rules in this file and **[GUARDRAILS.md](GUARDRAILS.md)** apply (and any relevant Signs).
|
||||
2. Current **Scope** boundaries.
|
||||
3. Which **validation commands** you will run (`cd gitnexus && npm test`, `npx tsc --noEmit`).
|
||||
|
||||
On long threads, *"Remember: apply all AGENTS.md rules"* re-weights these instructions against context dilution.
|
||||
On long threads, _"Remember: apply all AGENTS.md rules"_ re-weights these instructions against context dilution.
|
||||
|
||||
## Claude Code hooks
|
||||
|
||||
|
|
@ -89,30 +90,32 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke
|
|||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-09-24 | 1.17.0 | Clones with the same `origin` URL now share a store automatically; `--no-share` records a lasting opt-out (#3352). |
|
||||
| 2026-09-24 | 1.16.0 | Documented the shared worktree index store (`<GITNEXUS_HOME>/stores/`, `analyze --share-with`, `GITNEXUS_SHARED_STORE=off`) in the storage notes (#3352). |
|
||||
| 2026-09-07 | 1.15.0 | Added the Objective-C provider guide as the required reference before changing Objective-C parsing or resolution. |
|
||||
| 2026-07-20 | 1.14.0 | `gitnexus-review` gains a coordinated swarm: six `ci-personas/` lanes the CI review agent dispatches as subagents (via the `Agent` tool), with a bounded critic gate and sidechain-excluded evidence. |
|
||||
| 2026-07-16 | 1.13.0 | `gitnexus-plan` asks plan depth up front (quick/standard/deep) in interactive runs; `gitnexus-lfg` gate slimmed to proceed/stop (Deepen stays as the route-back mechanism). |
|
||||
| 2026-07-16 | 1.12.0 | Renamed `gitnexus-pr-review` to `gitnexus-review`; added PR URL/number, branch/range, and local-change targets plus install migration (setup warns on a legacy `gitnexus-pr-review` dir and leaves it in place; uninstall removes it). |
|
||||
| 2026-07-11 | 1.11.0 | Skill family shipped via npm skills/ + plugin (sync-guarded); added eval/workflow_bench token-savings benchmark. |
|
||||
| 2026-07-11 | 1.10.0 | Added `gitnexus-work` (plan executor) and `gitnexus-lfg` (plan → deepen/work gate → review pipeline) skills; section renamed to Engineering planning & execution. |
|
||||
| 2026-07-11 | 1.9.0 | Added Engineering planning (`/gitnexus-plan`) section; registered the `gitnexus-plan` skill (`.claude/skills/gitnexus-plan/`). |
|
||||
| 2026-05-22 | 1.8.0 | Kotlin added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). Closes #1756 (companion-vs-instance dispatch) and #1757 (lambda scopes); refs #1746. RFC §6.4 corpus criterion waived (corpus-mode wiring is #927-scope); fixture criterion met. |
|
||||
| 2026-04-23 | 1.7.0 | TypeScript added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). |
|
||||
| 2026-04-20 | 1.6.0 | Added scope-resolution pipeline pointer (RFC #909 Ring 3); Python migrated to registry-primary. |
|
||||
| 2026-04-19 | 1.5.0 | Cross-repo impact (#794): `impact`/`query`/`context` accept `repo: "@<group>"` + `service`. Removed `group_query`/`group_contracts`/`group_status` MCP tools; added `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` resources. |
|
||||
| 2026-04-16 | 1.4.0 | Fixed: web UI description, pre-commit behavior, MCP tools (7->16), added gitnexus-shared, removed stale vite-plugin-wasm gotcha. |
|
||||
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
|
||||
| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication. |
|
||||
| 2026-03-23 | 1.1.0 | Updated agent instructions, references, Cursor layout. |
|
||||
| 2026-03-22 | 1.0.0 | Initial structured header and changelog. |
|
||||
| Date | Version | Change |
|
||||
| ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-10-17 | 1.18.0 | Require the reviewed preview's single-use `plan_token` when applying semantic rename (#2973). |
|
||||
| 2026-09-24 | 1.17.0 | Clones with the same `origin` URL now share a store automatically; `--no-share` records a lasting opt-out (#3352). |
|
||||
| 2026-09-24 | 1.16.0 | Documented the shared worktree index store (`<GITNEXUS_HOME>/stores/`, `analyze --share-with`, `GITNEXUS_SHARED_STORE=off`) in the storage notes (#3352). |
|
||||
| 2026-09-07 | 1.15.0 | Added the Objective-C provider guide as the required reference before changing Objective-C parsing or resolution. |
|
||||
| 2026-07-20 | 1.14.0 | `gitnexus-review` gains a coordinated swarm: six `ci-personas/` lanes the CI review agent dispatches as subagents (via the `Agent` tool), with a bounded critic gate and sidechain-excluded evidence. |
|
||||
| 2026-07-16 | 1.13.0 | `gitnexus-plan` asks plan depth up front (quick/standard/deep) in interactive runs; `gitnexus-lfg` gate slimmed to proceed/stop (Deepen stays as the route-back mechanism). |
|
||||
| 2026-07-16 | 1.12.0 | Renamed `gitnexus-pr-review` to `gitnexus-review`; added PR URL/number, branch/range, and local-change targets plus install migration (setup warns on a legacy `gitnexus-pr-review` dir and leaves it in place; uninstall removes it). |
|
||||
| 2026-07-11 | 1.11.0 | Skill family shipped via npm skills/ + plugin (sync-guarded); added eval/workflow_bench token-savings benchmark. |
|
||||
| 2026-07-11 | 1.10.0 | Added `gitnexus-work` (plan executor) and `gitnexus-lfg` (plan → deepen/work gate → review pipeline) skills; section renamed to Engineering planning & execution. |
|
||||
| 2026-07-11 | 1.9.0 | Added Engineering planning (`/gitnexus-plan`) section; registered the `gitnexus-plan` skill (`.claude/skills/gitnexus-plan/`). |
|
||||
| 2026-05-22 | 1.8.0 | Kotlin added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). Closes #1756 (companion-vs-instance dispatch) and #1757 (lambda scopes); refs #1746. RFC §6.4 corpus criterion waived (corpus-mode wiring is #927-scope); fixture criterion met. |
|
||||
| 2026-04-23 | 1.7.0 | TypeScript added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). |
|
||||
| 2026-04-20 | 1.6.0 | Added scope-resolution pipeline pointer (RFC #909 Ring 3); Python migrated to registry-primary. |
|
||||
| 2026-04-19 | 1.5.0 | Cross-repo impact (#794): `impact`/`query`/`context` accept `repo: "@<group>"` + `service`. Removed `group_query`/`group_contracts`/`group_status` MCP tools; added `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` resources. |
|
||||
| 2026-04-16 | 1.4.0 | Fixed: web UI description, pre-commit behavior, MCP tools (7->16), added gitnexus-shared, removed stale vite-plugin-wasm gotcha. |
|
||||
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
|
||||
| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication. |
|
||||
| 2026-03-23 | 1.1.0 | Updated agent instructions, references, Cursor layout. |
|
||||
| 2026-03-22 | 1.0.0 | Initial structured header and changelog. |
|
||||
|
||||
---
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows).
|
||||
|
|
@ -123,7 +126,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
|
||||
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
|
||||
- **MUST analyze changes before commit.** Run `detect_changes({scope: "all"})` or `node .gitnexus/run.cjs detect-changes --scope all --repo .`. `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review, fetch target branch "main" from its remote; compute its merge base with the reviewed HEAD and pass that SHA to `detect_changes({scope: "compare", base_ref: "<merge-base SHA>"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "<merge-base SHA>" --repo .`.
|
||||
- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
|
||||
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
|
||||
- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.
|
||||
|
|
@ -134,28 +137,28 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
|
||||
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read `UNKNOWN` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` with `dry_run: true` first, review the graph-backed semantic spans and coverage, then apply with `dry_run: false` and that preview's `plan_token`.
|
||||
- NEVER commit before MCP/CLI graph change analysis.
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
| --- | --- |
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
| Resource | Use for |
|
||||
| ----------------------------------------- | ---------------------------------------- |
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
| --- | --- |
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus-cli/SKILL.md` |
|
||||
| Task | Read this skill file |
|
||||
| -------------------------------------------- | -------------------------------------------------- |
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
|
|
@ -163,14 +166,14 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
|
||||
### Packages
|
||||
|
||||
| Package | Path | Purpose |
|
||||
|---------|------|---------|
|
||||
| **CLI/Core** | `gitnexus/` | TypeScript CLI, indexing pipeline, MCP server. Published to npm. |
|
||||
| **Web UI** | `gitnexus-web/` | React/Vite thin client. All queries via `gitnexus serve` HTTP API. |
|
||||
| **Shared** | `gitnexus-shared/` | Shared TypeScript types and constants. |
|
||||
| Claude Plugin | `gitnexus-claude-plugin/` | Static config for Claude marketplace. |
|
||||
| Cursor Integration | `gitnexus-cursor-integration/` | Static config for Cursor editor. |
|
||||
| Eval | `eval/` | Python evaluation harness (Docker + LLM API keys). |
|
||||
| Package | Path | Purpose |
|
||||
| ------------------ | ------------------------------ | ------------------------------------------------------------------ |
|
||||
| **CLI/Core** | `gitnexus/` | TypeScript CLI, indexing pipeline, MCP server. Published to npm. |
|
||||
| **Web UI** | `gitnexus-web/` | React/Vite thin client. All queries via `gitnexus serve` HTTP API. |
|
||||
| **Shared** | `gitnexus-shared/` | Shared TypeScript types and constants. |
|
||||
| Claude Plugin | `gitnexus-claude-plugin/` | Static config for Claude marketplace. |
|
||||
| Cursor Integration | `gitnexus-cursor-integration/` | Static config for Cursor editor. |
|
||||
| Eval | `eval/` | Python evaluation harness (Docker + LLM API keys). |
|
||||
|
||||
### Running services
|
||||
|
||||
|
|
@ -183,12 +186,14 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind
|
|||
### Testing
|
||||
|
||||
**CLI / Core (`gitnexus/`)**
|
||||
|
||||
- `npm test` — full vitest suite (~2000 tests)
|
||||
- `npm run test:unit` — unit tests only
|
||||
- `npm run test:integration` — integration (~1850 tests). LadybugDB file-locking tests may fail in containers (known env issue).
|
||||
- `npx tsc --noEmit` — typecheck
|
||||
|
||||
**Web UI (`gitnexus-web/`)**
|
||||
|
||||
- `npm test` — vitest (~200 tests)
|
||||
- `npm run test:e2e` — Playwright (7 spec files; requires `gitnexus serve` + `npm run dev`)
|
||||
- `npx tsc -b --noEmit` — typecheck
|
||||
|
|
|
|||
311
ARCHITECTURE.md
311
ARCHITECTURE.md
|
|
@ -36,7 +36,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
|||
| `context` | Callers, callees, processes for one symbol |
|
||||
| `impact` | Blast radius (upstream/downstream) with risk summary |
|
||||
| `detect_changes` | Map git diffs to affected symbols and processes |
|
||||
| `rename` | Graph-assisted multi-file rename with `dry_run` preview |
|
||||
| `rename` | Semantic rename: previews by default (`dry_run` omitted/true), applies with `dry_run: false` and the preview's `plan_token` |
|
||||
| `api_impact` | Pre-change impact report for an API route handler |
|
||||
| `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@<group>"`) for cross-repo traces |
|
||||
| `route_map` | API route → handler → consumer mappings |
|
||||
|
|
@ -69,7 +69,7 @@ Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path th
|
|||
| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` |
|
||||
| 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` |
|
||||
| Import resolution | `src/core/ingestion/scope-resolution/pipeline/run.ts` + per-language `ScopeResolver.resolveImportTarget` hooks |
|
||||
| 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/` |
|
||||
|
|
@ -166,7 +166,7 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
|||
|
||||
### Where routes come from
|
||||
|
||||
`route-extractors/` holds four independent ways a route can be discovered, all
|
||||
`route-extractors/` holds five independent ways a route can be discovered, all
|
||||
converging on the routes phase's `(method, url)` registry:
|
||||
|
||||
| Source | Shape | Examples |
|
||||
|
|
@ -175,8 +175,9 @@ converging on the routes phase's `(method, url)` registry:
|
|||
| Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` |
|
||||
| Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` |
|
||||
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS (`@Controller` + `@Get`/`@Post`/…; URLs are controller-relative — `setGlobalPrefix` and URI versioning live in the bootstrap file and are not applied), **JS/TS dispatch guards and static data route tables** |
|
||||
| Cross-file AST route facts | `extractRouteFacts` + `resolveRouteFacts` | Express apps/routers and their mount paths; directly proven Hono registrations |
|
||||
|
||||
The last row is the one whose name undersells it. A route is DECLARED by a
|
||||
The AST-level route hook is the one whose name undersells it. A route is DECLARED by a
|
||||
decorator, but it can also be **inferred** from a raw `node:http` server's own
|
||||
dispatch — `if (req.method === 'GET' && pathname === '/api/x')` is a route with
|
||||
a path, a verb and a handler, and nothing else in the pipeline could see it.
|
||||
|
|
@ -198,6 +199,58 @@ That extractor is deliberately **precision-weighted**: `route_map` presents its
|
|||
output as fact, so a `startsWith` namespace test, a bare `pathname === '/'`
|
||||
without a verb, and any regex it cannot translate exactly are all dropped rather
|
||||
than guessed at. A missing route is a coverage limit; an invented one is a lie.
|
||||
Express `app.get(RegExp, handler)` registrations are likewise omitted: a regex
|
||||
matcher does not have one finite URL to use as a Route node key.
|
||||
Express `app.use(path, middleware)` does not itself create a Route node. When
|
||||
both receivers are proven Express instances, mounting a router joins the literal
|
||||
prefix to its registered paths before handler selection. Nested and repeated
|
||||
mounts, literal path arrays, and middleware arrays share this composition.
|
||||
|
||||
Express receiver facts reuse the existing AST and `ParsedFile` lexical identities.
|
||||
They follow explicit ESM/CommonJS imports and exports through the configured
|
||||
`ScopeResolver`, including stable aliases and barrels. An object named `app` or
|
||||
`router` is not evidence; type-only imports, shadowed bindings, conflicting writes,
|
||||
and unproven factories do not establish a receiver. Directly proven Hono verb
|
||||
registrations remain supported, without applying Express mounting semantics.
|
||||
A registration whose receiver, literal path or mounted router is a parameter of a
|
||||
named function (`mountSSEProgress(app, '/p', jobs)` → `app.get(routePath, …)`)
|
||||
resolves through each call site of that function, once per site and in the
|
||||
helper's file. This is one hop only: a site argument that is itself a parameter,
|
||||
an unproven argument, or a reassigned parameter establishes nothing, and a helper
|
||||
with no recorded call site is read as written, so an unknown mount path still
|
||||
suppresses its router's standalone paths.
|
||||
|
||||
Fetch-wrapper facts use the same lexical identities. The shared resolver in
|
||||
`route-extractors/fetch-wrapper-index.ts` names no language, but it applies
|
||||
ECMAScript `ResolveExport` rules to the re-export facts it receives: only
|
||||
providers whose modules link that way implement the re-export hook. A file that
|
||||
passes a route's URL to a callee not proven to be a wrapper is counted on the
|
||||
Route as `unverifiedConsumers` (proven consumers and the route's own file
|
||||
excluded), and `api_impact` then reports its consumer count as a lower bound.
|
||||
|
||||
A `FETCHES` edge is file-level. Its reason also names the callables whose spans
|
||||
contain the request sites (`|via:<id>`, under the same containment rule as the
|
||||
R3-6 sink set). `api_impact` follows their `CALLS` edges one level up and lists
|
||||
those callers as `indirectConsumers`, apart from the direct count and outside
|
||||
the risk level: they never mention the URL, so no edge of their own could reach
|
||||
the route. An index built before this attribution carries no such list.
|
||||
|
||||
An inline Express handler resolves to the anonymous Function node the capture
|
||||
layer names by position, so its Route carries a `handlerSymbolId`. In a file
|
||||
with several routes, response keys come from that handler's lines rather than
|
||||
the whole file (middleware stays file-level), and flows link only where they
|
||||
start at that handler.
|
||||
|
||||
A standalone router retains its local paths. A known dynamic mount suppresses
|
||||
that fallback, while independently proven static mounts remain available. Cycles
|
||||
terminate, and exceeding the expansion budget omits the affected mount component
|
||||
rather than publishing a partial set of paths. Per-file facts are cached, but
|
||||
cross-file paths are recomputed each run. Group HTTP contracts use the same
|
||||
resolver and composition, including in source-only mode.
|
||||
|
||||
Dispatch-guard inference also ignores stored `route.routePath` metadata:
|
||||
comparing a recorded route name with a literal does not prove that the request
|
||||
path is being dispatched.
|
||||
|
||||
Two rules there need more than one comparison to decide, and are worth knowing
|
||||
about before changing either:
|
||||
|
|
@ -219,36 +272,105 @@ about before changing either:
|
|||
|
||||
## Semantic model
|
||||
|
||||
`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`.
|
||||
`SemanticModel` is the active declaration store used by the ingestion pipeline.
|
||||
Its `symbols`, `types`, `methods` and `fields` registries supply symbol and
|
||||
owner-oriented lookups. Scope resolution consumes this model; it does not replace
|
||||
it with a second symbol store.
|
||||
|
||||
`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.
|
||||
### Type facts and resolution responsibilities
|
||||
|
||||
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`.
|
||||
| Layer | Responsibility |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------ |
|
||||
| `LanguageProvider` and parse workers | Extract declarations, type annotations, imports and reference facts using language-owned rules. |
|
||||
| `TypeEnvironment` (`type-env.ts`) | Collect temporary per-file hints used by extraction and exported-type metadata. |
|
||||
| `ParsedFile` | Carry declarations, scopes, imports, type bindings and reference sites into workspace resolution. |
|
||||
| `SemanticModel` | Keep authoritative declaration registries and member ownership. |
|
||||
| `ScopeResolver` and shared passes | Apply language rules, visibility, type propagation, receiver folding and dispatch to emit graph edges. |
|
||||
|
||||
**Write / read phase contract.** The model is mutable during three ordered phases and read-only afterward:
|
||||
```text
|
||||
Language providers + parse workers
|
||||
├── symbol registration ───────► SemanticModel
|
||||
├── scope/type/reference facts ► ParsedFile
|
||||
└── TypeEnv hints ─────────────► extraction / exported-type metadata
|
||||
|
||||
```
|
||||
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 ───────────────────────────
|
||||
Read phase: all resolution passes + MCP + HTTP + embeddings see
|
||||
SemanticModel (read-only handle); writes are type-errors.
|
||||
SemanticModel + ParsedFile + ScopeResolver hooks
|
||||
└──► finalize scopes/imports → propagate types → resolve → emit graph edges
|
||||
```
|
||||
|
||||
`runScopeResolution` narrows `MutableSemanticModel` → `SemanticModel` at the phase boundary so downstream passes physically cannot mutate the model even accidentally.
|
||||
Type handling occurs during both extraction and resolution. The worker builds
|
||||
TypeEnv without a global model or imported-binding inputs. Only file-scope
|
||||
TypeEnv bindings are serialized into `BindingAccumulator` for exported-type
|
||||
metadata enrichment; function-local maps are not a second workspace resolver.
|
||||
The current `crossFile` phase only disposes that accumulator. The legacy
|
||||
cross-file call re-resolution and call-resolution DAG were removed in #942.
|
||||
|
||||
**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#).
|
||||
`ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the per-file
|
||||
artifact the scope-resolution pipeline consumes. Passes must reuse it rather
|
||||
than build parallel parse representations. If a provider needs additional AST
|
||||
facts, it can reuse the orchestrator's `treeCache`; ordinary worker-pool runs
|
||||
consume serialized facts because native Trees cannot cross MessageChannels.
|
||||
|
||||
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'`.
|
||||
`WorkspaceResolutionIndex` supplies derived scope and return-fact lookups, such as
|
||||
`classScopeByDefId` and `moduleScopeByFile`. These caches complement the model's
|
||||
authoritative declaration registries.
|
||||
|
||||
References: `semantic-model.ts` file-head (full write/read contract); `contract/scope-resolver.ts` Contract Invariant I9 (scope-resolution-side rule).
|
||||
### Write and read lifecycle
|
||||
|
||||
1. Parsing calls `model.symbols.add`, which registers declarations with the
|
||||
appropriate type, method or field registry.
|
||||
2. The per-language scope pipeline populates ownership and calls
|
||||
`reconcileOwnership` to register corrections before reference resolution.
|
||||
3. The orchestrator narrows the model to the read-only `SemanticModel` interface
|
||||
for downstream resolution passes. `finalizeScopeModel` returns the scope/index
|
||||
bundle that the orchestrator consumes directly.
|
||||
|
||||
This read-only model handle restricts registry writes. It does not freeze every
|
||||
scope map: imported-return propagation and precise call-result binding deliberately
|
||||
refine `Scope.typeBindings` in ordered passes. Graph-query MCP and HTTP endpoints read the
|
||||
persisted graph/index rather than a live parse-time model handle.
|
||||
|
||||
The model also exports optional `scopes` / `attachScopeIndexes` APIs. No production
|
||||
attachment call is present in the reviewed source, so callers must not assume
|
||||
`model.scopes` is populated. Similarly, the shared `resolveTypeRef` helper is
|
||||
exported and unit-tested, while current registry lookup resolves receiver owners
|
||||
directly. These are API-audit candidates, not reasons to label the whole model or
|
||||
typing layer obsolete.
|
||||
|
||||
`reconcileOwnership` is an active compatibility pass for providers whose parse-time
|
||||
owner differs from their scope-resolution owner. The dev validator
|
||||
`validateOwnershipParity` reports drift under
|
||||
`NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`. Retiring reconciliation
|
||||
requires provider ownership parity first.
|
||||
|
||||
See [Type Resolution System](type-resolution-system.md) for execution details and
|
||||
[Type Resolution Roadmap](type-resolution-roadmap.md) for planned work and
|
||||
acceptance criteria.
|
||||
|
||||
---
|
||||
|
||||
## Scope-Resolution Pipeline (RFC #909 Ring 3)
|
||||
|
||||
Language-agnostic scope-resolution resolver. This is the resolution path for every language — it owns CALLS/ACCESSES/USES emission and inheritance edges. Adding a language is one interface implementation (`ScopeResolver`) plus one registration in the `SCOPE_RESOLVERS` map — no changes to shared code, no new pipeline phase. (RING4-1 #942 removed the legacy call-resolution DAG and the per-language `MIGRATED_LANGUAGES` flag, so `SCOPE_RESOLVERS` registration is all that's needed.)
|
||||
The shared scope-resolution pipeline handles ordinary source-language references
|
||||
using `ParsedFile`, `SemanticModel` and provider hooks. It emits resolved calls,
|
||||
accesses, value uses and inheritance edges. Register a `ScopeResolver` in
|
||||
`SCOPE_RESOLVERS` to participate; the old per-language migration flag and parallel
|
||||
call-resolution DAG were removed in RING4-1 #942.
|
||||
|
||||
Method extraction still supplies graph properties and overload identity; scope
|
||||
extraction builds the declaration metadata used by resolution. Their repeated
|
||||
node-only signature callbacks share facts through `memoizeDeclarationFact`
|
||||
(`ingestion/method-extractors/declaration-facts.ts`). Each callback owns a WeakMap
|
||||
from the immutable parse tree to native node IDs, so separate traversal wrappers
|
||||
reuse one result without retaining old trees. Parameter facts are read-only;
|
||||
providers can map syntax wrappers to the same declaration identity. Each consumer
|
||||
keeps its own arity/type normalization. Owner-dependent flags and file-dependent
|
||||
receiver rules remain separate. The cache stays inside parser isolates; serialized
|
||||
graph/ParsedFile payloads and the persistent parse-cache format remain unchanged.
|
||||
|
||||
A provider can explicitly restrict its edge mode. COBOL currently uses
|
||||
`callable-flow-only`: its established processor retains structural CALLS/IMPORTS,
|
||||
and scope resolution contributes procedure-pointer calls. Registration alone is
|
||||
not a claim that every language feature uses identical resolution rules.
|
||||
|
||||
### Pipeline stages
|
||||
|
||||
|
|
@ -276,9 +398,17 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
|
|||
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` over the worker-serialized `ParsedFile`s. (Per-language `emitScopeCaptures` hooks may reuse a cached Tree via the orchestrator's `treeCache`, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted `ParsedFile` instead; § Performance notes.)
|
||||
|
||||
### Semantic rename
|
||||
|
||||
`rename` plans through the graph-backed planner in `mcp/local/rename/graph-rename.ts`; `dry_run` (omitted or `true`) stops after planning and returns a single-use `plan_token`. Public apply requires that token with `dry_run: false`, validates the captured repository, declaration inventory, stable symbol identity, source-file set, source hashes and approved physical paths, and executes exactly the captured edits without replanning. The process-local cache retains edited-file snapshots and targets, hash/target evidence for unedited semantic sources, and an immutable provider-resolution configuration value, with a 15-minute lifetime and bounded plan-count and memory budgets. Provider-owned fresh configuration hooks bypass workspace memoization; configuration semantics are checked at capability validation and once after staging, before the first installation, independently of edited-file count. Source bytes and approved targets are checked before each replacement. These cooperative-workspace checks are not an atomic filesystem snapshot. An unedited source aliasing an edited target blocks preview. In MCP read-only mode the same tool is listed preview-only and any other `dry_run` is rejected before backend dispatch (`mcp/read-only-policy.ts`). The selected graph declaration must match the current source. Each language's `LanguageProvider.rename` supplies original-source name spans, spelling rules, declaration families, and syntax-specific rewrites. Embedded and standalone providers retain their own parsing strategy; shared planning does not classify language syntax or invoke compiler/language-service APIs.
|
||||
|
||||
The planner runs the existing scope-resolution pipeline before and after the proposed edits. An opt-in source-binding observer records the declaration selected for a written name, separately from runtime dispatch expansion. Provider capture metadata travels with each parsed file, so both passes retain ordinary import and ownership evidence. Provider-proven declaration families use the existing inheritance, implementation, and declaration relationships. Re-resolution checks edited references, untouched bindings, collisions, and changes in those families.
|
||||
|
||||
Application validates all captured source snapshots before each per-file replacement, then rechecks that destination immediately before installing it. Validation and replacement are separate filesystem operations; the operation is neither an atomic compare-and-swap nor a multi-file transaction. Unsupported or ambiguous source constructs produce an explicit blocked result. Coverage is limited to the indexed workspace and the provider's available semantic evidence; dynamic names and external consumers are not silently treated as resolved.
|
||||
|
||||
### Callable-value flow
|
||||
|
||||
First-class callable values use a language-neutral inclusion analysis in `passes/callable-value-flow.ts`. Providers recognize their own syntax and emit JSON-safe `CallableFlowSite` facts (`seed`, `copy`, `alias`, `address`, `load`, `store`, `formal`, `argument`, and `invoke`) into `ParsedFile`; shared ingestion never branches on a language name. These always-on facts cross workers and the durable parse store, whose schema is bumped whenever their semantic shape changes.
|
||||
First-class callable values use a language-neutral inclusion analysis in `passes/callable-value-flow.ts`. Providers recognize their own syntax and emit JSON-safe `CallableFlowSite` facts (`seed`, `copy`, `alias`, `address`, `load`, `store`, `formal`, `argument`, and `invoke`) into `ParsedFile`; shared ingestion never branches on a language name. These always-on facts cross workers and the durable parse store. Changes to their producers or shared contracts automatically change the base parser identity used by both caches.
|
||||
|
||||
The emit stage defers only invocation sites proven to reference a flow cell. Ordinary receiver/free/reference passes still resolve direct callees first and record exact callee IDs by file/line/column. Property dispatch then runs before callable flow because a property-dispatched wrapper call can seed actual-to-formal propagation. The callable solver consumes those direct targets, propagates callable sets through lexical cells and formals, and emits `CALLS` at the real indirect invocation site with reason `callable-value-flow` (confidence 0.8 for a singleton, 0.7 for a bounded multi-target set).
|
||||
|
||||
|
|
@ -298,13 +428,69 @@ Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.
|
|||
|
||||
A compound receiver (`svc.getUser().address.save()`) is captured as a compact string on `ReferenceSite.receiverChain`. `utils/receiver-chain-codec.ts` is the ONE encoder/decoder — capture emitters, the scope-resolution fold, and the durable ParsedFile store all import it rather than hand-rolling the format.
|
||||
|
||||
Wire format is **v2**: `2|<base>|<step>|<step>…`, one-character version prefix, then base-first steps, each a one-character kind sigil plus the member name (`c` = call, `f` = field). `a` (await) and `i` (index) are **name-free** and encode as a bare sigil — an awaited call's name already lives on its `c` step, and a subscript key is a value, not a lookup-able identifier. The version went 1 → 2 when those two kinds were added, and a decoder REFUSES a foreign version rather than decoding the prefix it understands: a chain missing its await/index hop decodes cleanly as a different, shorter chain and would type the receiver against the wrong member. The format is unescaped (`|` and `~` cannot occur in an identifier), so an unencodable name is refused rather than escaped, and the payload is capped at `MAX_RECEIVER_CHAIN_BYTES` / `MAX_CHAIN_DEPTH` steps. Because these strings live in the incremental parse cache and the durable ParsedFile store, a format change requires a `PARSE_CACHE_VERSION` schema bump — a stale cache would otherwise replay v1 chains this build discards.
|
||||
Wire format is **v2**: `2|<base>|<step>|<step>…`, one-character version prefix, then base-first steps, each a one-character kind sigil plus the member name (`c` = call, `f` = field). `a` (await) and `i` (index) are **name-free** and encode as a bare sigil — an awaited call's name already lives on its `c` step, and a subscript key is a value, not a lookup-able identifier. The version went 1 → 2 when those two kinds were added, and a decoder REFUSES a foreign version rather than decoding the prefix it understands: a chain missing its await/index hop decodes cleanly as a different, shorter chain and would type the receiver against the wrong member. The format is unescaped (`|` and `~` cannot occur in an identifier), so an unencodable name is refused rather than escaped, and the payload is capped at `MAX_RECEIVER_CHAIN_BYTES` / `MAX_CHAIN_DEPTH` steps. These strings live in both parse stores. Codec and capture changes automatically invalidate their caches through the base parser identity; the explicit receiver-chain wire version and foreign-version rejection still govern decoding.
|
||||
|
||||
Receivers the resolver could not type are not silently dropped. Each records a `ResolutionOutcome` (`scope-resolution/resolution-outcome.ts`) carrying the receiver's *shape* (`classifyReceiverShape`: `chain-call` / `chain-field` / `chain-mixed` / `chain-unwrap` / `no-chain` — the bench censuses these) and its *origin* (`in-program` / `external` / `unknown`). `scope-resolution/unresolved-receivers.ts` aggregates them per member name into the index-persisted `unresolvedReceiverMembers` summary, keeping in-program and external counts under separate keys. Only in-program drops make a count short: an external-rooted call (`System.out.println`, `fetch(...)`) has no in-graph node an edge could have reached, so it is reported but does not hedge. `impact` / `context` read that summary and publish `epistemic: 'exact' | 'lower-bound'`, prose `boundaries`, and the machine-readable `causes` split (`EpistemicCauses` in `mcp/local/local-backend.ts`).
|
||||
|
||||
Python's provider classifies attribute reads and assignment targets separately; augmented assignments produce both read and write references. Constructor assignments on a proven instance receiver also create `Property` nodes without requiring a value type annotation. Annotation-only targets do not assign a value. These rules follow the [Python attribute-reference](https://docs.python.org/3/reference/expressions.html#attribute-references) and [assignment](https://docs.python.org/3/reference/simple_stmts.html#assignment-statements) semantics.
|
||||
|
||||
Property references declined across language boundaries are recorded in `crossLanguageProperties`. Both `context` and upstream `impact` on an affected property report `epistemic: 'lower-bound'`, with the explanation in `boundaries` and `unresolved`, and the definition languages in `anchorLanguages`. This evidence does not fabricate cross-language edges and does not qualify a downstream walk.
|
||||
|
||||
### Java overload selection and missing call edges (#3789)
|
||||
|
||||
Java resolves an overload family through provider hooks before the shared passes
|
||||
prune candidates by owner or conversion rank. The provider gathers eligible
|
||||
members across the receiver's compile-time hierarchy, removes overridden
|
||||
signatures, and applies the JLS 15.12.2 phases in order: strict invocation, loose
|
||||
invocation, then variable arity. Within the first applicable phase it compares
|
||||
parameter types pairwise for specificity. Boxing, primitive widening, arrays,
|
||||
null, and concrete invariant generic arguments retain their distinct meanings.
|
||||
Lexically scoped argument facts travel in Java's existing `captureSideChannel`,
|
||||
so parse workers and durable cache replay make the same decisions.
|
||||
Static imports use accessible overload families; an explicit import shadows only
|
||||
the same signature from an on-demand import. Enum constant bodies and implicit
|
||||
record accessors reuse the provider's existing synthetic identities. Field,
|
||||
hierarchy, and subtype walks memoize shared ancestors while preserving cycle and
|
||||
depth bounds.
|
||||
|
||||
The provider models a bounded set of qualified JDK type relationships and
|
||||
`Arrays.asList` / `Collections.singletonList` calls. These factories are poly
|
||||
expressions: their type arguments depend on the candidate's parameter type.
|
||||
Source declarations and lexical shadowing take precedence over these summaries.
|
||||
Unsupported generic inference, unavailable argument types, and incomplete
|
||||
inherited inventories leave an overload unresolved. A complete original family
|
||||
containing only one declaration may retain the existing unique-member inference
|
||||
for opaque arguments; this exception does not apply to a family narrowed to one
|
||||
candidate. Runtime implementation dispatch follows the selected signature.
|
||||
It preserves the original receiver's subtype bound and excludes static and private
|
||||
methods from virtual dispatch.
|
||||
|
||||
Captures also retain callable-owned return annotations and structural identities
|
||||
for `var` initializers. The shared passes keep the selected compile-time method
|
||||
separate from runtime dispatch targets, then propagate owned return types to
|
||||
untyped assignments and revisit waiting receiver calls through a dependency
|
||||
queue. Sequential assignments have no expression-depth cutoff. Unresolved producers
|
||||
cannot restore an earlier name-based return guess. Generic implementation
|
||||
signatures substitute a whole formal type parameter only when concrete source
|
||||
heritage proves its binding; unsupported substitutions remain unknown.
|
||||
|
||||
Suppressed overload calls record graph identities for their candidate methods,
|
||||
declaring owners, and caller. `unresolved-overloads.ts` reconciles decisions per
|
||||
call site and resolution phase and persists a bounded `unresolvedOverloads`
|
||||
receipt, including an explicit complete-empty receipt. `impact` and `context`
|
||||
report matching omitted sites through `causes.overloadResolution` and
|
||||
`epistemic: 'lower-bound'`; possible callers never become ordinary `CALLS`
|
||||
edges or inflate known caller counts. Impact checks the nodes whose neighbors
|
||||
were actually expanded before pagination, using candidate identities upstream
|
||||
and caller identities downstream. Missing, malformed, or capped receipts retain
|
||||
an uncertainty boundary. Re-analyze with the current build (`--force` when
|
||||
repairing metadata) to replace a legacy receipt.
|
||||
|
||||
### Optional CFG/PDG emission (`--pdg`, #2081–#2086)
|
||||
|
||||
On a `--pdg` run the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript today) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits the program-dependence layers from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and each layer is bounded by a per-function edge cap that logs any dropped edges. All layers are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table, keyed by `type`; there is **no** `Function → BasicBlock` edge — the symbol↔block join is reconstructed from the BasicBlock id prefix + line span. The layers build on each other:
|
||||
On a `--pdg` run providers build control-flow graphs through `LanguageProvider.cfgVisitor` (Tree-sitter ASTs) or `collectSourceCfgs` (standalone source processing). Every supported language supplies one of these hooks; Vue covers executable scripts. AST visitors run in the parse worker, while source-backed providers run during fresh scope extraction. Both produce plain `ParsedFile.cfgSideChannel` data. Scope-resolution emits the program-dependence layers **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live**. The store is cleared after that phase, so a later standalone phase cannot consume it.
|
||||
|
||||
The opt-in is off by default, folded into the parse-cache key, and bounded by executable-region and edge caps. Providers report unsupported regions and cap drops instead of fabricating complete flow. All dependence edges remain `BasicBlock → BasicBlock` in the existing `CodeRelation` table. Ordinary function ownership uses the BasicBlock id prefix and source span. A provider may supply an exact graph `ownerId` for a larger executable region; COBOL uses its existing program Module identity so paragraph fallthrough and PERFORM return continuations remain inside one region. This identity is persisted on BasicBlocks and used by PDG symbol projection. No new function-to-block relation is introduced. The layers build on each other:
|
||||
|
||||
- **M1 — CFG** (#2081): `BasicBlock` nodes + `CFG` edges. Edge _kind_ (`seq`/`cond-true`/`loop-back`/…) rides the `reason` column (CFG is one `CodeRelation` type, not one per kind).
|
||||
- **M2 — REACHING_DEF** (#2082): GEN/KILL def→use data dependence from a pure fixpoint solver; the variable name rides `reason`.
|
||||
|
|
@ -333,12 +519,17 @@ Single interface a language implements to plug into the pipeline. Contract fully
|
|||
| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) |
|
||||
| `elementTypeOf?` | `(containerType, via: {kind:'index'} \| {kind:'accessor',name}) → elementType \| undefined` — element type of a container, reached by subscript (`repos[0]`) or by a property-style collection view (`data.Values`). ONE hook for both routes (it replaced the split `unwrapCollectionAccessor` / `unwrapCollectionElement`, where implementing one silently answered nothing for the other). Consulted only where the source actually performed the access — never as a general type-name normalizer |
|
||||
| `stripTypePreservingDecoration?` | `(typeName) → strippedName \| undefined` — strip ONE layer of TYPE-PRESERVING decoration (pointer, reference, `const`, nullable, borrow, sigil) so a receiver declared `*Host` still finds the `Host` binding (#2766). Never a container: unwrapping `Repo[]` here would fold `repos.find(x)` to `Repo.find` — that is `elementTypeOf`'s job, and only after a real subscript. Consulted only after every undecorated lookup fails, and only by receiver-chain base/step resolution — default off |
|
||||
| `qualifiedTypeLookupSpelling?` | `(typeRef, scopes?) → 'pkg.Type' \| null \| undefined` — the namespace-qualified spelling of a declared type whose qualifier capture cut from `rawName` (`*models.User` stored as `User`), recovered from `declaredSpelling`. Receiver typing tries it first, grounded on the qualifier's import (no unique-name guess). A failed written-qualified lookup stops instead of falling back to a bare type name. The optional scope indexes also let a provider expand a visible namespace alias; `null` refuses the written lookup and its fallback. The binding is not rewritten — default off |
|
||||
| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off |
|
||||
| `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` |
|
||||
| `resolveImplicitInheritanceBase?` | Fallback for a bare inheritance base the shared chain left unbound, bound by the implicit namespace rule (siblings exist only after the pre-pass); Java, Kotlin, C# opt in |
|
||||
| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level |
|
||||
| `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions |
|
||||
| `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in |
|
||||
| `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) |
|
||||
| `resolveQualifiedCallee?` | `(calleeText, inScope, …) → callable \| undefined` — resolve a qualified callee only the language can read (Rust `rp::make` in `x = rp::make()` / `rp::make().m()`, Kotlin package path `com.acme.mod.make`) so chain-follow types the call from its return annotation; default off |
|
||||
| `resolveQualifiedTypeSpellings?` | A receiver's declared or return type written QUALIFIED (`rp::C x`, `f(a::Config c)`) that binds nothing through the scope chain resolves through `resolveQualifiedInheritanceBase` — qualifier matched against the candidate's namespace, refuse on a tie — instead of by its tail, which binds the nearer same-named class. Runs after every existing lookup and after the template-argument match; default off, C++ opts in |
|
||||
| `splitQualifiedCallee?` | `(callee, callerScope?, scopes?, position?) → { receiver, member } \| undefined` — map a qualified callee spelling (`rp::make`) onto a receiver/member pair that retains the full written namespace qualifier, so a factory result (`auto x = rp::make(); x.m()`, `rp::make().m()`) is typed from the definition `resolveQualifiedReceiverMember` returns and that definition's own declared return type. Caller scope, indexes and source position let a provider reject shadowed or aliased namespace qualifiers only when visible at the factory expression. Shared code never splits on a separator; default off, C++ implements it |
|
||||
|
||||
### Per-language registration
|
||||
|
||||
|
|
@ -398,7 +589,6 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
|||
| `id`, `extensions` | Language identity and file matching |
|
||||
| `treeSitterQueries` | S-expression queries for AST extraction |
|
||||
| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` |
|
||||
| `importResolver` | Language-specific path → file resolution |
|
||||
| `exportChecker` | Public/exported symbol detection |
|
||||
| `typeConfig` | Type annotation extraction rules |
|
||||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
|
|
@ -413,22 +603,9 @@ Per-language tree-sitter queries use different AST node names but produce the **
|
|||
|
||||
### Import resolution
|
||||
|
||||
Per-language import resolution uses the **configs + factory** pattern (like call/method/class extractors). Each language declares an `ImportResolutionConfig` in `import-resolvers/configs/`, listing an ordered chain of `ImportResolverStrategy` functions. `createImportResolver()` (in `resolver-factory.ts`) composes them: first non-null result wins. Low-level helpers shared across strategies live alongside the configs in `import-resolvers/` (e.g. `go.ts`, `rust.ts`, `python.ts`).
|
||||
Per-language import resolution runs through `ScopeResolver.resolveImportTarget`, registered in `SCOPE_RESOLVERS`. The shared orchestrator (`scope-resolution/pipeline/run.ts`) passes each language's workspace configuration to the resolver hook. The hook maps a parsed import to its target file or files; finalization links imports and materializes bindings for scope lookup.
|
||||
|
||||
Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates:
|
||||
|
||||
| Tier | Confidence | Mechanism |
|
||||
| ----------------- | ---------- | ---------------------------------------------------------------------- |
|
||||
| 1 — same-file | 0.95 | Symbol table for caller's file |
|
||||
| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) |
|
||||
| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only |
|
||||
|
||||
| Import strategy | Languages | Behavior |
|
||||
| --------------------- | ----------------------------------- | ---------------------------------------------- |
|
||||
| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible |
|
||||
| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports |
|
||||
| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports |
|
||||
| `namespace` | Python | Module aliases resolved at call site |
|
||||
Language-specific import helpers and namespace rules live beside each resolver in `languages/<language>/`. Language-neutral package-directory and suffix indexes, workspace indexes, and per-file-set caches live in `ingestion/utils/`; suffix lookups receive their ordered extension candidates from callers. The dormant `LanguageProvider.importResolver` field, adapter, and strategy factory have been removed; new languages implement the `ScopeResolver` hook.
|
||||
|
||||
### Chunked parse-and-resolve
|
||||
|
||||
|
|
@ -448,7 +625,7 @@ Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
|
|||
|
||||
### 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:
|
||||
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. A bare base the shared chain cannot bind falls back to `ScopeResolver.resolveImplicitInheritanceBase`, because same-namespace sibling bindings (`populateNamespaceSiblings`) are populated only after this pre-pass; without it a Java `class C extends B` with no import loses its edge whenever another package also declares a `B`. 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)
|
||||
|
|
@ -515,6 +692,60 @@ the read-only open is retried — see `sidecar-recovery.ts`
|
|||
|
||||
Managed by `repo-manager.ts`.
|
||||
|
||||
### Automatic parse-cache identity
|
||||
|
||||
`core/parse-cache-identity.ts` derives full SHA-256 base and PDG identities
|
||||
from sorted, framed logical paths and raw artifact bytes. It needs no Git
|
||||
history, branch name, timestamp, network access, or contributor-assigned
|
||||
semantic version. The boundary is intentionally conservative:
|
||||
|
||||
| Component | Owned inputs |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Base | `core/ingestion/**` except the PDG-only paths below, `core/tree-sitter/**`, `lib/**`, `core/graph/**`, and the audited support/storage files listed in `PARSE_CACHE_BASE_FILES` |
|
||||
| PDG | `core/ingestion/cfg/**` and the classified COBOL extraction helpers `languages/cobol/flow.ts` and `flow-harvest.ts`; the shared COBOL tokenizer remains in base |
|
||||
| Runtime and dependencies | The actual selected worker, shared source/compiled runtime or packaged `dist/_shared`, grammar/parser/native/loader dependency closure, and effective runtime/loader options |
|
||||
|
||||
Source execution covers both source inputs and the compiled worker when that
|
||||
is the selected entry. Editing source invalidates identity immediately; it
|
||||
does not compile that worker. Run `npm run build` before expecting changed
|
||||
worker facts. Installed packages derive identity from their shipped runtime
|
||||
without requiring source, Git metadata, or a development compiler. Native
|
||||
binary bytes and loader fallback candidates are covered even when package
|
||||
versions stay the same. Unsupported resolution modes such as
|
||||
`--preserve-symlinks` cannot be verified and disable persistent cache reuse.
|
||||
|
||||
Raw bytes mean comments and formatting inside owned roots can invalidate
|
||||
reuse. Repository documentation, UI, unrelated CLI files, and unrelated
|
||||
dependencies do not change this parser identity; broader analyzer freshness
|
||||
rules still apply. New files inside an owned root are included automatically.
|
||||
Inputs outside that boundary require an ownership decision and an update to
|
||||
`test/unit/parse-cache-input-coverage.test.ts`, which audits imports, dynamic
|
||||
loaders, runtime assets, and shared dependencies.
|
||||
|
||||
Both the worker-result index and durable `ParsedFile` index validate the base
|
||||
identity before workers can be skipped. Physical chunk keys bind that identity
|
||||
and sorted source path/content hashes; PDG-enabled chunks additionally bind
|
||||
the PDG identity and resolved function-line cap. A CFG-only change therefore
|
||||
does not invalidate ordinary chunks. Legacy numeric, missing, malformed, or
|
||||
foreign identities miss; a concurrently pruned shard is also a miss.
|
||||
|
||||
`RepoMeta.parseCacheIdentity` records the effective parser identity for
|
||||
unchanged-analysis and shared-current adoption checks. Analysis resolves it
|
||||
once, stages fresh cache writes, and verifies artifacts again before
|
||||
publication. A changed build rejects publication; failed staging or build
|
||||
verification keeps the published cache pair. Cache-write failures after graph
|
||||
publication warn without rolling back the successful graph. If identity is
|
||||
unverifiable, the run uses bounded transient `ParsedFile` storage without
|
||||
persistent cache reads or writes.
|
||||
`--no-parse-cache` instead requests a cold parse and publishes its fresh cache
|
||||
generation on success. FTS-only maintenance retains the old parser receipt.
|
||||
|
||||
These checks complement graph `SCHEMA_FINGERPRINT`, the analyzer runner
|
||||
receipt, embedding preservation, and explicit wire-format checks such as the
|
||||
V8 envelope and receiver-chain version. Benchmark semantic goldens still
|
||||
require review of changed facts and pack membership; automatic compatibility
|
||||
does not approve a new baseline.
|
||||
|
||||
## LadybugDB schema
|
||||
|
||||
Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation` table.
|
||||
|
|
|
|||
64
CLAUDE.md
64
CLAUDE.md
|
|
@ -1,10 +1,10 @@
|
|||
<!-- version: 1.8.0 -->
|
||||
<!-- version: 1.9.0 -->
|
||||
<!--
|
||||
Metadata: version, last reviewed, scope, model policy, reference docs, changelog.
|
||||
Last updated: 2026-07-16
|
||||
Last updated: 2026-10-17
|
||||
-->
|
||||
|
||||
Last reviewed: 2026-07-16
|
||||
Last reviewed: 2026-10-17
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ Prefer **PreToolUse** hooks for hard gates (e.g. tests before `git_commit`). Ada
|
|||
|
||||
## Context budget
|
||||
|
||||
If always-on instructions grow, load deep conventions via conditional reads (e.g. *“When writing new code, read STANDARDS.md”*) instead of pasting long blocks here. In Cursor, prefer `.cursor/index.mdc` plus optional `.cursor/rules/*.mdc` globs (see [AGENTS.md](AGENTS.md) § Context budget).
|
||||
If always-on instructions grow, load deep conventions via conditional reads (e.g. _“When writing new code, read STANDARDS.md”_) instead of pasting long blocks here. In Cursor, prefer `.cursor/index.mdc` plus optional `.cursor/rules/*.mdc` globs (see [AGENTS.md](AGENTS.md) § Context budget).
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
|
|
@ -41,17 +41,18 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
|||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-07-20 | 1.8.0 | The CI review agent runs `gitnexus-review` as a coordinated swarm — six `ci-personas/` lanes dispatched via the `Agent` tool with a bounded critic gate. |
|
||||
| 2026-07-16 | 1.7.0 | `/gitnexus-plan` asks depth up front in interactive runs; `/gitnexus-lfg` gate slimmed to proceed/stop. |
|
||||
| 2026-07-16 | 1.6.0 | Renamed `/gitnexus-pr-review` to `/gitnexus-review` and added PR, branch/range, and local-change targets. |
|
||||
| 2026-07-11 | 1.5.0 | Added `/gitnexus-work` and `/gitnexus-lfg` to the engineering plans & execution pointer. |
|
||||
| 2026-07-11 | 1.4.0 | Added `/gitnexus-plan` pointer to Reference Documentation. |
|
||||
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
|
||||
| 2026-03-24 | 1.2.0 | Removed duplicated gitnexus:start block and scope table; replaced with pointers to AGENTS.md. |
|
||||
| 2026-03-23 | 1.1.0 | Updated agent instructions to match AGENTS.md. |
|
||||
| 2026-03-22 | 1.0.0 | Added structured header and changelog. |
|
||||
| Date | Version | Change |
|
||||
| ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-10-17 | 1.9.0 | Require the reviewed preview's `plan_token` when applying semantic rename (#2973). |
|
||||
| 2026-07-20 | 1.8.0 | The CI review agent runs `gitnexus-review` as a coordinated swarm — six `ci-personas/` lanes dispatched via the `Agent` tool with a bounded critic gate. |
|
||||
| 2026-07-16 | 1.7.0 | `/gitnexus-plan` asks depth up front in interactive runs; `/gitnexus-lfg` gate slimmed to proceed/stop. |
|
||||
| 2026-07-16 | 1.6.0 | Renamed `/gitnexus-pr-review` to `/gitnexus-review` and added PR, branch/range, and local-change targets. |
|
||||
| 2026-07-11 | 1.5.0 | Added `/gitnexus-work` and `/gitnexus-lfg` to the engineering plans & execution pointer. |
|
||||
| 2026-07-11 | 1.4.0 | Added `/gitnexus-plan` pointer to Reference Documentation. |
|
||||
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
|
||||
| 2026-03-24 | 1.2.0 | Removed duplicated gitnexus:start block and scope table; replaced with pointers to AGENTS.md. |
|
||||
| 2026-03-23 | 1.1.0 | Updated agent instructions to match AGENTS.md. |
|
||||
| 2026-03-22 | 1.0.0 | Added structured header and changelog. |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -60,6 +61,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
|||
See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions.
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows).
|
||||
|
|
@ -70,7 +72,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
|
||||
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
|
||||
- **MUST analyze changes before commit.** Run `detect_changes({scope: "all"})` or `node .gitnexus/run.cjs detect-changes --scope all --repo .`. `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review, fetch target branch "main" from its remote; compute its merge base with the reviewed HEAD and pass that SHA to `detect_changes({scope: "compare", base_ref: "<merge-base SHA>"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "<merge-base SHA>" --repo .`.
|
||||
- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
|
||||
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
|
||||
- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.
|
||||
|
|
@ -81,27 +83,27 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
|
||||
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read `UNKNOWN` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` with `dry_run: true` first, review the graph-backed semantic spans and coverage, then apply with `dry_run: false` and that preview's `plan_token`.
|
||||
- NEVER commit before MCP/CLI graph change analysis.
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
| --- | --- |
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
| Resource | Use for |
|
||||
| ----------------------------------------- | ---------------------------------------- |
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
| --- | --- |
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus-cli/SKILL.md` |
|
||||
| Task | Read this skill file |
|
||||
| -------------------------------------------- | -------------------------------------------------- |
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
|
|
|||
|
|
@ -85,6 +85,48 @@ Commits within a PR may use any style — only the **merged PR title** shows up
|
|||
|
||||
Maintainers may request changes for correctness, tests, performance, or consistency with existing patterns. Keeping diffs focused makes review faster.
|
||||
|
||||
### Parser changes and cache compatibility
|
||||
|
||||
Parser compatibility is derived automatically from the parser artifacts and
|
||||
effective options. There is no semantic counter to allocate, reconcile with
|
||||
the base branch, or regenerate as a checked-in digest. Parallel parser changes
|
||||
under the same package version produce different identities from their bytes.
|
||||
|
||||
The ownership boundary is declared in
|
||||
`gitnexus/src/core/parse-cache-identity.ts`: ingestion, Tree-sitter, `lib`, graph
|
||||
helpers, audited support/storage files, the selected worker, shared runtime,
|
||||
and parser/native/loader dependencies. CFG extraction and the classified COBOL
|
||||
flow helpers have a separate PDG identity. See
|
||||
[Architecture](ARCHITECTURE.md#automatic-parse-cache-identity) for the boundary
|
||||
and publication safeguards. Raw bytes are covered, so formatting or comments
|
||||
inside these roots can conservatively invalidate caches. Repository docs, UI,
|
||||
unrelated CLI files, and unrelated dependencies are outside this boundary.
|
||||
|
||||
For a parser change:
|
||||
|
||||
1. Check that every new import, loader, runtime asset, and effective option is
|
||||
covered. A new boundary needs an explicit ownership/classification decision
|
||||
and coverage-audit update; a new provider inside an owned root is included
|
||||
automatically.
|
||||
2. Run `npm run build` from `gitnexus/` before validating changed parser facts.
|
||||
A source edit invalidates source-mode cache identity immediately, but does
|
||||
not compile the selected worker: an old `dist` worker can still emit old
|
||||
facts until rebuilt.
|
||||
3. Verify a real cold parse followed by warm reuse of both worker results and
|
||||
durable `ParsedFile` shards, asserting graph facts as well as cache metrics.
|
||||
For extraction-only PDG changes, verify ordinary chunks remain reusable.
|
||||
Run the focused identity and coverage guards from `gitnexus/`:
|
||||
`npm test -- test/unit/parse-cache-identity.test.ts test/unit/parse-cache-input-coverage.test.ts`.
|
||||
4. Review benchmark golden/fingerprint changes explicitly. Explain changed
|
||||
output or pack membership and why the new baseline is intended, then run
|
||||
the exact benchmark check. Automatic invalidation does not establish that
|
||||
a semantic or performance change is correct.
|
||||
|
||||
Graph DDL still has its own automatic `SCHEMA_FINGERPRINT`, and analyzer
|
||||
freshness still has its runner-identity receipt. Intentional wire-format
|
||||
versions, including the V8 envelope and receiver-chain codec, retain their
|
||||
own compatibility rules. Preserve those checks when changing a format.
|
||||
|
||||
## GitHub Actions — Concurrency Convention
|
||||
|
||||
Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:` block using this convention:
|
||||
|
|
@ -95,6 +137,7 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:
|
|||
- `workflow_run` scope (e.g. `ci-report.yml`): `${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}` — the fork fallback must be stable across reruns (never `workflow_run.id`, which is per-run-unique and defeats serialization).
|
||||
- Global single-slot (manual dispatch utilities): `${{ github.workflow }}`
|
||||
- **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). Approved literal prefixes: `CI-` (`ci.yml`) and `docker-build-push-` (`docker.yml`). The `check-workflow-concurrency.py` validation script must be updated whenever a new approved literal prefix is added.
|
||||
- **Shared EC2 resource:** `gitnexus-skill-evolution.yml` and `release-evaluation.yml` use the exact group `gitnexus-evolution-runner` with `cancel-in-progress: false`. They must serialize startup, paid work and hosted shutdown across both workflows. This exception is limited to those two files.
|
||||
- **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel).
|
||||
- **`cancel-in-progress` policy:**
|
||||
|
||||
|
|
@ -155,12 +198,40 @@ Re-invoking `/autofix` after a successful apply is a safe no-op — the workflow
|
|||
|
||||
**Sensitive paths.** The apply workflow refuses any patch that touches `.github/` (workflow files, CODEOWNERS, dependabot config). A malicious PR could ship a custom prettier or ESLint config that reformats workflow YAML; if accepted, those edits would be pushed under `contents: write` without human review. Apply formatter changes to files under `.github/` manually in a normal commit so they get the same review every other workflow change gets.
|
||||
|
||||
### Native tree-sitter npm packages
|
||||
|
||||
The CLI pins and bundles the native `tree-sitter` runtime and its npm grammars.
|
||||
Several compatible grammars still declare older runtime peer ranges. Root npm
|
||||
`overrides` do not propagate to consumers, and older npm versions validate bundled
|
||||
peers against their original ranges even when an override is configured. The
|
||||
lockfile therefore pins the same eight audited peers to exactly `0.25.1`.
|
||||
`gitnexus/scripts/prepare-tree-sitter-bundle.cjs` runs during `postinstall`
|
||||
and `prepack` to keep installed dependency manifests consistent with that lockfile
|
||||
and the published bundle. It leaves grammar sources unchanged and replaces the
|
||||
incorrect `linux-arm64` prebuilds in five exact npm releases with audited AArch64
|
||||
binaries. It validates package versions, peer ranges, dependency layouts, and
|
||||
replacement checksums and architecture before writing manifests or binaries.
|
||||
The upstream ranges remain in the script only as validation inputs; installed and
|
||||
published audited manifests require exactly `0.25.1`. See
|
||||
[`gitnexus/prebuilds/bundled/README.md`](gitnexus/prebuilds/bundled/README.md) for
|
||||
the affected releases, provenance, and regeneration instructions.
|
||||
|
||||
When upgrading these packages, update the exact pins, lockfile, and audited peer
|
||||
list together. If regenerating the lockfile restores upstream's older peer ranges,
|
||||
restore the same audited `0.25.1` peer pins before committing it. Verify ordinary
|
||||
`npm ci` and a subsequent `npm install` complete without peer-resolution warnings.
|
||||
Run the parser, extraction, CFG, and worker tests, then use a real
|
||||
`npm pack` (including `prepack`) to test a fresh consumer's `npm install`,
|
||||
`npm ci`, `npm ls`, and a subsequent dependency install. Check native parser
|
||||
loading on the supported Node.js versions and release platforms. A successful
|
||||
install in this checkout alone does not verify the published bundle.
|
||||
|
||||
### Vendored tree-sitter grammars
|
||||
|
||||
`.github/vendored-grammars.json` is the **single source of truth** for the vendored tree-sitter grammar **set** and each grammar's policy `hold` (the ones shipped from `gitnexus/vendor/<name>` rather than installed from npm). It lists each grammar's name, upstream coords (`npm` or `github`), and any `hold`. The monitor resolves upstreams from it; the readiness report keeps its own upstream-drift coords and reads vendored ABIs from `gitnexus/vendor/`. Two workflows read it:
|
||||
|
||||
- `grammar-update-monitor.yml` (`.github/scripts/update-vendored-grammars.mjs`) — weekly; opens auto-PRs re-vendoring ABI-compatible upstream updates.
|
||||
- `tree-sitter-upgrade-readiness.yml` (`.github/scripts/check-tree-sitter-upgrade-readiness.py`) — daily; renders the tree-sitter-0.25 readiness report (issue #858), reading each vendored grammar's ABI from `gitnexus/vendor/<name>/src/parser.c`.
|
||||
- `tree-sitter-upgrade-readiness.yml` (`.github/scripts/check-tree-sitter-upgrade-readiness.py`) — daily; renders the tree-sitter compatibility-workarounds report (pack-time peer patches, intentional pins, vendored holds; issue #858), reading each vendored grammar's ABI from `gitnexus/vendor/<name>/src/parser.c`.
|
||||
|
||||
Sharing the manifest keeps the two aligned: a consistency-guard test asserts the manifest set equals the `gitnexus/vendor/tree-sitter-*` directories. **When you vendor a new grammar (or remove one), update `.github/vendored-grammars.json` in the same change** — otherwise that guard fails CI and the readiness report regresses to `?` placeholders.
|
||||
|
||||
|
|
@ -184,7 +255,12 @@ routes between two modes based on the triggering event:
|
|||
`gitnexus-claude-plugin/.codex-plugin/plugin.json`,
|
||||
`.agents/plugins/marketplace.json`, and the matching `CHANGELOG.md` entry in
|
||||
lockstep — the always-on `gitnexus` unit suite now fails if those manifest
|
||||
versions drift.
|
||||
versions drift. Stable publication also requires paired agent evidence for
|
||||
the exact tagged commit: run **Release evaluation** from `main` with
|
||||
`candidate_ref` set to the full commit SHA, confirm the run started and
|
||||
passed, and push the tag within seven days. Without that evidence, npm and
|
||||
Docker publication fail. See `eval/workflow_bench/README.md` § Release
|
||||
evaluation.
|
||||
- **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
|
||||
|
|
@ -196,7 +272,11 @@ routes between two modes based on the triggering event:
|
|||
from `latest`. Dispatching with `bump: patch|minor|major` **resets**
|
||||
the cycle from `latest`.
|
||||
- `N` is auto-incremented against existing `X.Y.Z-rc.*` entries on the
|
||||
registry. First rc for a given base is `rc.1`.
|
||||
registry and `vX.Y.Z-rc.*` Git tags, so delayed npm visibility cannot
|
||||
reuse an allocated version. First rc for a given base is `rc.1`.
|
||||
- The RC is gated by CI, including the fixed-answer tool-accuracy check
|
||||
(`gitnexus/bench/tool-accuracy/`); its report is attached to every
|
||||
release. RCs do not wait for a paid agent evaluation.
|
||||
- After the npm publish succeeds, the workflow calls `docker.yml` as a
|
||||
reusable workflow to build and push the corresponding RC Docker images
|
||||
(e.g. `ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1`, mirrored to
|
||||
|
|
|
|||
|
|
@ -83,31 +83,18 @@ 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.
|
||||
# Verify the packaged FTS binary under the runtime user. ExtensionManager loads
|
||||
# this same vendored path before trying HOME, including under load-only policy.
|
||||
# The CDN can replace a versioned artifact with an ABI-incompatible rebuild;
|
||||
# downloading it here would bypass the binary pinned and tested in this tree.
|
||||
# A missing or unloadable packaged extension must still fail the build.
|
||||
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"
|
||||
RUN su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts --verify-bundled"
|
||||
|
||||
# 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.
|
||||
# FTS verification above so editing hook/skill content does not invalidate that
|
||||
# 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).
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Maintainer may widen scope per task.
|
|||
## Non-negotiables
|
||||
|
||||
1. **Never commit secrets** — API keys, tokens, real `.env` values, private URLs, session cookies. Use `.env.example` with placeholders.
|
||||
2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` MCP tool with `dry_run: true` first, review `graph` vs `text_search` edits. No separate `gitnexus rename` CLI exists.
|
||||
2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` with `dry_run: true` first (omitting `dry_run` also previews), review exact graph-backed semantic spans and coverage, then apply with `rename`, `dry_run: false`, and that preview's `plan_token`. Unsupported targets fail closed; inspect and refactor them manually with language-aware tools. No separate `gitnexus rename` CLI exists.
|
||||
3. **Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off.
|
||||
4. **Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available.
|
||||
5. **Preserve embeddings** — plain `npx gitnexus analyze` now preserves any embeddings recorded in the index metadata (`.gitnexus/gitnexus.json`, mirrored to the legacy `meta.json`) — the previous behavior wiped them. Use `--embeddings` to also generate vectors for new/changed nodes; use `--drop-embeddings` only when an explicit wipe is intended (e.g., model swap).
|
||||
|
|
@ -60,9 +60,9 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
|||
|
||||
### Analyze reports INCOMPLETE with a collapsed graph write
|
||||
|
||||
- **Trigger:** `npx gitnexus status` reports `incompleteReasons: ["graph-write-collapsed"]`; the analyze summary printed `Repository indexed INCOMPLETELY` naming an expected and a persisted relationship count, and the CLI exited non-zero.
|
||||
- **Do:** Re-run `npx gitnexus analyze --force`. If it recurs, check free disk space on the volume holding `.gitnexus/`, confirm no second `analyze` is running against the same repo (both stage through `.gitnexus/csv`), then run `npx gitnexus doctor`.
|
||||
- **Why:** The run finished and wrote metadata, but far fewer relationships are readable back than the pipeline produced. Nothing throws: the DB holds rows and the metadata is valid, so every query answers with missing edges rather than an error — a confident empty answer, which is worse than a failure because it looks like a result. Unlike `incremental-in-progress` and `embedding-checkpoint-pending`, which describe a run that did what it said and left work for next time, this one means most of your edges are gone, so it is the one incomplete reason that also fails the exit code. The check compares in-memory totals (including rows streamed out of the heap) against the post-write count, refuses to answer when the count cannot be read, and is skipped on incremental runs where whole-scope counts are not comparable.
|
||||
- **Trigger:** Analysis rejects a staged graph write with expected and persisted relationship counts, or an in-place run prints `Repository indexed INCOMPLETELY`, exits nonzero, and records `incompleteReasons: ["graph-write-collapsed"]`.
|
||||
- **Do:** Inspect COPY errors, available memory, and free disk space on the volume holding `.gitnexus/`; confirm no second `analyze` is running against the same repo, then run `npx gitnexus doctor`. For native buffer-pool exhaustion, see [RUNBOOK.md](RUNBOOK.md#memory--analyze-crashes) for `GITNEXUS_LBUG_BUFFER_POOL_SIZE` and memory-budget guidance. Re-run `npx gitnexus analyze --force` after addressing the cause.
|
||||
- **Why:** Far fewer relationships are readable back than the pipeline produced. A staged build throws before publication and preserves the previous index, if any. In-place writes cannot roll back, so the incomplete stamp and nonzero exit prevent a partial graph from being presented as successful. Recognized buffer exhaustion also throws immediately during COPY or fallback inserts. The collapse check compares in-memory totals (including streamed rows) against the post-write count, stays unmeasurable when the count cannot be read, and is skipped on incremental runs where whole-scope counts are not comparable.
|
||||
|
||||
### MCP lists no repos
|
||||
|
||||
|
|
|
|||
54
MIGRATION.md
54
MIGRATION.md
|
|
@ -1,5 +1,59 @@
|
|||
# Migration Guide
|
||||
|
||||
## LadybugDB 0.22.0 storage and extensions
|
||||
|
||||
LadybugDB 0.22.0 reads indexes written by 0.21.1, but a write/checkpoint upgrades
|
||||
their storage format from version 47 to 49. No manual data conversion is needed.
|
||||
Use the packaged FTS and VECTOR binaries shipped with the new core; older
|
||||
extension binaries can load successfully and then crash when building an index.
|
||||
|
||||
Before upgrading, stop index writers and readers and retain a backup of the
|
||||
complete index storage directory if rollback is required. Include shared stores
|
||||
when worktrees or sibling clones share an index. Keep clients sharing a writable
|
||||
index on compatible engine versions.
|
||||
|
||||
After an index is written by 0.22.0, downgrading the dependency alone is not
|
||||
enough: 0.21.1 refuses storage version 49. Restore the backup, or rebuild with the
|
||||
older engine using `gitnexus analyze --force`. A rebuild under an engine that
|
||||
cannot read the upgraded index cannot recover its cached embeddings; use the
|
||||
backup to preserve them, or regenerate them with `--embeddings`.
|
||||
|
||||
## Automatic parser cache identities
|
||||
|
||||
Parser caches now use artifact-derived base and PDG identities instead of
|
||||
manually assigned semantic version numbers. The worker-result cache and
|
||||
durable `ParsedFile` store both validate the base identity, and their physical
|
||||
chunk keys include it. PDG-enabled chunks also include the PDG identity and
|
||||
resolved function-line cap. CFG-only changes therefore leave ordinary parse
|
||||
chunks eligible for reuse.
|
||||
|
||||
### Do I need to migrate?
|
||||
|
||||
**No.** Run `npx gitnexus analyze` as usual. Legacy numeric caches and indexes
|
||||
without the parser receipt miss once; successful analysis writes the new
|
||||
identities. Plan for a cold parse on the first run after upgrading. Missing,
|
||||
malformed, or foreign identities are also misses, including in shared stores.
|
||||
Plain analysis continues to preserve existing embeddings.
|
||||
|
||||
`--force` rebuilds the graph and may reuse compatible parser output.
|
||||
`--no-parse-cache` forces a cold parse and publishes a fresh cache generation
|
||||
after successful analysis. If parser identity cannot be verified, analysis
|
||||
uses a bounded transient store without persistent cache reads or writes.
|
||||
Failed staging or build verification leaves the previously published cache
|
||||
pair intact. Cache-write failures after graph publication are reported as
|
||||
warnings and do not roll back the successful graph.
|
||||
|
||||
### What changes for contributors?
|
||||
|
||||
There is no semantic counter or generated digest file to update. Check input
|
||||
coverage and cold/warm behavior instead; see
|
||||
[Contributing](CONTRIBUTING.md#parser-changes-and-cache-compatibility).
|
||||
When running from source, edits invalidate identity immediately but do not
|
||||
rebuild compiled workers: run `npm run build` in `gitnexus/` before expecting
|
||||
new parser facts.
|
||||
Graph DDL fingerprints, analyzer freshness receipts, and intentional
|
||||
wire-format versions remain separate safeguards.
|
||||
|
||||
## `impact` tool may now return `{ status: 'ambiguous' }` (PR #888, issue #470)
|
||||
|
||||
Before this change the `impact` MCP tool silently picked the first match
|
||||
|
|
|
|||
73
RUNBOOK.md
73
RUNBOOK.md
|
|
@ -57,6 +57,20 @@ version that writes the completeness receipt. An older index or unreadable compl
|
|||
`incompleteReasons: ["scope-extraction-unverified"]`; re-analyze it before treating
|
||||
empty impact results as exact.
|
||||
|
||||
**Invalid symbol identity:** `query`, `context`, or `impact` can return
|
||||
`The index returned an invalid symbol identity` with recovery guidance instead
|
||||
of returning malformed results. Rebuild from the affected repository root with
|
||||
`npx gitnexus analyze --force`. If the error returns, retain the failing command,
|
||||
GitNexus/Node versions and `gitnexus doctor` output for investigation. This check
|
||||
detects blank IDs and NUL-containing identity fields; it does not prove that
|
||||
every otherwise plausible name, path or source range is correct.
|
||||
|
||||
**MCP freshness after rebuilding:** a warm session refreshes its index reference
|
||||
when it opens the replacement graph, including a rebuild at the same commit.
|
||||
A busy reader keeps its old reference until it can reopen. During an observed
|
||||
graph/metadata publication gap, freshness is `unknown`; retry after analysis
|
||||
finishes rather than treating that response as current.
|
||||
|
||||
---
|
||||
|
||||
## Embeddings
|
||||
|
|
@ -181,10 +195,69 @@ Orchestrator: `.github/workflows/ci.yml`.
|
|||
|
||||
---
|
||||
|
||||
## Query memory limit
|
||||
|
||||
**Symptom:** a CLI, MCP, or HTTP server process exits with code **70** and
|
||||
`GitNexus stopped: process memory reached …` on stderr.
|
||||
|
||||
The pooled-read guard detected memory above its process-wide budget. Native
|
||||
query allocations are outside both V8's heap limit and LadybugDB's buffer pool.
|
||||
The Node binding cannot interrupt them, so the guard exits the process to
|
||||
release memory. Every request in that process is interrupted; restart the
|
||||
CLI/server or reconnect the MCP client. A read-only query does not require a
|
||||
reindex after this exit.
|
||||
|
||||
To diagnose a repeat, set `GITNEXUS_QUERY_TRACE=1` in the query/server environment
|
||||
and retain stderr. It includes query text but not bound parameters. Review the
|
||||
output before sharing it with [issue #3696](https://github.com/abhigyanpatwari/GitNexus/issues/3696).
|
||||
Set `GITNEXUS_QUERY_MAX_RSS_MB=<MiB>` to change the limit; `0` disables it. The
|
||||
default is max(8 GiB, 25% of available RAM), capped at 75% of available RAM,
|
||||
including a reported container/runtime constraint.
|
||||
|
||||
RSS is checked at query start/end and every 200 ms while reads run. On macOS,
|
||||
queries lasting at least one second also sample physical footprint at most
|
||||
once per second, including compressed/swapped memory. If the OS footprint
|
||||
command is unavailable, monitoring falls back to RSS. Sampling is a mitigation,
|
||||
not a hard OS memory limit: rapid allocation or a blocked event loop can outrun
|
||||
it. Writes during `analyze` are outside this guard.
|
||||
|
||||
The caller's 30-second read deadline covers prepare, execution, and row
|
||||
materialization. Timing out does not cancel native work: monitoring and its
|
||||
checked-out connection remain owned by that work until it settles. An impact
|
||||
frontier is separately capped at 100,000 relationship rows per depth. A capped
|
||||
walk stops and reports `partial: true`. Both `risk` and `riskSharedAxes` retain
|
||||
HIGH/CRITICAL or report UNKNOWN instead of LOW/MEDIUM. This row cap does not bound the database's
|
||||
intermediate allocations. Narrow the target or relation filters before relying
|
||||
on a partial result.
|
||||
|
||||
## Memory / analyze crashes
|
||||
|
||||
Analyze re-execs Node with a **large old-space heap** when needed (`analyze.ts`). If you still OOM on huge repos, close other processes, avoid `--embeddings` for a first pass, or analyze a smaller path if supported by your workflow.
|
||||
|
||||
`Unable to allocate memory` / `The buffer pool is full` during graph writes
|
||||
refers to LadybugDB's native buffer pool. Increasing `NODE_OPTIONS`,
|
||||
`--memory-budget`, or worker heap limits does not resize that pool. Recognized
|
||||
pool exhaustion aborts analysis instead of retrying with row skipping.
|
||||
|
||||
If the host has enough memory, set an explicit pool size in **bytes**, then
|
||||
rebuild. For example, 3 GiB is `3221225472` bytes:
|
||||
|
||||
```bash
|
||||
GITNEXUS_LBUG_BUFFER_POOL_SIZE=3221225472 npx gitnexus analyze --force
|
||||
```
|
||||
|
||||
The override bypasses automatic pool sizing. Budget for the Node heap, parse
|
||||
workers, other native allocations, and the OS alongside it; a larger pool can
|
||||
exhaust the host. `GITNEXUS_LBUG_BUFFER_POOL_SIZE=0` restores LadybugDB's native
|
||||
80%-of-RAM default, not an unlimited supply of memory. If memory is insufficient,
|
||||
exclude generated/vendor directories or use a larger host.
|
||||
|
||||
A measured graph-write collapse aborts a staged build before publication,
|
||||
leaving the previous index, if any, intact. An in-place run cannot roll back:
|
||||
it records `graph-write-collapsed` and exits nonzero. Rebuild a previously
|
||||
collapsed index with `--force` after addressing memory, disk space, or the
|
||||
reported COPY error; do not rely on its incomplete query results.
|
||||
|
||||
---
|
||||
|
||||
## LadybugDB / lock errors
|
||||
|
|
|
|||
190
TESTING.md
190
TESTING.md
|
|
@ -26,6 +26,7 @@ From `gitnexus/`:
|
|||
| `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 |
|
||||
| `npm run test:dupes` | Duplicate-test scan of lines changed vs `main` | Before adding tests or benchmarks |
|
||||
|
||||
Vitest transpiles TypeScript without checking types. Run `npm run typecheck:tests`
|
||||
in `gitnexus/` to check test code and helpers against the production types. This
|
||||
|
|
@ -103,6 +104,40 @@ The subset is defined in `gitnexus/scripts/cross-platform-tests.ts` and includes
|
|||
|
||||
When adding a platform-sensitive test, add it to the appropriate section in `scripts/cross-platform-tests.ts`.
|
||||
|
||||
### Automatic shard balancing
|
||||
|
||||
CI uses `vitest-shard-balancer` to distribute test files by measured duration.
|
||||
Timing history lives in GitHub Actions storage; adding a test does not require
|
||||
editing a weight table. Windows, macOS, and Ubuntu coverage each have their own
|
||||
profile because native startup and coverage costs differ.
|
||||
|
||||
The `shard-plan` job restores history once and uploads `shard-timing-input`.
|
||||
Every shard and report merge downloads that same snapshot. Retries reuse it,
|
||||
so a newly published history cannot change the partition halfway through a run.
|
||||
Fork approval can increment the attempt number before any job executes. Attempt
|
||||
2 initializes a snapshot only when GitHub confirms attempt 1 required approval
|
||||
and ran zero jobs. Later retries always require the frozen snapshot.
|
||||
|
||||
If the snapshot cannot be restored, the retry fails. If it was never uploaded,
|
||||
deleted, or expired, trigger a fresh workflow run with a new commit; rerunning
|
||||
the old run must not replace its timing input.
|
||||
With no usable history, the sequencer spreads sequential/native work using
|
||||
generic estimates; new files in an established profile use the package's median.
|
||||
|
||||
The merge reporter includes setup, collection, and test execution costs and
|
||||
smooths new measurements with previous ones. It rejects missing, duplicate, or
|
||||
unexpected modules and unsuccessful runs, and retains prior costs for skipped
|
||||
modules. Native blobs are merged on their originating OS. Only main-branch push
|
||||
runs that pass the execution audit publish the updated history to the Actions
|
||||
cache. Cache expiry affects balance, not test discovery or completeness.
|
||||
|
||||
For a local reproduction, download the run's `shard-timing-input` artifact into
|
||||
`gitnexus/.vitest/timings/` and set `GITNEXUS_SHARD_PROFILE` to `windows-latest`,
|
||||
`macos-latest`, or `coverage` (the default). Then use the same `--shard=i/n`
|
||||
argument with `npm run test:cross-platform -- --shard=i/n` or `npm test -- --shard=i/n`.
|
||||
Keep `.vitest/` untracked. Bump the workflow's timing-cache schema key when a
|
||||
runner/measurement change makes the stored profiles incompatible.
|
||||
|
||||
### Confirming no tests are orphaned
|
||||
|
||||
Every test file matches one of the three vitest projects. To verify:
|
||||
|
|
@ -159,12 +194,167 @@ The PR report shows the reconciled result as **Unverified**. Zero means every te
|
|||
has execution evidence; individual OS logs still show tests that require another
|
||||
OS as skipped. Failed executions remain failures even if another job passes.
|
||||
|
||||
### Duplicate-test scan (advisory)
|
||||
|
||||
The `Duplicate test scan` job in `ci.yml` runs `npm run test:dupes` on the lines a PR
|
||||
changes, and the PR report lists what it finds under **Possible duplicate tests**. It
|
||||
never fails a build. It reports:
|
||||
|
||||
| Kind | Meaning |
|
||||
| -------------------- | --------------------------------------------------------------------------- |
|
||||
| `same-test` | Same body and same setup as an existing test |
|
||||
| `assertions-pinned` | Every assertion is a literal `fn(...)` → matcher another test already makes |
|
||||
| `same-fixture-setup` | A `before*` hook builds a fixture another describe already builds |
|
||||
| `copied-helper` | A top-level helper identical to one in another file or `test/helpers` |
|
||||
| `orphan-bench-gate` | A bench script with `--check` that no workflow or npm script runs |
|
||||
|
||||
"Same setup" includes enclosing hooks, describe-level statements, wrapper arguments,
|
||||
loop heads, `each` tables, referenced file-local declarations and resolved imports, so
|
||||
identical bodies that run against different fixtures are not reported. Mark an
|
||||
intentional repeat with `// dupe-ok: <reason>` on the reported line or the line above.
|
||||
Locally, `npm run test:dupes` compares against `origin/main`; pass `--base <ref>` to
|
||||
change that, or `--all` to scan every test. Python tests are not scanned.
|
||||
|
||||
`npm run test:benchmarks` discovers all tests gated by `GITNEXUS_BENCH` and runs
|
||||
them serially. Keep timing measurements out of parallel coverage workers. The
|
||||
`eval-tests` job installs the locked Python dependencies and runs the workflow
|
||||
preflight Vitest tests as well as pytest; those tests must exercise real Python
|
||||
validation, never a stubbed success.
|
||||
|
||||
## Mutation testing tooling
|
||||
|
||||
`gitnexus/tools/mutation/` holds a Stryker test runner for Vitest 5 (`vitest5`) and the tools
|
||||
that validate it. The runner supports Vitest `5.0.x` only and refuses any other version. Its
|
||||
validated scope is the pure-JS category: 251 unit test files (4,667 tests) that load no native
|
||||
code, workers or child processes and read no fixtures. The gates stay out of the main suite
|
||||
(only the tools' unit tests in `test/unit/mutation/` run there), and nothing ships in the npm
|
||||
package.
|
||||
|
||||
Every run works in a prepared copy outside the checkout (`--out`/`--work` must lie outside it).
|
||||
The copy blanks each `as` type assertion in the mutated files with spaces of equal length,
|
||||
because Stryker never mutates code under one, and relocates `gitnexus-shared/src` into the
|
||||
package. From `gitnexus/` (each tool documents its options in its file header):
|
||||
|
||||
| Command | What it does |
|
||||
| -------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `npm run test:mutation:run` | One Stryker run over a scope file with the `vitest5` runner |
|
||||
| `npm run test:mutation:classify` | Lists the pure-JS unit test files |
|
||||
| `npm run test:mutation:fidelity` | Gate 1: the unmutated run matches plain Vitest; records the envelope |
|
||||
| `npm run test:mutation:conformance` | Gate 2: pinned outcomes of the conformance fixture project |
|
||||
| `npm run test:mutation:oracle` | Gate 3: compares runner runs with the frozen oracle manifests |
|
||||
| `npm run test:mutation:oracle:freeze` | Freezes (or re-freezes) one oracle manifest |
|
||||
| `npm run test:mutation:oracle:command` | The oracle's own Stryker run, with Stryker's `command` runner |
|
||||
| `npm run test:mutation:controls` | Gate 4: planted positive controls and determinism across two profiles |
|
||||
| `npm run test:mutation:bench` | Oracle cost benchmark |
|
||||
| `npm run test:mutation:prepare` | Builds a prepared copy on its own |
|
||||
|
||||
Frozen artifacts live in `gitnexus/test/fixtures/`: `mutation-conformance/` (the gate 2
|
||||
fixture project and `expected.json`), `mutation-oracle/` (the gate 1 envelope and one gate 3
|
||||
manifest per oracle scope: `shared`, `schema-constants`, `middleware`) and
|
||||
`mutation-controls/planted.json` (gate 4).
|
||||
|
||||
Re-freeze a manifest when gate 3 reports "re-freeze required", that is after a change to a
|
||||
scope's tests or their imports, the Vitest configs, the prepare tool, or the Stryker, Vitest or
|
||||
Node major version. Use the `refreeze` command recorded in the manifest, with a new seed.
|
||||
Re-select the controls when gate 4 reports "re-select required". After a Vitest, Stryker or Node
|
||||
upgrade, re-run all four gates. Refresh `expected.json` from the conformance `observed.json`
|
||||
only for an intended change.
|
||||
|
||||
`.github/workflows/mutation-runner-validation.yml` runs gate 2 on PRs that touch the tooling,
|
||||
its fixtures, `gitnexus/package.json`, the lockfile or `gitnexus/vitest.config.ts`. A manual
|
||||
dispatch also runs gates 1, 3 and 4 and the benchmark, or re-freezes oracle manifests sharded
|
||||
by scope and uploads them for a maintainer to commit. The workflow is advisory.
|
||||
|
||||
### Test Scenario Map
|
||||
|
||||
`gitnexus/tools/scenario-map/` builds a report on the eligible pure-JS tests: tests that repeat
|
||||
a scenario, tests that kill no evaluated mutant, and per-file gaps. It runs Layers 0–3 of the
|
||||
design (`docs/plans/2026-10-23-test-scenario-map-design.md`) on the `vitest5` runner. It only
|
||||
reports. It never edits, deletes or folds a test.
|
||||
|
||||
From `gitnexus/`, run `npm run test:scenario-map -- <stage> --work <dir>`. The work directory
|
||||
must lie outside the checkout. Every stage reads and writes JSON there, so a local `all` run and
|
||||
CI produce the same report. `value-flow`, `all` and `controls` need a built `dist/`
|
||||
(`npm run build`). The CLI header (`tools/scenario-map/scenario-map.ts`) documents every flag
|
||||
and file.
|
||||
|
||||
| Stage | What it does | Writes (under `--work`) |
|
||||
| ------------ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `plan` | Eligibility, Layers 0–2, the run manifest, candidates, shard plan | `plan.json`, `scenario-run-manifest.json`, `layer0.json`, `layer2.json`, `coverage/` |
|
||||
| `value-flow` | PDG index, static value-flow check, trace run, final shard plan | `value-flow.json` |
|
||||
| `shards` | Prints the final shard count (the CI matrix size) | Nothing |
|
||||
| `shard` | `--index <i> --of <n>`: Layer 3 runs, row deletion, isolation pass | `observations/`, `batches/`, `isolation/`, `row-deletion-shard-<i>.json`, `shard-<i>.json` |
|
||||
| `merge` | Merges every result, computes verdicts, compares with `--baseline` | `scenario-map-report.json`, `scenario-map-report.md`, `issue-body.md` |
|
||||
| `all` | `plan` (one shard), `value-flow`, `shard` 0 of 1 and `merge` | All of the above |
|
||||
| `controls` | `all` over the controls fixture, then compares every expected result | All of the above |
|
||||
|
||||
Exit code 0 means complete results. Exit code 1 means the stage finished with incomplete results,
|
||||
or a controls verdict differs. Exit code 2 is a usage or setup error.
|
||||
|
||||
The controls fixture is `gitnexus/test/fixtures/mutation-scenario-map/`: one pair per
|
||||
`*.case.ts` file (16 pairs). It holds the pure-JS adversarial pairs of design §8, an
|
||||
order-dependent pair, an object-key row and positive controls. `expected.json` was written from
|
||||
the design before any pipeline run. Never regenerate it from observed output: a mismatch is a
|
||||
finding. The fixture has its own Vitest config and tsconfig, and no repo project collects it.
|
||||
The `scenario-map-controls` job in `.github/workflows/mutation-runner-validation.yml` runs
|
||||
`controls` on the same PRs as gate 2.
|
||||
|
||||
**Supported matchers** (`SUPPORTED_MATCHERS` in `tools/scenario-map/facet/checks.ts`), after
|
||||
`expect(…)` or `expect.soft(…)` and an optional `.not`, `.resolves` or `.rejects`:
|
||||
`toBe`, `toEqual`, `toStrictEqual`, `toMatchObject`, `toBeTruthy`, `toBeFalsy`, `toBeNull`,
|
||||
`toBeUndefined`, `toBeDefined`, `toBeNaN`, `toBeNullable`, `toBeTypeOf`, `toBeInstanceOf`,
|
||||
`toBeGreaterThan`, `toBeGreaterThanOrEqual`, `toBeLessThan`, `toBeLessThanOrEqual`,
|
||||
`toBeCloseTo`, `toBeOneOf`, `toContain`, `toContainEqual`, `toHaveLength`, `toHaveProperty`,
|
||||
`toMatch`, `toThrow`, `toThrowError`, `toHaveBeenCalled`, `toHaveBeenCalledOnce`,
|
||||
`toHaveBeenCalledTimes`, `toHaveBeenCalledWith`, `toHaveBeenCalledExactlyOnceWith`,
|
||||
`toHaveBeenLastCalledWith`, `toHaveBeenNthCalledWith`, `toHaveBeenCalledBefore`,
|
||||
`toHaveBeenCalledAfter`, `toBeCalled`, `toBeCalledTimes`, `toBeCalledWith`, `toHaveReturned`,
|
||||
`toHaveReturnedTimes`, `toHaveReturnedWith`, `toHaveLastReturnedWith`, `toHaveNthReturnedWith`,
|
||||
`toReturn`, `toReturnTimes`, `toReturnWith`, `toHaveResolved`, `toHaveResolvedTimes`,
|
||||
`toHaveResolvedWith`, `toHaveLastResolvedWith`, `toHaveNthResolvedWith`, and the snapshot
|
||||
matchers `toMatchSnapshot`, `toMatchInlineSnapshot`, `toMatchFileSnapshot`,
|
||||
`toThrowErrorMatchingSnapshot`, `toThrowErrorMatchingInlineSnapshot`. `expect.assertions(n)` and
|
||||
`expect.hasAssertions()` count as checks. Any other form is unsupported and blocks strong
|
||||
evidence: `assert.*`, chai-style chains, custom matchers, `toSatisfy`, and an `expect` inside a
|
||||
loop, a conditional, a catch clause, a nested function or a helper.
|
||||
|
||||
**Wrapped built-ins** of the trace run (`BASE_WRAP_LIST` in
|
||||
`tools/scenario-map/value-flow/builtins.ts`), by group:
|
||||
|
||||
- `math`: every `Math` function except `random`.
|
||||
- `string`: `String.prototype` `toUpperCase`, `toLowerCase`, `toLocaleUpperCase`,
|
||||
`toLocaleLowerCase`, `normalize`, `localeCompare`, `trim`, `trimStart`, `trimEnd`, `padStart`,
|
||||
`padEnd`, `slice`, `substring`, `substr`, `replace`, `replaceAll`, `split`, `at`, `charAt`,
|
||||
`charCodeAt`, `codePointAt`, `startsWith`, `endsWith`, `includes`, `indexOf`, `lastIndexOf`,
|
||||
`repeat`.
|
||||
- `regexp`: `RegExp.prototype.test` and `exec`; `String.prototype.match`, `matchAll` and
|
||||
`search`.
|
||||
- `number`: `parseInt`, `parseFloat`, the `Number` constructor, and `Number.prototype.toFixed`,
|
||||
`toPrecision`, `toExponential`, `toString`, `toLocaleString`.
|
||||
- `json`: `JSON.parse`, `JSON.stringify`.
|
||||
- `intl`: the `Intl` constructors `Collator`, `DateTimeFormat`, `NumberFormat`, `PluralRules`,
|
||||
`RelativeTimeFormat`, `ListFormat`, `Segmenter`, `DisplayNames`; `Collator.prototype.compare`,
|
||||
`DateTimeFormat.prototype.format` and `NumberFormat.prototype.format`; and the methods
|
||||
`formatToParts`, `formatRange`, `formatRangeToParts`, `select`, `selectRange`, `segment` and
|
||||
`of`.
|
||||
- `date`: the `Date` constructor, `Date.UTC`, `Date.parse`, every `Date.prototype` getter and
|
||||
setter, and its formatters.
|
||||
- `sort`: `Array.prototype.sort` and `toSorted`, recorded with the receiver and the comparator.
|
||||
|
||||
The trace also wraps the opaque callees that the static value-flow check finds. Storage and
|
||||
table reads stay unwrapped: array `push` and `unshift`, and every method of `Map`, `Set`,
|
||||
`WeakMap` and `WeakSet`.
|
||||
|
||||
`.github/workflows/test-scenario-map.yml` runs the pipeline every Monday and on manual dispatch
|
||||
(inputs `seed` and `shards`). Its jobs are `plan`, `value-flow`, `shards`, a `shard` matrix,
|
||||
`merge` and `upsert-issue`. Every job is advisory (`continue-on-error`). Only a complete run from
|
||||
the default branch uploads the `scenario-map-baseline` artifact and replaces the body of the
|
||||
tracking issue (label `test-scenario-map`), unless the issue already records a newer run. A
|
||||
partial run adds a comment and leaves the body alone. The workflow never closes the issue.
|
||||
|
||||
Mark a human keep with `// dupe-ok: <reason>` on either test of a pair, on the test call's line
|
||||
or the line above. The report then lists the pair as kept, with its evidence.
|
||||
|
||||
## Regression testing
|
||||
|
||||
Re-run the full relevant suite when:
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ services:
|
|||
# (http://localhost:4747) works when both containers run locally.
|
||||
# Set GITNEXUS_BACKEND_URL in your .env or shell for remote/custom setups:
|
||||
# GITNEXUS_BACKEND_URL=http://<server-ip>:4747
|
||||
# Behind a reverse proxy that routes /api/* next to the UI, set it to `/`:
|
||||
# the browser then calls the API on whichever hostname served the page
|
||||
# (see "Same-origin API behind a reverse proxy" in the README).
|
||||
environment:
|
||||
- GITNEXUS_BACKEND_URL=${GITNEXUS_BACKEND_URL:-}
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -78,12 +78,26 @@ function numberFromEnv(label, fallback, min = 0) {
|
|||
return n;
|
||||
}
|
||||
|
||||
// A root-relative value — `/`, or `/prefix` when the API is mounted under a
|
||||
// path — names no host at all: the browser resolves it against the origin that
|
||||
// served the page, so one deployment answers on every hostname a reverse proxy
|
||||
// gives it (#3593). Not a URL, so it is read before validHttpUrl would warn.
|
||||
// `//host` is protocol-relative, an origin change, and is left to the URL check;
|
||||
// so is `/\host`, since the URL parser reads a backslash as a slash.
|
||||
function sameOriginPath(value) {
|
||||
if (typeof value !== 'string' || !/^\/(?![/\\])/.test(value)) return null;
|
||||
// A base for `${base}/api/...`: no query, fragment, backslash, whitespace or
|
||||
// control char.
|
||||
if (/[?#\\\s\x00-\x1f\x7f]/.test(value)) return null;
|
||||
return value.replace(/\/+$/, '') || '/';
|
||||
}
|
||||
|
||||
// Falls back to RENDER_EXTERNAL_URL so a Render web service hands the browser
|
||||
// its own public origin — same-origin API calls via the proxy below, no config.
|
||||
const backendUrlVar =
|
||||
process.env.GITNEXUS_BACKEND_URL !== undefined ? 'GITNEXUS_BACKEND_URL' : 'RENDER_EXTERNAL_URL';
|
||||
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? process.env.RENDER_EXTERNAL_URL ?? null;
|
||||
const backendUrl = validHttpUrl(backendUrlVar, rawBackendUrl);
|
||||
const backendUrl = sameOriginPath(rawBackendUrl) ?? validHttpUrl(backendUrlVar, rawBackendUrl);
|
||||
const configScript = backendUrl
|
||||
? `<script>window.__GITNEXUS_CONFIG__=${jsonForScriptTag({ backendUrl })};</script>`
|
||||
: '';
|
||||
|
|
|
|||
|
|
@ -199,6 +199,47 @@ it('injects __GITNEXUS_CONFIG__ into SPA fallback routes', async () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('injects a same-origin path when GITNEXUS_BACKEND_URL is /', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: '/' }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(res.body.includes('"backendUrl":"/"'), 'Expected the root path as the backend URL');
|
||||
});
|
||||
});
|
||||
|
||||
it('injects a same-origin prefix without its trailing slash', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: '/gitnexus/' }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
res.body.includes('"backendUrl":"/gitnexus"'),
|
||||
'Expected the prefix as the backend URL',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject a protocol-relative GITNEXUS_BACKEND_URL', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: '//evil.example' }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
!res.body.includes('__GITNEXUS_CONFIG__'),
|
||||
'Expected no __GITNEXUS_CONFIG__ for a protocol-relative value',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject a backslash-prefixed GITNEXUS_BACKEND_URL, which a URL parser reads as a host', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: '/\\evil.example' }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
!res.body.includes('__GITNEXUS_CONFIG__'),
|
||||
'Expected no __GITNEXUS_CONFIG__ for a backslash-prefixed value',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject when GITNEXUS_BACKEND_URL is not set', async () => {
|
||||
await withInjectionServer({}, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
|
|
|
|||
368
docs/cli-reference.md
Normal file
368
docs/cli-reference.md
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
# CLI reference
|
||||
|
||||
[Back to GitNexus](../README.md)
|
||||
|
||||
## Commands
|
||||
|
||||
Everyday commands:
|
||||
|
||||
```bash
|
||||
gitnexus setup # Configure MCP for detected editors (one-time; -c to select)
|
||||
gitnexus analyze [path] # Index a repository (or update a stale index)
|
||||
gitnexus analyze [path] --watch # Watch local files and serialize incremental refreshes
|
||||
gitnexus mcp # Start MCP server (stdio; one server PER CLIENT) — all indexed repos
|
||||
gitnexus mcp --http # Start MCP server (Streamable HTTP + SSE) — ONE server, many clients
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
gitnexus eval-server # Start lightweight evaluation HTTP tools (loopback by default)
|
||||
gitnexus list # List all indexed repositories
|
||||
gitnexus status # Show index status for current repo
|
||||
gitnexus clean # Delete index for current repo
|
||||
gitnexus wiki [path] # Generate repository wiki from knowledge graph
|
||||
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (--force to apply)
|
||||
```
|
||||
|
||||
### Multi-client setups: use `--http`, not stdio
|
||||
|
||||
MCP-over-stdio is **one server process per client** by protocol design — every client
|
||||
spawns its own `gitnexus mcp` child, with its own copy of the loaded index. If you run N
|
||||
concurrent agents/workers, that is N full index servers.
|
||||
|
||||
`gitnexus mcp --http` starts **one** server (Streamable HTTP at `POST /mcp`, legacy SSE at
|
||||
`GET /sse` + `POST /messages`) that any number of clients can connect to:
|
||||
|
||||
```bash
|
||||
gitnexus mcp --http --host 127.0.0.1 --port 3111
|
||||
```
|
||||
|
||||
Point clients at `http://127.0.0.1:3111/mcp`. A non-loopback bind (`--host 0.0.0.0`)
|
||||
requires `--auth-token` (or `GITNEXUS_MCP_AUTH_TOKEN`) and refuses to start without it.
|
||||
|
||||
Measured on a 5-core host with 3 indexed repos, 5 concurrent clients issuing the same
|
||||
query: shared `--http` ran the batch in 7.7 s at a flat 517 MB, versus 30.0 s and
|
||||
+514 MB (~103 MB per extra client) for per-client stdio — with identical results.
|
||||
|
||||
You can also query the graph directly from the terminal — `gitnexus query`, `context`, `impact`, `trace`, `cypher`, `detect-changes`, and `check` mirror the MCP tools of the same names, and `gitnexus doctor` prints runtime platform capabilities.
|
||||
|
||||
`gitnexus impact <symbol> --max-tokens 1000` budgets compact JSON at four UTF-8 bytes per token. Single-repo callgraph results retain the nearest dependants first. Counts and risk remain complete; `pagination.trimmedForBudget` describes omitted symbols. Group and PDG results must fit intact. If required content cannot fit, the command returns a JSON error and exits nonzero. Extremely small budgets return JSON `null` with the same failure status. Budgeted output is compact even on a terminal; other direct tool output is compact when piped and indented on a terminal.
|
||||
|
||||
Impact defaults to 30 symbols per depth when pagination is omitted. Use `--limit` and `--offset` for explicit pages, or `GITNEXUS_IMPACT_DEFAULT_LIMIT=0` for all symbols. `--summary-only`, group queries and PDG mode bypass this default. Symbols list at most three processes, with `processCount` preserving the full count when shortened. `--max-tokens` is explicit on the CLI; `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` applies only to MCP.
|
||||
|
||||
`gitnexus analyze --watch` requires a Git repository. It runs one initial
|
||||
analysis, then debounces scanner-admitted working-tree changes for 300 ms by
|
||||
default and applies serialized incremental refreshes. Events arriving during a
|
||||
refresh remain queued, and retryable failures retain the same batch with bounded
|
||||
backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes
|
||||
until the control file is fixed. Stop the watcher with Ctrl+C.
|
||||
|
||||
Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`,
|
||||
`--max-file-size`, `--max-processes`, `--max-process-branching`,
|
||||
`--max-process-trace-depth`, `--max-entry-point-candidates`, `--branch`, `--pdg`, `--skip-fts`, `--name`, `--allow-duplicate-name`, and
|
||||
`--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`,
|
||||
embedding flags, `--skills`, `--self-commit`, `--index-only`, and `--skip-git`
|
||||
are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a
|
||||
warning rather than making an otherwise valid repository unwatchable.
|
||||
|
||||
POSIX requests clone-first copy-and-swap publication when the live index has no
|
||||
orphan sidecars. Windows and sidecar fallback runs update in place: failures
|
||||
known to occur before writes are retried, while a failure that may have mutated
|
||||
the live index stops the watcher. Watch mode does not pull remotes. Running MCP
|
||||
and `serve` processes reopen a newly published index automatically; MCP observes
|
||||
the replacement on its next tool call, typically within five seconds, so no
|
||||
restart is required.
|
||||
|
||||
## Authenticated eval-server binding
|
||||
|
||||
`gitnexus eval-server` binds to `127.0.0.1` by default. Loopback bindings do not require authentication. Any non-loopback bind, including `0.0.0.0`, a LAN address, or a hostname that resolves to a LAN IPv4 address, requires `GITNEXUS_AUTH_TOKEN`. Every endpoint then requires an exact `Authorization: Bearer <token>` header.
|
||||
|
||||
```bash
|
||||
GITNEXUS_AUTH_TOKEN='replace-me' gitnexus eval-server --host 0.0.0.0
|
||||
```
|
||||
|
||||
The token may be set in the shell, `.env.local`, or `.env` in the working directory. Precedence is shell > `.env.local` > `.env`. Only `GITNEXUS_AUTH_TOKEN` is read from those files; their other values are not added to the process environment. Keep token files uncommitted.
|
||||
|
||||
## All analyze flags
|
||||
|
||||
```bash
|
||||
gitnexus analyze --force # Full graph + FTS rebuild (reuses unchanged parser output)
|
||||
gitnexus analyze --no-parse-cache # Full rebuild that re-parses every source file
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --skip-fts # Index graph/embeddings without loading FTS or building keyword indexes
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --skip-skills # Skip installing standard skill files under .claude/skills/ and .agents/skills/
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --default-branch develop # Branch used in the generated regression-compare example (base_ref)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16,
|
||||
# auto-sized to the repo). 0 is rejected — there is no sequential mode.
|
||||
gitnexus analyze --max-processes <n> # Process-detection process cap (replaces dynamic max(20, round(symbols/10)))
|
||||
gitnexus analyze --max-entry-point-candidates <n> # Ranked entry-point pool (default 200; raise when the warning names it)
|
||||
gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots
|
||||
gitnexus analyze --asyncapi-spec ./docs/asyncapi # Resolve broker addresses from AsyncAPI 3.x documents
|
||||
gitnexus analyze --memory-budget 3000 # Main-thread V8 heap in MB (>= 200); overrides the auto-sizer and --max-old-space-size
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes
|
||||
# (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
```
|
||||
|
||||
`--skip-fts` (or `GITNEXUS_SKIP_FTS=1`) disables FTS extension loading and keyword-index construction for this analysis. Graph queries, communities, processes, and existing embeddings remain available. Status and search report "FTS disabled for this index". Remove both the flag and environment setting and run `analyze` again to restore keyword search, even at the same commit. Only the exact environment value `1` enables the opt-out; the flag takes precedence. It cannot be combined with `--repair-fts`. Disabling an existing FTS index may require one graph-store rebuild to avoid unsafe writes through native indexes.
|
||||
|
||||
`--spring-actuator` is explicitly opt-in and accepts either a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. It confirms matching static nodes and adds conservative runtime-only routes, beans, and property keys. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Because snapshots are external runtime state, an enabled run always rebuilds; the first later run without the option rebuilds once to remove runtime evidence. The same path can be set as `springActuator` in `.gitnexusrc`.
|
||||
|
||||
`--asyncapi-spec` is explicitly opt-in and accepts a directory of AsyncAPI documents or a single document; the path is resolved against the repository root, so a committed `docs/asyncapi` and an absolute cache written by something else both work. Each `operations[]` entry of an **AsyncAPI 3.x** document can contribute a `Destination` node keyed by broker and address, with `action: send` emitting `PUBLISHES_TO` and `action: receive` emitting `CONSUMES_FROM`, so a document and source code that name one address on one broker land on the same node. Edges start at the document, not at a callable — a document states that the service talks to an address, not which method does — and no address a document names is ever attached to an unresolved source site.
|
||||
|
||||
An operation must name a protocol, either through its own `bindings` or through the `servers[].protocol` of the servers its channel resolves to (a channel that lists no `servers` resolves to all of them); operations that name none are refused, as are operations whose two readings name different brokers, and channels that inherit a multi-protocol server set without choosing. HTTP and WebSocket documents are refused for destination minting: there the host rather than the address names the place, and an HTTP endpoint is already modelled as a `Route`. A parameterized address — a channel declaring `parameters`, or an address containing `{` — is refused rather than keyed: two services publishing `{env}.orders` share a pattern, not a queue. AsyncAPI **2.x is refused** under its own counted reason and never mapped, because its `publish`/`subscribe` are inverted relative to 3.x `send`/`receive` and a naive mapping would reverse the async graph while leaving it connected. Every refusal is counted, and a configured path that yields nothing is reported rather than passed over in silence.
|
||||
|
||||
Like Actuator snapshots, documents are external to git freshness — replacing one moves no commit and dirties no file — so an enabled run always rebuilds, and the first later run without the option rebuilds once to remove document-derived evidence. There is no glob-based auto-discovery, and the option is unsupported with `--watch`.
|
||||
|
||||
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 `--worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
**Embeddings node limit** — `gitnexus analyze --embeddings` generates semantic search vectors with a default 50,000-node safety cap to protect memory on large repositories:
|
||||
|
||||
```bash
|
||||
gitnexus analyze --embeddings # default 50,000 node safety cap
|
||||
gitnexus analyze --embeddings 0 # disable the cap entirely
|
||||
gitnexus analyze --embeddings 100000 # custom cap
|
||||
```
|
||||
|
||||
If embeddings are skipped on a large repository, the indexed graph likely exceeds the default cap — re-run with `--embeddings 0` or a higher limit.
|
||||
|
||||
## Keep remote repositories indexed with gitnexus auto-sync
|
||||
|
||||
`gitnexus auto-sync` clones or pulls configured repositories, analyzes new commits, and optionally syncs their group. It runs once immediately, then repeats on the configured interval. It runs in the foreground; use your process manager if it must survive a shell session. `gitnexus watch` is reserved and prints this split; it does not start auto-sync or local file watching.
|
||||
|
||||
```bash
|
||||
# 1. Create the config once. It never overwrites an existing file.
|
||||
gitnexus auto-sync init
|
||||
|
||||
# 2. Edit $GITNEXUS_HOME/watch_config.yml, then start it.
|
||||
gitnexus auto-sync start # `gitnexus auto-sync` is equivalent
|
||||
gitnexus auto-sync status
|
||||
gitnexus auto-sync restart # Required after config changes
|
||||
gitnexus auto-sync stop
|
||||
gitnexus auto-sync reset # Clear failure state; leaves clones and indexes intact
|
||||
```
|
||||
|
||||
`GITNEXUS_HOME` defaults to `~/.gitnexus`. A minimal configuration:
|
||||
|
||||
```yaml
|
||||
sync_interval_minutes: 10
|
||||
analyze_timeout: 5m
|
||||
# Extra hosts beyond github.com, gitlab.com, and gitee.com. Exact names only.
|
||||
# allowed_hosts: [gitlab.mycompany.com]
|
||||
projects:
|
||||
- local_path: /absolute/path/to/clones
|
||||
branches: [main, master]
|
||||
# pdg: omit = preserve live index mode; true = keep PDG current;
|
||||
# false = init default (warns, then strips PDG on the next successful rebuild).
|
||||
# Do not paste pdg: false onto an existing watch file unless you intend to drop PDG.
|
||||
pdg: false
|
||||
overwrite_local_changes: false
|
||||
remote_urls:
|
||||
- git@github.com:owner/repo.git
|
||||
```
|
||||
|
||||
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`.
|
||||
- Remote URLs may use SSH SCP or HTTPS. Hosts are github.com, gitlab.com, and gitee.com unless listed in top-level `allowed_hosts` (exact DNS names, no wildcards). The CLI image includes OpenSSH; mount keys yourself. Invalid `watch_config.yml` skips auto-sync immediately. Auto-sync honors `.gitnexusrc` embeddings (HTTP embeddings env still required in the image).
|
||||
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
|
||||
- Set per-project `pdg: true` to keep the full control-flow, control/data-dependence, and taint layers current. Untouched configs that omit `pdg` preserve an existing index's mode and cannot silently strip PDG data. Do not paste `pdg: false` from this example onto an existing watch file unless you intend to drop PDG; an explicit `false` opt-out logs a warning before removing existing PDG data. Auto-sync requests atomic incremental publication where supported, so readers keep using the previous graph until a successful update is ready and a failed staged analysis leaves it intact; unsupported paths retain the analyzer's existing in-place behavior.
|
||||
- Analysis runs in an isolated worker; `analyze_timeout` defaults to half of `sync_interval_minutes`, but may be longer (for example, a `30m` analysis timeout with `5` minute polling) up to Node's timer limit. If a polling tick arrives while analysis is active, it is coalesced into one immediate follow-up run using the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.
|
||||
- Add `group_name` only after creating that group with `gitnexus group create <name>`. Partial clone output is isolated and removed after 14 days.
|
||||
|
||||
See the [full auto-sync configuration and runtime reference](../gitnexus/README.md#gitnexus-auto-sync) for concurrency, timeouts, failure thresholds, and runtime files.
|
||||
|
||||
## Repository groups (multi-repo / monorepo service tracking)
|
||||
|
||||
```bash
|
||||
gitnexus group create <name> # Create a repository group
|
||||
gitnexus group add <group> <groupPath> <registryName> # Add a repo. <groupPath> is a hierarchy path
|
||||
# (e.g. hr/hiring/backend); <registryName> is the
|
||||
# repo's name from the registry (see `gitnexus list`)
|
||||
gitnexus group remove <group> <groupPath> # Remove a repo by its hierarchy path
|
||||
gitnexus group list [name] # List groups, or show one group's config
|
||||
gitnexus group sync <name> # Extract contracts and match across repos/services
|
||||
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
|
||||
gitnexus group query <name> <q> # Search execution flows across all repos in a group
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
gitnexus group impact <name> --target <symbol> --repo <groupPath> # Cross-repo blast radius
|
||||
```
|
||||
|
||||
## 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 standard skill files under .claude/skills/ and .agents/skills/
|
||||
"embeddings": true, // generate embeddings by default
|
||||
"springActuator": "./actuator", // optional local runtime snapshot directory or bundle
|
||||
"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`, `maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
|
||||
- The file is JSON only. Unknown keys and wrong JSON types fail fast with an actionable error before analysis starts. Process-detection knobs (`maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`) that are not a positive integer warn and fall through to env, then the built-in default.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Read queries have a process-wide native-memory guard. See the
|
||||
[query memory runbook](../RUNBOOK.md#query-memory-limit) for exit code 70,
|
||||
sampling limits, and recovery.
|
||||
|
||||
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 `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.
|
||||
|
||||
| Query variable | Default | Effect | When to tune |
|
||||
| --------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_QUERY_MAX_RSS_MB` | max(8192 MiB, 25% of available RAM), capped at 75% of available RAM | Process-memory ceiling while pooled reads run; available RAM is the smaller of physical RAM and a reported runtime/container constraint. Positive MiB values override it; `0` disables the guard. Invalid, overflowing, or sub-byte values use the default. A breach exits the process with code 70. | Set in the CLI/MCP/serve environment when a legitimate read workload needs a different budget. It does not resize the database buffer pool. |
|
||||
| `GITNEXUS_QUERY_TRACE` | unset | `1` writes query duration, row count, and sampled memory to stderr. Bound parameters are omitted; query text is included. | Diagnose an expensive read or a memory-guard exit. Inspect trace output before sharing it. |
|
||||
|
||||
| Analyze configuration | Default | Effect | When to tune |
|
||||
| --- | --- | --- | --- |
|
||||
| `GITNEXUS_RC` | unset | Absolute path to a JSON file with the same keys as `.gitnexusrc`. Its keys override repository configuration; CLI flags still win. Missing, invalid, or oversized files fail analysis. | Keep local configuration outside the checkout. Re-indexing hooks and watch processes must inherit it too. Changing or removing router aliases or handler wrappers rebuilds route evidence. |
|
||||
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| ----------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_TEST_REPORT` | unset | Write a JSON execution receipt from `scripts/run-cross-platform.ts`; accepts a basename ending in `.json`. | CI platform shards use distinct filenames so the completeness check can require every report. |
|
||||
| `GITNEXUS_SHARD_PROFILE` | `coverage` | Selects the timing profile for Vitest shard balancing and merged timing reports: `coverage`, `windows-latest`, or `macos-latest`. | CI selects its native OS; set the matching profile when reproducing a shard locally. See [Automatic shard balancing](../TESTING.md#automatic-shard-balancing). |
|
||||
| `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_EMBEDDING_RETRY_TIMEOUTS` | unset | When truthy (`1`/`true`/`yes`), per-attempt HTTP embedding timeouts (`TimeoutError` on fetch or body read) go through the bounded `GITNEXUS_EMBEDDING_MAX_ATTEMPTS` retry loop instead of failing the job. Any other value leaves it off, so cloud/default timeouts remain terminal. | Local accelerators that drop a device lock when the client disconnects and succeed on the next request (observed with FastFlowLM on Ryzen AI). |
|
||||
| `GITNEXUS_EMBEDDING_SIDECAR_TIMEOUT_MS` | `180000` (3 minutes) | Per-request IPC timeout for local embedding sidecar embed batches. On overrun the parent SIGKILLs the sidecar child and rejects the batch. Init still uses the HF download budget (`HF_DOWNLOAD_TIMEOUT_MS` × attempts), not this knob. | Large embed batches or slow local ONNX inference cause sidecar request timeouts during `analyze --embeddings`, `embeddings sync`, serve, or MCP. |
|
||||
| `GITNEXUS_VECTOR_MAX_DISTANCE` | `0.6` for CLI/MCP `query`; `0.5` for standalone semantic search | Maximum cosine distance for vector hits, applied to both indexed search and exact-scan fallback. Hits must have `distance < cutoff`. Unset/blank uses the default; non-numeric, non-finite, zero, or negative values warn and use the default; finite values above `2` warn and clamp to `2`. | Paraphrased queries have weak semantic recall with your embedding model. See [Vector cutoff tuning](#vector-cutoff-tuning). |
|
||||
| `GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS` | unset | When truthy (`1`/`true`/`yes`), forces in-process cache-guard validation once a batch has ≥128 requests. In-process mode also auto-selects when `packageRoot`/`buildRoot` fail `W_OK` with `EACCES`/`EROFS`. Otherwise those large batches use a Node subprocess probe. Batches under 128 always stay in-process. | Trusted or read-only installs where two identity subprocess spawns per analyze dominate wall time; leave unset to keep the default isolation path on writable trees. |
|
||||
| `GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO` | on (unset) | Memoizes `resolveDefGraphId` per `nodeLookup` instance (WeakMap). Enabled by default. Set to `0`/`false`/`off`/`no` to disable and recompute on every call (debug / bisect memo bugs). | Suspecting stale graph-id resolution after a lookup rebuild, or comparing memo vs uncached cost on a large index. |
|
||||
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
|
||||
| `GITNEXUS_MCP_AUTH_TOKEN` | unset | Bearer token for the dedicated `gitnexus mcp --http` server, for a **directly reachable** `gitnexus serve` `/api/mcp` route, and for the `docker-server` / web proxy in front of one. A non-loopback dedicated MCP bind requires it; `serve` enables protocol-layer MCP auth when it is set. Behind a proxy, set the **same** value on both services: the proxy spends the edge `GITNEXUS_SERVE_AUTH_TOKEN`, then replaces `Authorization` with this token on `/api/mcp` only. | Dedicated MCP, a `serve` the client can reach directly, or a proxied deploy (Render Blueprint) where the backend runs protocol-layer MCP auth — configure it on the proxy too. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_INCLUDE_DOT_DIRS` | unset | Comma-separated dot-directory names to index, e.g. `.github,.scallywag`. Changes take effect on the next analyze; status replays the recorded list. Hidden files, credential paths, and symlinks under opted-in paths remain excluded. Ignore rules still apply. | Indexing trusted source under hidden directories. For `.github`, also add `!.github/` to `.gitnexusignore`. Path protection does not scan source text for secrets. |
|
||||
| `GITNEXUS_MAX_PROCESSES` | dynamic (`max(20, round(symbols/10))`) | Analyze-time process-detection process cap. Equivalent to `--max-processes <n>` / `.gitnexusrc` `maxProcesses`. Explicit values replace the dynamic formula (not a multiplier). `0` is invalid, not unlimited. Changing this re-detects flows on the next analyze without `--force`. Distinct from query-time `IMPACT_MAX_CHUNKS`. | `[processes] … whole flows are MISSING` names `--max-processes` after entry points were never traced or flows were dropped. Tracing does not start the next entry once collected traces already reach `maxProcesses * 2`; a started entry can still emit every trace that entry produces. |
|
||||
| `GITNEXUS_MAX_PROCESS_BRANCHING` | `4` | Analyze-time per-node branching cap during flow tracing. Equivalent to `--max-process-branching <n>`. Shape-only: raising it shortens fewer traces; it does not restore whole missing flows. | A flow is present but `calleesDropped` is high at debug. |
|
||||
| `GITNEXUS_MAX_PROCESS_TRACE_DEPTH` | `10` | Analyze-time DFS depth cap during flow tracing. Equivalent to `--max-process-trace-depth <n>`. Shape-only. | A reported flow is shorter than the code path (`tracesDepthCapped` at debug). |
|
||||
| `GITNEXUS_MAX_ENTRY_POINT_CANDIDATES` | `200` | Ranked entry-point candidate pool. Equivalent to `--max-entry-point-candidates <n>`. Raising `--max-processes` alone does not clear `entryPointCandidatesDropped`. Doubling the current cap is the usual first raise; setting it to the full remaining candidate count can exhaust CPU and memory. | The `[processes]` warning reports candidate entry points that never ranked in. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
|
||||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_STORAGE_PATH` | unset (`<repo>/.gitnexus/`) | Complete external index directory. This preserves the existing configuration semantics and takes precedence over `GITNEXUS_STORAGE_ROOT` when both are set. | You already keep one repository index outside its checkout or need one explicit index location. |
|
||||
| `GITNEXUS_STORAGE_ROOT` | unset | Absolute root directory for external indexes. GitNexus creates an isolated `<repo-basename>-<canonical-path-hash>/` slot beneath it for each repository, then registers the resolved slot so `status`, MCP, and `serve` can reopen it later. | You want to manage multiple repository indexes centrally or keep generated data outside source checkouts. |
|
||||
| `GITNEXUS_SHARED_STORE` | unset (on) | Set to `off` (or `0`, `false`, `no`) to turn off shared index stores for both linked git worktrees and sibling clones; every checkout then indexes into its own `.gitnexus/`. Sharing is also off whenever `GITNEXUS_STORAGE_PATH` or `GITNEXUS_STORAGE_ROOT` is set. | Disk or memory is not a concern, or you want each worktree's index fully independent. |
|
||||
| `GITNEXUS_CONTENT_RETENTION` | `full` | Source-text retention profile: `full` keeps file and symbol text, `symbol` keeps symbol snippets without full file content, and `none` keeps the structural graph without source body text. | You need to reduce persisted source text while preserving graph structure. |
|
||||
| `GITNEXUS_SKIP_FTS` | unset | When exactly `1`, skips FTS extension loading and keyword index creation during analyze. Equivalent to `--skip-fts`; a later analyze without either option restores FTS. | Graph-only consumers with their own retrieval, or short-lived indexes that do not need keyword search. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). The native engine requires a power of two: any other positive integer is rounded up to the next power of two with a warning, and invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
|
||||
| `GITNEXUS_ATOMIC_INCREMENTAL` | unset (the caller's choice) | `1` stages an incremental analyze into a copy of the index and swaps it in at the end, so a failed run leaves the live index untouched. `0` turns that off even for callers that request it (watch mode, auto-sync); the run logs a line when it overrides one. The cost: a failed in-place write can leave the live index partly written, and the next run then rebuilds it in full. A full rebuild, and an incremental run that escalates to a staged rebuild, still stage. POSIX only. | A staged incremental analyze fails (see _Troubleshooting_ in the package README), or you need in-place writes. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
|
||||
| `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_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Per-bucket byte budget for parse-cache packing. Files are grouped by `(language, hash(path) mod 128)`; packs inside a bucket are cut at this limit. Smaller = finer-grained invalidation and more dispatch. Default is always 2 MiB and no longer scales with worker count. | Tuning incremental-analyze cache invalidation on monorepos without changing `--workers`. |
|
||||
| `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 exactly `1`, skips optional vendored Dart, Proto, Swift, Kotlin and Zig grammar setup. The C grammar and other native dependencies remain required. | Skipping optional language parsers when their prebuilds are unavailable and no source-build toolchain is installed. |
|
||||
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
|
||||
| `GITNEXUS_MCP_ENABLE_REFRESH` | unset | Set to `1` to list the `refresh_index` MCP tool on a local `gitnexus mcp` server (stdio or `--http`). `gitnexus serve` never exposes it, read-only mode hides and rejects it, and any value other than `0` or `1` fails startup. | Agents on a local server can see a stale index but have no shell to run `gitnexus analyze`. |
|
||||
| `GITNEXUS_MCP_REFRESH_MAX_FILES` | `5000` | `refresh_index` refuses a repository whose working tree holds more files than this, counted the way analyze scans it before starting the rebuild. `0` removes the limit. | Raise it when larger repositories should be refreshable over MCP. |
|
||||
| `GITNEXUS_MCP_REFRESH_TIMEOUT_MS` | `600000` | Maximum time one `refresh_index` call waits for its rebuild before returning an error. The worker is not interrupted and finishes in the background. At most `2147483647`. | A refresh needs longer than ten minutes. |
|
||||
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
|
||||
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
|
||||
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
|
||||
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
|
||||
| `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. |
|
||||
|
||||
### Vector cutoff tuning
|
||||
|
||||
`GITNEXUS_VECTOR_MAX_DISTANCE` controls which vector candidates enter hybrid search. Cosine distance is lower for more similar vectors; the appropriate cutoff depends on the embedding model and repository. A higher cutoff can recover useful semantic matches, but can also admit less relevant hits and change the fused ranking. The default is `0.6` for CLI and MCP `query`, and `0.5` for the standalone semantic-search function.
|
||||
|
||||
For an index that already has embeddings, compare the default with `0.8` and `1.0` on representative queries, including paraphrases, exact symbol names, and queries that should have no relevant result:
|
||||
|
||||
```bash
|
||||
GITNEXUS_VECTOR_MAX_DISTANCE=0.8 gitnexus query "how are expired sessions removed" --limit 10
|
||||
```
|
||||
|
||||
Check both whether the expected symbols appear and where they rank. In [#3457](https://github.com/abhigyanpatwari/GitNexus/issues/3457), the reporter's `voyage-code-4` sample found 20 of 32 paraphrased targets at `0.6`, 25 at `0.8`, and 28 at `1.0`. Those values are a tuning starting point for that model, not universal recommendations: one paraphrased target fell from first to twelfth at `1.0`. The sample covered eight repositories, recorded no raw distances, used agent-written queries after reading the code, and did not send Voyage's `input_type`.
|
||||
|
||||
Set the variable in the process that runs the search. For MCP, add it to the server's launch environment and restart the server; for `gitnexus serve`, set it in that process's environment and restart it. Setting it only during `analyze`, or exporting it in another shell, does not configure an existing server. Changing the cutoff requires no reindex.
|
||||
|
||||
The cutoff only filters available vector candidates. It cannot add missing embeddings or repair a mismatch between the indexing and query-time embedding configuration; see the [embeddings runbook](../RUNBOOK.md#embeddings). A cutoff of `2` is very permissive, but still rejects distance exactly `2` and retains the search candidate limits.
|
||||
|
||||
## gitnexus uninstall
|
||||
|
||||
`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.
|
||||
|
||||
## Publishing to understand-quickly (opt-in)
|
||||
|
||||
[`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly) is a public registry of code-knowledge graphs that lists `gitnexus@1` as a first-class format. After registering your repo once (`npx @understand-quickly/cli add` or the [wizard](https://looptech-ai.github.io/understand-quickly/add.html)), `gitnexus publish` fires a single `repository_dispatch` event so the registry resyncs your entry on demand instead of waiting for the nightly job.
|
||||
|
||||
It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained GitHub PAT with `Repository dispatches: write` on the registry repo. Nothing else happens; no graph file is uploaded. See the [protocol spec](https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md) for the full contract.
|
||||
|
||||
## Index storage and multiple repositories
|
||||
|
||||
GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere.
|
||||
|
||||
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo by default (portable, gitignored). `GITNEXUS_STORAGE_PATH` selects one complete external index directory and preserves the established configuration behavior. To manage multiple repositories under one external directory, set `GITNEXUS_STORAGE_ROOT`; GitNexus derives an isolated `<repo-basename>-<canonical-path-hash>/` slot beneath it for each repository. If both variables are set, `GITNEXUS_STORAGE_PATH` takes precedence. GitNexus registers the resolved slot in `~/.gitnexus/registry.json`, allowing later `status`, MCP, and `serve` commands to reopen the index without repeating the environment variable. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly.
|
||||
|
||||
**Worktrees share one index store.** When a repository has linked worktrees (`git worktree add`), the main checkout and every worktree index into one store at `~/.gitnexus/stores/<repo>/` instead of each keeping a full `.gitnexus/`. Checkouts at the same commit with no local changes read one shared, read-only graph: one copy on disk and one open database in MCP. A checkout with uncommitted changes gets its own graph, copied from the nearest shared graph and updated incrementally rather than rebuilt. Parse caches are shared too. Each worktree keeps a small `.gitnexus/store.json` pointer, and an index it had before sharing is left in place; `gitnexus status` reports it and `gitnexus clean --local-index --force` removes it. `gitnexus clean` in one worktree removes only that worktree's slot and any shared graph no other checkout uses; `gitnexus clean --gc` also drops slots whose worktree was deleted. Independent clones of one repository share too: when another registered clone has the same `origin` URL, `gitnexus analyze` in a clone joins that clone's store (or starts one the other clone joins on its next analyze). A lone clone keeps its own `.gitnexus/`. `gitnexus analyze --share-with <name-or-path>` joins a specific checkout's store after checking the `origin` URLs match, and `--no-share` moves a clone back to its own `.gitnexus/` and keeps it out until `--share-with`. On filesystems with copy-on-write clones (APFS, btrfs, XFS) a checkout's private graph shares its unchanged pages with the shared graph on disk; elsewhere it is a full copy, and `gitnexus status` says which. Queries cannot combine two graphs, because LadybugDB reads one database per query, so a checkout with edits always has a complete graph of its own. Set `GITNEXUS_SHARED_STORE=off` (or `0`, `false`, `no`) to turn sharing off for worktrees and clones alike.
|
||||
|
||||
## Architecture diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph CLI [CLI Commands]
|
||||
Setup["gitnexus setup"]
|
||||
Analyze["gitnexus analyze"]
|
||||
Clean["gitnexus clean"]
|
||||
List["gitnexus list"]
|
||||
end
|
||||
|
||||
subgraph Registry ["~/.gitnexus/"]
|
||||
RegFile["registry.json"]
|
||||
end
|
||||
|
||||
subgraph Repos [Project Repos]
|
||||
RepoA[".gitnexus/ in repo A"]
|
||||
RepoB[".gitnexus/ in repo B"]
|
||||
end
|
||||
|
||||
subgraph MCP [MCP Server]
|
||||
Server["server.ts"]
|
||||
Backend["LocalBackend"]
|
||||
Pool["Connection Pool"]
|
||||
ConnA["LadybugDB conn A"]
|
||||
ConnB["LadybugDB conn B"]
|
||||
end
|
||||
|
||||
Setup -->|"writes global MCP config"| CursorConfig["~/.cursor/mcp.json"]
|
||||
Analyze -->|"registers repo"| RegFile
|
||||
Analyze -->|"stores index"| RepoA
|
||||
Clean -->|"unregisters repo"| RegFile
|
||||
List -->|"reads"| RegFile
|
||||
Server -->|"reads registry"| RegFile
|
||||
Server --> Backend
|
||||
Backend --> Pool
|
||||
Pool -->|"lazy open"| ConnA
|
||||
Pool -->|"lazy open"| ConnB
|
||||
ConnA -->|"queries"| RepoA
|
||||
ConnB -->|"queries"| RepoB
|
||||
```
|
||||
182
docs/deployment.md
Normal file
182
docs/deployment.md
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# Deployment
|
||||
|
||||
[Back to GitNexus](../README.md)
|
||||
|
||||
## Deploy to Render
|
||||
|
||||
Deploy GitNexus in one click:
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/abhigyanpatwari/GitNexus)
|
||||
|
||||
The Blueprint creates two services. `gitnexus-server` runs `gitnexus serve` as a private service: no public URL, reachable only over Render's private network, with a persistent disk for indexes and cloned repos. `gitnexus-web` is the public one. It serves the UI and reverse-proxies `/api/*` to the server, so the browser talks to a single origin.
|
||||
|
||||
At the Blueprint's defaults this runs about **$35/month**: $25 for the server's `standard` instance, $7 for the web service's `starter` instance, and $2.50 for the 10 GB disk. See [Render's pricing](https://render.com/pricing) for other plans.
|
||||
|
||||
The deploy generates an access token, and the UI asks for it on first use:
|
||||
|
||||
1. Open the `gitnexus-web` service in your [Render dashboard](https://dashboard.render.com/).
|
||||
2. Copy `GITNEXUS_SERVE_AUTH_TOKEN` from its **Environment** tab.
|
||||
3. Load the site and paste the token into the prompt (or the settings panel).
|
||||
|
||||
Every `/api/*` request carries that token as a header, and the proxy answers `401` without it. The browser keeps it in `sessionStorage`, so a new tab asks again. To rotate it, edit the environment variable and redeploy.
|
||||
|
||||
The proxy strips `Origin` before forwarding, so the server's CSRF guard does nothing for proxied traffic; it passes `Origin`-less requests through by design. The token is the only control on this deploy, not a second layer behind the guard. Anyone holding it can read every indexed repo. See [SECURITY.md](../SECURITY.md#hosted-deploys-on-render).
|
||||
|
||||
Indexing is memory-bound. If `gitnexus-server` runs out of memory on a large repo, raise its `plan`, which sets available RAM: `standard` is 2 GB, `pro` is 4 GB. Raise `sizeGB` only if the disk fills with clones and indexes.
|
||||
|
||||
## Deploy to RepoCloud
|
||||
|
||||
[](https://repocloud.io/details/gitnexus/)
|
||||
|
||||
## Docker
|
||||
|
||||
Run Compose commands from the repository root.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts the server on `http://localhost:4747` and the web UI on `http://localhost:4173`. The UI auto-detects the server because the browser runs on the host and reaches the container via the mapped port.
|
||||
|
||||
The official setup ships **two signed images**, published identically to **GitHub Container Registry** (GHCR) and **Docker Hub** — same build, same digest, same Cosign signature:
|
||||
|
||||
| Purpose | GHCR (default in `docker-compose.yaml`) | Docker Hub mirror |
|
||||
| ---------------------------------------------------------------------- | --------------------------------------------- | ------------------------------ |
|
||||
| CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) | `ghcr.io/abhigyanpatwari/gitnexus:latest` | `akonlabs/gitnexus:latest` |
|
||||
| Static web UI (port `4173`) | `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | `akonlabs/gitnexus-web:latest` |
|
||||
|
||||
A named volume (`gitnexus-data`) persists the global registry, indexes, and cloned repos at `/data/gitnexus` inside the server container. To make repos on your host machine indexable, set `WORKSPACE_DIR` before bringing the stack up:
|
||||
|
||||
```bash
|
||||
WORKSPACE_DIR=$HOME/code docker compose up -d
|
||||
# Inside the server container the directory is mounted read-only at /workspace.
|
||||
docker compose exec gitnexus-server gitnexus index /workspace/my-repo
|
||||
```
|
||||
|
||||
> **Heads-up — image rename.** Earlier releases published the web UI under `ghcr.io/abhigyanpatwari/gitnexus`. That slug now hosts the CLI/server image and the UI moved to `ghcr.io/abhigyanpatwari/gitnexus-web`. Previous tags remain pullable, but new versions are only published under the new slugs — update your `docker run` / compose files (or just adopt the bundled compose).
|
||||
|
||||
## Direct docker run & env file
|
||||
|
||||
```bash
|
||||
# Server
|
||||
docker run --rm -d \
|
||||
--name gitnexus-server \
|
||||
-p 4747:4747 \
|
||||
-v gitnexus-data:/data/gitnexus \
|
||||
ghcr.io/abhigyanpatwari/gitnexus:latest
|
||||
|
||||
# Web UI
|
||||
docker run --rm -d \
|
||||
--name gitnexus-web \
|
||||
-p 4173:4173 \
|
||||
ghcr.io/abhigyanpatwari/gitnexus-web:latest
|
||||
```
|
||||
|
||||
Optional env file (override image tags, container names, ports, workspace dir):
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose --env-file .env up -d
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Same-origin API behind a reverse proxy</strong></summary>
|
||||
|
||||
`GITNEXUS_BACKEND_URL` is normally an absolute URL, which ties the UI to one API hostname. Set it to `/` instead and the browser calls the API on the origin that served the page — so one `gitnexus-web` container can be reached on several hostnames (an internal `*.localhost` name and a public one, say) as long as `/api/*` is served next to the UI on each of them. A path such as `/gitnexus` works the same way for an API mounted under a prefix.
|
||||
|
||||
`/api/*` goes through the web container's own proxy: give `gitnexus-web` `GITNEXUS_UPSTREAM_URL=http://gitnexus-server:4747` and a `GITNEXUS_SERVE_AUTH_TOKEN`; it forwards `/api/*` to the server and ends the browser origin there, so the server's CORS allowlist never sees the public hostnames. The UI asks for the token once. With Traefik, one router for the whole UI is enough:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gitnexus-web:
|
||||
environment:
|
||||
- GITNEXUS_BACKEND_URL=/
|
||||
- GITNEXUS_UPSTREAM_URL=http://gitnexus-server:4747
|
||||
- GITNEXUS_SERVE_AUTH_TOKEN=${GITNEXUS_SERVE_AUTH_TOKEN}
|
||||
labels:
|
||||
- traefik.http.routers.gitnexus.rule=Host(`gitnexus.example.com`) || Host(`gitnexus.localhost`)
|
||||
- traefik.http.services.gitnexus.loadbalancer.server.port=4173
|
||||
```
|
||||
|
||||
Routing `/api/*` straight to `gitnexus serve` on a public hostname is not supported: `serve` has no authentication of its own yet, and it refuses to start with `GITNEXUS_PUBLIC_ORIGIN` set until it does. Reach it through the proxy above.
|
||||
|
||||
</details>
|
||||
|
||||
Files:
|
||||
|
||||
- [Dockerfile.web](../Dockerfile.web) — builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend.
|
||||
- [Dockerfile.cli](../Dockerfile.cli) — builds the CLI/server (with its native deps) and runs `gitnexus serve --host 0.0.0.0`. Local embeddings are **not** in the image (`onnxruntime-node` is opt-in; runtime npm is stripped). Bind-mount a prefix or set `GITNEXUS_EMBEDDING_URL`.
|
||||
- [docker-compose.yaml](../docker-compose.yaml) — starts both signed images side by side.
|
||||
- [.env.example](../.env.example) — overrides for image names, container names, ports, and the workspace mount.
|
||||
|
||||
## Versioning & supply-chain protection (Cosign signatures, provenance, Kubernetes admission policy)
|
||||
|
||||
The Docker images are version-locked to the npm package:
|
||||
|
||||
- Stable images are **only published from `vX.Y.Z` git tags** (via `docker.yml` triggered directly by the tag push), and the workflow refuses to build unless the tag exactly matches `gitnexus/package.json`'s version. So `ghcr.io/abhigyanpatwari/gitnexus:1.6.2` (and its Docker Hub mirror `akonlabs/gitnexus:1.6.2`) is byte-for-byte the same release as `npm install gitnexus@1.6.2` — no drift, no floating builds from `main`. Both registries receive the same digest from a single build step, so you can pull from either and the signature verifies identically.
|
||||
- Release-candidate images (e.g. `:1.7.0-rc.1`) are published alongside each RC npm release. They are built by `publish.yml` calling `docker.yml` as a reusable workflow after the RC tag is created and pushed.
|
||||
- `:latest` is auto-promoted only from non-prerelease tags by the Docker metadata action, so it always points at a real, npm-published version.
|
||||
|
||||
Both images are signed with [Cosign keyless signing][cosign-keyless] using the workflow's GitHub OIDC identity, and shipped with build provenance and SBOM attestations. **This is your protection against supply-chain attacks**: even if an attacker republishes a same-named image elsewhere (or somehow pushes to a typo-squatted registry), they cannot forge a Cosign signature tied to `abhigyanpatwari/GitNexus`'s `docker.yml`. Always verify before pulling into sensitive environments.
|
||||
|
||||
**Stable releases** — signed from the `v*` tag ref:
|
||||
|
||||
```bash
|
||||
cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \
|
||||
--certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||
|
||||
# Same signature verifies the Docker Hub mirror (identical digest):
|
||||
cosign verify docker.io/akonlabs/gitnexus:1.6.2 \
|
||||
--certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||
```
|
||||
|
||||
The regex pins the certificate identity to this repo's `docker.yml` workflow **run from a `v*` tag** — rejecting unsigned images, images signed by other workflows, and images signed from unprotected refs. It is identical for both registries because both sets of tags were signed at the same digest in one workflow run.
|
||||
|
||||
**Release candidates** — signed from `refs/heads/main` (the caller's ref when `publish.yml` invokes `docker.yml` as a reusable workflow):
|
||||
|
||||
```bash
|
||||
cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1 \
|
||||
--certificate-identity 'https://github.com/abhigyanpatwari/GitNexus/.github/workflows/docker.yml@refs/heads/main' \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
||||
```
|
||||
|
||||
You can also inspect the build provenance and SBOM:
|
||||
|
||||
```bash
|
||||
cosign download attestation ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \
|
||||
--predicate-type https://slsa.dev/provenance/v1
|
||||
```
|
||||
|
||||
**Kubernetes: enforce signatures at admission.** Ship the bundled [`ClusterImagePolicy`](../deploy/kubernetes/cluster-image-policy.yaml) so the [Sigstore policy-controller][policy-controller] rejects any GitNexus pod whose image is not signed by this repo's `docker.yml` running from a `vX.Y.Z` tag — the same identity the `cosign verify` snippet above pins.
|
||||
|
||||
```bash
|
||||
# 1. Install the controller (one-time, cluster-wide)
|
||||
helm repo add sigstore https://sigstore.github.io/helm-charts && helm repo update
|
||||
helm install policy-controller -n cosign-system --create-namespace \
|
||||
sigstore/policy-controller
|
||||
|
||||
# 2. Opt your namespace in
|
||||
kubectl label namespace <your-ns> policy.sigstore.dev/include=true
|
||||
|
||||
# 3. Apply the policy
|
||||
kubectl apply -f deploy/kubernetes/cluster-image-policy.yaml
|
||||
```
|
||||
|
||||
After this, attempting to deploy an unsigned image — or one signed by anything other than `abhigyanpatwari/GitNexus`'s `docker.yml` at a `v*` tag — fails the admission webhook before a pod is ever created. This turns the verifiable signature into an enforced policy, which is the supply-chain control most clusters actually need.
|
||||
|
||||
[cosign-keyless]: https://docs.sigstore.dev/cosign/signing/overview/
|
||||
[policy-controller]: https://docs.sigstore.dev/policy-controller/overview/
|
||||
|
||||
## Run the frontend locally
|
||||
|
||||
```bash
|
||||
git clone https://github.com/abhigyanpatwari/gitnexus.git
|
||||
cd gitnexus/gitnexus-web && npm install
|
||||
# Compile sibling gitnexus-shared with this package's TypeScript 7 (do not npm ci shared).
|
||||
cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js
|
||||
cd ../gitnexus-web && npm run dev
|
||||
# Then in another terminal, start the backend the frontend connects to:
|
||||
npx gitnexus@latest serve
|
||||
```
|
||||
170
docs/editor-setup.md
Normal file
170
docs/editor-setup.md
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# Editor setup
|
||||
|
||||
[Back to GitNexus](../README.md)
|
||||
|
||||
## Automatic setup
|
||||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. Run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list, e.g. `gitnexus setup -c cursor,codex`.
|
||||
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| ------------------------ | --- | ------ | ----------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** |
|
||||
| **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** |
|
||||
| **Factory** (Droid) | Yes | Yes | Yes (PostToolUse, [plugin](../gitnexus-factory-plugin/)) | **Full** |
|
||||
| **OpenCode** | Yes | Yes | Yes (`tool.execute.after`, [plugin](https://opencode.ai/docs/plugins/)) | **Full** |
|
||||
| **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
|
||||
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
|
||||
> **Full** means MCP tools + agent skills + hooks that enrich searches with graph context. **Claude Code** and **Codex** go deepest: their PreToolUse hooks enrich the search before it runs, and their PostToolUse hooks also detect a stale index after commits and prompt the agent to reindex. **Cursor**, **Antigravity**, and **Factory** augment from a post-tool hook only, so they enrich the result rather than the query and do not carry the stale-index hint.
|
||||
|
||||
OpenCode's plugin uses the v1 `tool.execute.after` API: it enriches search results and adds a stale-index hint after git mutations. The integration is verified with OpenCode 1.19.5; OpenCode v2 uses a different plugin API.
|
||||
|
||||
For Bash tools, the hooks recognize literal commands and known options; ambiguous shell constructs are skipped. Stale-index hints concern the current OpenCode project.
|
||||
|
||||
Bash search augmentation requires `rg` or `grep` to be the first command. A search preceded by `cd`, such as `cd src && rg UserService .`, runs normally but receives no graph augmentation. Use a direct search command or OpenCode's `grep`/`glob` tools to get graph context.
|
||||
|
||||
<a id="fn-antigravity-hooks"></a>
|
||||
|
||||
> ¹ **Antigravity hooks** follow the [Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/) (Antigravity 2.0 is the documented successor to Gemini CLI). Augmentation runs in `AfterTool` because `BeforeTool` has no context-injection channel in the Gemini contract — the agent sees graph context appended to the tool result via `hookSpecificOutput.additionalContext`. Stale-index hints land in the same channel after a successful `git commit/merge/rebase/cherry-pick/pull`. The schema may evolve if Antigravity-specific hook docs diverge from Gemini CLI's; the implementation will track those changes.
|
||||
|
||||
## Manual MCP configuration (if you prefer not to run gitnexus setup)
|
||||
|
||||
**Claude Code** (full support — MCP + skills + hooks):
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
claude mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
|
||||
# Windows
|
||||
claude mcp add gitnexus -- cmd /c npx -y gitnexus@latest mcp
|
||||
```
|
||||
|
||||
**Codex** (full support — MCP + skills + hooks):
|
||||
|
||||
```bash
|
||||
codex mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
```
|
||||
|
||||
Or via `~/.codex/config.toml` (system scope) / `.codex/config.toml` (project scope):
|
||||
|
||||
```toml
|
||||
[mcp_servers.gitnexus]
|
||||
command = "npx"
|
||||
args = ["-y", "gitnexus@latest", "mcp"]
|
||||
```
|
||||
|
||||
Codex hooks (PreToolUse graph enrichment + PostToolUse stale-index detection in `~/.codex/hooks.json`, [same schema as Claude Code](https://developers.openai.com/codex/hooks)) need the bundled adapter script, so they are installed by `gitnexus setup -c codex` rather than manually.
|
||||
|
||||
Alternatively, install everything as a [Codex plugin](https://developers.openai.com/codex/plugins/build) (MCP + skills + hooks in one step):
|
||||
|
||||
```bash
|
||||
codex plugin marketplace add abhigyanpatwari/GitNexus
|
||||
# then inside Codex: /plugins → install "GitNexus"
|
||||
```
|
||||
|
||||
> **Codex notes:** SessionStart is intentionally not registered — Codex reads [AGENTS.md natively](https://developers.openai.com/codex/guides/agents-md), which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via `/hooks` before they run. Pick **one** install route (`gitnexus setup -c codex` **or** the plugin): plugin hooks load alongside `~/.codex/hooks.json`, so installing both can fire duplicate hooks per tool call.
|
||||
|
||||
**Factory** (Droid) — MCP + skills via `gitnexus setup -c droid`, or add the server manually to `~/.factory/mcp.json` ([user scope](https://docs.factory.ai/cli/configuration/mcp), applies to all projects):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`gitnexus setup -c droid` also installs skills to `~/.factory/skills/`. For the PostToolUse search-augment hook, install the bundled [`gitnexus-factory-plugin/`](../gitnexus-factory-plugin/) — from a marketplace that includes this repo, run `droid plugin install gitnexus@<marketplace>`, or point Droid at it via `extraKnownMarketplaces` in `.factory/settings.json`. Factory reads [`AGENTS.md` natively](https://docs.factory.ai/), which already carries the GitNexus context block.
|
||||
|
||||
**Cursor** (`~/.cursor/mcp.json` — global, works for all projects):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Antigravity** (Google) — `~/.gemini/antigravity/mcp_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `gitnexus setup` also merges an `AfterTool` entry into `~/.gemini/settings.json` (under the canonical [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) and installs skills to `~/.gemini/antigravity/skills/`. Existing user hooks are preserved. The hook adapter's path is rewritten at install time, so run `gitnexus setup` rather than hand-editing.
|
||||
|
||||
**OpenCode** (`~/.config/opencode/opencode.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"gitnexus": {
|
||||
"type": "local",
|
||||
"command": ["gitnexus", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run `gitnexus setup -c opencode` to also install skills and the auto-loaded plugin at `~/.config/opencode/plugins/gitnexus.js`. Restart OpenCode after setup. The plugin runs GitNexus asynchronously with the interpreter captured during setup, enriches `grep`/`glob`/`bash` searches, and suggests `gitnexus analyze` after a git mutation leaves the index stale. Rerun setup after moving or reinstalling GitNexus. `gitnexus uninstall --force` removes this plugin while preserving sibling plugins.
|
||||
|
||||
**CodeBuddy** (Tencent) — priority chain, edit the **first non-empty file that exists**: `~/.codebuddy/.mcp.json` (recommended) → `~/.codebuddy/mcp.json` (deprecated) → `~/.codebuddy.json` (legacy). CodeBuddy reads only the first existing file, so adding servers to a higher-priority file than the one currently in use would hide the servers below it. Create `~/.codebuddy/.mcp.json` only if none exist:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Qoder** (Alibaba) — `~/.qoder.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Install problems? npm 11 crash · slow cold install · no C++ toolchain
|
||||
|
||||
> **On npm 11.x?** `npx` can crash during install with `Cannot destructure property 'package' of 'node.target'` (an npm/arborist bug, before GitNexus runs). Use pnpm instead — it builds the native deps explicitly:
|
||||
>
|
||||
> ```bash
|
||||
> pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze
|
||||
> ```
|
||||
>
|
||||
> Or install globally (`npm install -g gitnexus@latest`) and run `gitnexus analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
|
||||
> **Fastest MCP startup:** install globally (`npm i -g gitnexus`) before running `gitnexus setup` — this writes an absolute-path MCP config that bypasses `npx` entirely. On a cold cache, an `npx`-based MCP install can exceed Claude Code's `MCP_TIMEOUT` default (~30s).
|
||||
|
||||
> **No C++ toolchain?** Vendored grammars use committed prebuilds when one matches the platform. Source-build fallback needs `python3`, `make` and `g++`. Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip Dart, Proto, Swift, Kotlin and Zig grammar setup. The C grammar remains required. Only the exact value `1` enables this option; it does not skip other native dependencies.
|
||||
|
||||
> **Local embeddings are opt-in.** Default `npm install` does not fetch `@huggingface/transformers` or `onnxruntime-node`. Run `gitnexus embeddings install` (or `gitnexus analyze --embeddings`, which auto-heals) to fetch the stack through your npm registry config into `~/.gitnexus/embedding-runtime`. CUDA GPU binaries still use NuGet via `--cuda` ([#2370](https://github.com/abhigyanpatwari/GitNexus/issues/2370)). The prefix needs Node with `module.registerHooks` (≥ 22.15 on 22.x, ≥ 23.5 on 23.x). A leftover 1.6.12 package-first tree is residual until a clean reinstall; `--force` only refreshes prefix overrides.
|
||||
|
||||
> **About `tree-sitter-kotlin`:** like Dart/Proto/Swift, Kotlin is a **vendored** grammar (under `gitnexus/vendor/tree-sitter-kotlin`). Upstream ships **source only** (no prebuilt binaries), so GitNexus cross-builds the platform prebuilds itself (via the `build-tree-sitter-prebuilds` GitHub Actions workflow) and vendors them — the same uniform pipeline used for Dart, Proto, and Swift. `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.
|
||||
93
docs/languages/README.md
Normal file
93
docs/languages/README.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Language Ingestion Gap Inventories
|
||||
|
||||
These inventories are the starting point for language-specific plans. There is one
|
||||
`<language>-ingestion-gaps.md` file for each of the 18 languages in
|
||||
[SCOPE_RESOLVERS](../../gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts).
|
||||
Names match provider directories; `jvm` is shared infrastructure, not another language.
|
||||
|
||||
Freshly audited on 2026-10-23 with production sources at `c35806d24` and the
|
||||
accompanying benchmark changes. The [measurement receipt](receiver-resolution-audit.md)
|
||||
records the commands, exact-target checks, newly executed exclusions and test limits.
|
||||
The [baseline](../../gitnexus/bench/receiver-resolution/baseline.json) now covers
|
||||
110 executable cases and 58 explicit N/A declarations. Parsing, imports, visibility,
|
||||
inheritance and incremental behavior still need separate evidence; these fixed
|
||||
receiver examples do not establish whole-language, CFG/PDG or rename completeness.
|
||||
|
||||
## Inventories
|
||||
|
||||
| Language | First planning candidate |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| [C](c-ingestion-gaps.md) | Reproduce header ownership separately from pointer dispatch. |
|
||||
| [COBOL](cobol-ingestion-gaps.md) | Measure procedure-pointer and structural-call paths separately. |
|
||||
| [C++](cpp-ingestion-gaps.md) | Substitute explicit template arguments into proven return types. |
|
||||
| [C#](csharp-ingestion-gaps.md) | Trace typed array receivers and qualified nullable fields. |
|
||||
| [Dart](dart-ingestion-gaps.md) | Correct awaited-call ownership; trace indexed and generic receivers. |
|
||||
| [Go](go-ingestion-gaps.md) | Expand ownership coverage beyond the passing receiver matrix. |
|
||||
| [Java](java-ingestion-gaps.md) | Trace array-element receiver typing. |
|
||||
| [JavaScript](javascript-ingestion-gaps.md) | Preserve JSDoc container evidence for indexed receivers. |
|
||||
| [Kotlin](kotlin-ingestion-gaps.md) | Inventory companion and inherited-receiver limits. |
|
||||
| [Objective-C](objective-c-ingestion-gaps.md) | Add receiver measurements without weakening dynamic-dispatch boundaries. |
|
||||
| [PHP](php-ingestion-gaps.md) | Retain PHPDoc container types and establish namespace owners. |
|
||||
| [Python](python-ingestion-gaps.md) | Audit constructor-field and module ownership coverage. |
|
||||
| [Ruby](ruby-ingestion-gaps.md) | Isolate YARD parameter bindings before adding recall. |
|
||||
| [Rust](rust-ingestion-gaps.md) | Measure conservative smart-pointer limits before widening them. |
|
||||
| [Swift](swift-ingestion-gaps.md) | Type indexed receivers, then revalidate historical reports. |
|
||||
| [TypeScript](typescript-ingestion-gaps.md) | Revalidate deferred narrowing contracts. |
|
||||
| [Vue](vue-ingestion-gaps.md) | Type indexed receivers in SFC script blocks. |
|
||||
| [Zig](zig-ingestion-gaps.md) | Add matrix coverage for the delivered receiver forms. |
|
||||
|
||||
## Reading the evidence
|
||||
|
||||
| Label | Meaning |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Measured gap | A checked-in receiver cell lacks its call edge. Reproduce on the chosen base before fixing it. |
|
||||
| Recorded gap / boundary | The baseline notes, source contract or dedicated tests describe a limitation; it may be outside the shape matrix. |
|
||||
| Negative control / deliberate limit | An unresolved result protects precision. Do not make it resolve without new evidence of the exact target. |
|
||||
| Coverage gap / audit | A measurement is absent or coverage needs inventory. This does not establish a product bug. |
|
||||
| Historical reports | Older claims still need current positive and negative reproductions. |
|
||||
|
||||
The receiver harness has separate states:
|
||||
|
||||
- **RESOLVES:** the terminal call reaches its full expected declaration ID, with no same-named edge to a different owner. This proves only the supplied fixture.
|
||||
- **WRONG-TARGET:** at least one same-named call reaches another declaration, including any guessed edge from an untyped control.
|
||||
- **VISIBLE-GAP:** no matching edge, with a recorded receiver-unresolved drop. Some cells intentionally represent untyped negative controls.
|
||||
- **INVISIBLE-GAP:** neither the edge nor the receiver drop is recorded; drop counts cannot reveal this loss.
|
||||
- **N/A:** the harness declares this spelling inapplicable to its measurement. Read its reason before interpreting language support.
|
||||
- **GRAMMAR-UNAVAILABLE:** the parser did not load. No behavior was measured; this is not a product-gap verdict.
|
||||
|
||||
An all-N/A row (C or COBOL) does not mean complete ingestion. Vue now has an executable SFC row. Zig and
|
||||
Objective-C have dedicated tests but no row in this shape arm. A fully resolving
|
||||
row also proves only its fixed examples. The baseline's count arm includes
|
||||
external and unknown receivers, so total drops are not an actionable bug count.
|
||||
|
||||
## Turning an inventory into a plan
|
||||
|
||||
1. Select a stable gap ID and pin the implementation base. Record a minimal source
|
||||
example, expected full target identity, observed edge/drop and relevant tests.
|
||||
2. Add a meaningful negative: a same-name decoy, unknown type, shadowed import or
|
||||
ambiguous owner. Keep intentional untyped controls unresolved.
|
||||
3. Locate the missing fact: parsing/capture, type extraction, declaration ownership,
|
||||
import binding, receiver folding or dispatch. Keep language rules in providers
|
||||
and shared resolution algorithms language-agnostic.
|
||||
4. Write the plan under `docs/plans/`, linking back to the gap ID. Define the smallest
|
||||
contract change, affected tests and corpus/benchmark comparisons. Follow the
|
||||
repository's impact-analysis and pre-commit change-analysis requirements.
|
||||
5. Run focused regressions and the receiver check before updating expectations.
|
||||
The [harness](../../gitnexus/bench/receiver-resolution/measure.mjs) documents build
|
||||
and cache requirements. Record grammar skips and measurement limits explicitly.
|
||||
6. Update the inventory with the merged PR, verified source revision and evidence.
|
||||
Close the specific item without marking the whole language complete.
|
||||
|
||||
Keep historical reports separate from measured gaps. When adding a registered
|
||||
language, add its inventory here; when refreshing a snapshot, update its source pin
|
||||
and explain intended baseline changes. Do not copy old green results as fresh runs.
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Receiver measurement audit](receiver-resolution-audit.md): fresh results and corrected classifications.
|
||||
|
||||
- [Type Resolution System](../../type-resolution-system.md): active layers and lifecycle.
|
||||
- [Type Resolution Roadmap](../../type-resolution-roadmap.md): planned work and acceptance criteria.
|
||||
- [Architecture](../../ARCHITECTURE.md#scope-resolution-pipeline-rfc-909-ring-3): provider contract and shared pipeline.
|
||||
- [Objective-C provider guide](objective-c-provider.md): supported static behavior and runtime limits.
|
||||
- [Local import scoping](local-import-scoping.md) and [Jupyter notebooks](jupyter-notebook.md).
|
||||
47
docs/languages/c-ingestion-gaps.md
Normal file
47
docs/languages/c-ingestion-gaps.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# C Ingestion Gaps
|
||||
|
||||
Audited on 2026-10-23 with freshly built production sources at `c35806d24` and
|
||||
the accompanying benchmark changes. See the [measurement audit](receiver-resolution-audit.md)
|
||||
for commands, target checks, exclusions and test limits. Historical reports below
|
||||
remain separate from freshly measured receiver cases; broader ingestion is not
|
||||
exhaustively assessed.
|
||||
|
||||
## Receiver shape snapshot
|
||||
|
||||
States below come from [baseline.json](../../gitnexus/bench/receiver-resolution/baseline.json).
|
||||
The [harness](../../gitnexus/bench/receiver-resolution/measure.mjs) defines each
|
||||
spelling and its N/A reason. RESOLVES requires the exact expected declaration ID
|
||||
and no same-named call to a different owner. WRONG-TARGET records incorrect edges.
|
||||
|
||||
| Shape | Recorded state | Expression or exclusion |
|
||||
| ----------------------- | -------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `plainChain` | N/A | C has no method call on a returned value; dispatch is through struct function-pointer fields only. |
|
||||
| `plainDeepChain` | N/A | `u->address->save()`: dispatch through a function-pointer field has no static callee in the graph. |
|
||||
| `optionalChain` | N/A | C has no optional-chaining operator. |
|
||||
| `nonNullAssert` | N/A | C has no non-null assertion operator. |
|
||||
| `awaitParen` | N/A | C has no await expression. |
|
||||
| `explicitTypeArgs` | N/A | C has no generics. |
|
||||
| `indexElement` | N/A | `repos[0].save()`: dispatch through a function-pointer field has no static callee in the graph. |
|
||||
| `fieldReceiverCall` | N/A | `u->save()`: dispatch through a function-pointer field has no static callee in the graph. |
|
||||
| `decoratedReceiverBase` | N/A | C has no method receiver, so there is no receiver decoration. |
|
||||
| `decoratedFieldType` | N/A | A C struct field is a function pointer, not a typed receiver whose decoration could be stripped. |
|
||||
|
||||
All receiver-shape cells are N/A. The benchmark calls through function-pointer fields with no static callee node; moving the structs into `main.c` did not create a named dispatch target. This does not measure ordinary C calls or establish complete C ingestion.
|
||||
|
||||
## Planning candidates
|
||||
|
||||
| ID | Evidence | Gap or boundary | Next planning step and acceptance |
|
||||
| ---- | ------------ | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| C-01 | Recorded gap | The baseline reports C structs in `.h` files being owned by the C++ provider. | Reproduce with a C translation unit and header, then define ownership using source context. Preserve C++ and Objective-C header classification controls. |
|
||||
| C-02 | Coverage gap | The all-N/A receiver row does not exercise direct calls, linkage or header imports. | Add language-appropriate cases with exact targets and duplicate static names across files. Specify a callable-value target contract before attempting function-pointer field dispatch. |
|
||||
|
||||
## Code and regression entry points
|
||||
|
||||
- [Language provider directory](../../gitnexus/src/core/ingestion/languages/c/).
|
||||
- [Scope resolver](../../gitnexus/src/core/ingestion/languages/c/scope-resolver.ts).
|
||||
- [c-coverage.test.ts](../../gitnexus/test/integration/resolvers/c-coverage.test.ts).
|
||||
- [c-header-scan.test.ts](../../gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts).
|
||||
- [c-static-linkage-side-channel.test.ts](../../gitnexus/test/unit/c-static-linkage-side-channel.test.ts).
|
||||
|
||||
Evidence details: [receiver baseline notes](../../gitnexus/bench/receiver-resolution/BASELINE.md) and
|
||||
[current roadmap](../../type-resolution-roadmap.md#current-backlog).
|
||||
47
docs/languages/cobol-ingestion-gaps.md
Normal file
47
docs/languages/cobol-ingestion-gaps.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# COBOL Ingestion Gaps
|
||||
|
||||
Audited on 2026-10-23 with freshly built production sources at `c35806d24` and
|
||||
the accompanying benchmark changes. See the [measurement audit](receiver-resolution-audit.md)
|
||||
for commands, target checks, exclusions and test limits. Historical reports below
|
||||
remain separate from freshly measured receiver cases; broader ingestion is not
|
||||
exhaustively assessed.
|
||||
|
||||
## Receiver shape snapshot
|
||||
|
||||
States below come from [baseline.json](../../gitnexus/bench/receiver-resolution/baseline.json).
|
||||
The [harness](../../gitnexus/bench/receiver-resolution/measure.mjs) defines each
|
||||
spelling and its N/A reason. RESOLVES requires the exact expected declaration ID
|
||||
and no same-named call to a different owner. WRONG-TARGET records incorrect edges.
|
||||
|
||||
| Shape | Recorded state | Expression or exclusion |
|
||||
| ----------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `plainChain` | N/A | The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage. |
|
||||
| `plainDeepChain` | N/A | The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage. |
|
||||
| `optionalChain` | N/A | The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage. |
|
||||
| `nonNullAssert` | N/A | The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage. |
|
||||
| `awaitParen` | N/A | The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage. |
|
||||
| `explicitTypeArgs` | N/A | The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage. |
|
||||
| `indexElement` | N/A | The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage. |
|
||||
| `fieldReceiverCall` | N/A | The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage. |
|
||||
| `decoratedReceiverBase` | N/A | The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage. |
|
||||
| `decoratedFieldType` | N/A | The COBOL provider models structural/procedure-pointer calls, not object-oriented receiver chains; those calls have separate integration coverage. |
|
||||
|
||||
The receiver benchmark marks COBOL N/A because its emitter has no method-call receiver chains. The registered resolver uses `scopeResolutionEdgeMode: callable-flow-only`: it contributes procedure-pointer CALLS while the established COBOL processor owns structural CALLS and IMPORTS.
|
||||
|
||||
## Planning candidates
|
||||
|
||||
| ID | Evidence | Gap or boundary | Next planning step and acceptance |
|
||||
| -------- | -------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| COBOL-01 | Coverage gap | OO receiver shapes do not measure COBOL call resolution. | Inventory direct CALL, procedure-pointer, PERFORM and COPY cases against the existing suites; add a measurement only where coverage is missing. |
|
||||
| COBOL-02 | Boundary audit | Structural and callable-flow edges have separate production owners. | Compare exact target identities, qualified data names and duplicate program names. Assert neither path introduces duplicate or unjustified call edges. |
|
||||
|
||||
## Code and regression entry points
|
||||
|
||||
- [Language provider directory](../../gitnexus/src/core/ingestion/languages/cobol/).
|
||||
- [Scope resolver](../../gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts).
|
||||
- [cobol-scope.test.ts](../../gitnexus/test/integration/resolvers/cobol-scope.test.ts).
|
||||
- [cobol-parsing-coverage.test.ts](../../gitnexus/test/integration/resolvers/cobol-parsing-coverage.test.ts).
|
||||
- [cobol-pipeline-benchmark.test.ts](../../gitnexus/test/integration/cobol-pipeline-benchmark.test.ts).
|
||||
|
||||
Evidence details: [receiver baseline notes](../../gitnexus/bench/receiver-resolution/BASELINE.md) and
|
||||
[current roadmap](../../type-resolution-roadmap.md#current-backlog).
|
||||
50
docs/languages/cpp-ingestion-gaps.md
Normal file
50
docs/languages/cpp-ingestion-gaps.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# C++ Ingestion Gaps
|
||||
|
||||
Audited on 2026-10-23 with freshly built production sources at `c35806d24` and
|
||||
the accompanying benchmark changes. See the [measurement audit](receiver-resolution-audit.md)
|
||||
for commands, target checks, exclusions and test limits. Historical reports below
|
||||
remain separate from freshly measured receiver cases; broader ingestion is not
|
||||
exhaustively assessed.
|
||||
|
||||
## Receiver shape snapshot
|
||||
|
||||
States below come from [baseline.json](../../gitnexus/bench/receiver-resolution/baseline.json).
|
||||
The [harness](../../gitnexus/bench/receiver-resolution/measure.mjs) defines each
|
||||
spelling and its N/A reason. RESOLVES requires the exact expected declaration ID
|
||||
and no same-named call to a different owner. WRONG-TARGET records incorrect edges.
|
||||
|
||||
| Shape | Recorded state | Expression or exclusion |
|
||||
| ----------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `pointerArrowChain` | RESOLVES | `svc->getUser()->save();` |
|
||||
| `valueDotChain` | RESOLVES | `svc2.getUser()->save();` |
|
||||
| `plainChain` | RESOLVES | `svc->getUser()->save();` |
|
||||
| `plainDeepChain` | RESOLVES | `svc->getUser()->addr.save();` |
|
||||
| `optionalChain` | N/A | C++ has no optional-chaining operator. |
|
||||
| `nonNullAssert` | N/A | C++ has no non-null assertion operator. |
|
||||
| `awaitParen` | VISIBLE-GAP | `(co_await getUserAsync()).save();` |
|
||||
| `explicitTypeArgs` | VISIBLE-GAP | `svc->getTyped<User>()->save();` |
|
||||
| `indexElement` | RESOLVES | `repos[0].save();` |
|
||||
| `fieldReceiverCall` | RESOLVES | `this->repo.save();` |
|
||||
| `decoratedReceiverBase` | N/A | C++ `this` is implicitly a pointer with no written decoration; the pointer base is measured by pointerArrowChain. |
|
||||
| `decoratedFieldType` | RESOLVES | `this->repo->save();` |
|
||||
|
||||
The canonical `plainChain` now executes `svc->getUser()->save()` and resolves to `User.save`; the separate pointer-arrow and value-dot controls also resolve. The old N/A meant duplicate coverage, not a failed plain chain. Deep-chain, indexed and field-receiver cases also reach their exact expected owners. `explicitTypeArgs` remains a visible gap: reading `getTyped<User>()` does not generally substitute its declared return type `T` with `User`.
|
||||
|
||||
## Planning candidates
|
||||
|
||||
| ID | Evidence | Gap or boundary | Next planning step and acceptance |
|
||||
| ------ | -------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| CPP-01 | Measured gap | `explicitTypeArgs` has no call edge and records a receiver drop. | Tie substitutions to the selected declaration and explicit arguments. Test exact owners, ambiguous overloads and unknown mappings; preserve conversion-only suppression. |
|
||||
| CPP-02 | Boundary audit | Qualified factories, aliases and headers have dedicated regression suites beyond this matrix. | Retain namespace and alias ownership checks while extending templates. Evaluate header classification jointly with C rather than treating the C all-N/A row as success. |
|
||||
| CPP-03 | Measured gap | `(co_await getUserAsync()).save()` is VISIBLE-GAP despite an awaiter declaring `User await_resume()`. | Trace coroutine result typing; require User.save and retain an unknown awaiter negative. The minimal C++20 probe passed a compiler syntax check. |
|
||||
|
||||
## Code and regression entry points
|
||||
|
||||
- [Language provider directory](../../gitnexus/src/core/ingestion/languages/cpp/).
|
||||
- [Scope resolver](../../gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts).
|
||||
- [cpp-qualified-template-review.test.ts](../../gitnexus/test/integration/cpp-qualified-template-review.test.ts).
|
||||
- [cpp-qualified-factory-review.test.ts](../../gitnexus/test/integration/cpp-qualified-factory-review.test.ts).
|
||||
- [cpp-qualified-receiver-boundaries.test.ts](../../gitnexus/test/integration/cpp-qualified-receiver-boundaries.test.ts).
|
||||
|
||||
Evidence details: [receiver baseline notes](../../gitnexus/bench/receiver-resolution/BASELINE.md) and
|
||||
[current roadmap](../../type-resolution-roadmap.md#current-backlog).
|
||||
47
docs/languages/csharp-ingestion-gaps.md
Normal file
47
docs/languages/csharp-ingestion-gaps.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# C# Ingestion Gaps
|
||||
|
||||
Audited on 2026-10-23 with freshly built production sources at `c35806d24` and
|
||||
the accompanying benchmark changes. See the [measurement audit](receiver-resolution-audit.md)
|
||||
for commands, target checks, exclusions and test limits. Historical reports below
|
||||
remain separate from freshly measured receiver cases; broader ingestion is not
|
||||
exhaustively assessed.
|
||||
|
||||
## Receiver shape snapshot
|
||||
|
||||
States below come from [baseline.json](../../gitnexus/bench/receiver-resolution/baseline.json).
|
||||
The [harness](../../gitnexus/bench/receiver-resolution/measure.mjs) defines each
|
||||
spelling and its N/A reason. RESOLVES requires the exact expected declaration ID
|
||||
and no same-named call to a different owner. WRONG-TARGET records incorrect edges.
|
||||
|
||||
| Shape | Recorded state | Expression or exclusion |
|
||||
| ----------------------- | -------------- | ------------------------------------- |
|
||||
| `plainChain` | RESOLVES | `svc.GetUser().Save();` |
|
||||
| `plainDeepChain` | RESOLVES | `svc.GetUser().Address.Save();` |
|
||||
| `optionalChain` | RESOLVES | `svc?.GetUser().Save();` |
|
||||
| `nonNullAssert` | RESOLVES | `svc!.GetUser().Save();` |
|
||||
| `awaitParen` | RESOLVES | `(await svc.GetUserAsync()).Save();` |
|
||||
| `explicitTypeArgs` | RESOLVES | `svc.GetTyped<User>().Save();` |
|
||||
| `indexElement` | VISIBLE-GAP | `repos[0].Save();` |
|
||||
| `fieldReceiverCall` | RESOLVES | `this.repo.Save();` |
|
||||
| `decoratedReceiverBase` | N/A | C# `this` carries no type decoration. |
|
||||
| `decoratedFieldType` | RESOLVES | `this.repo.Save();` |
|
||||
|
||||
The matrix resolves local nullable fields and the tested chain wrappers. It still loses `repos[0].Save()` with a `User[]` parameter. The resolver already registers an `elementTypeOf` hook, so this gap needs a full capture-to-resolution trace, not an assumption that the hook is absent.
|
||||
|
||||
## Planning candidates
|
||||
|
||||
| ID | Evidence | Gap or boundary | Next planning step and acceptance |
|
||||
| --------- | ------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| CSHARP-01 | Measured gap | `indexElement` remains VISIBLE-GAP for a typed array. | Locate the lost array/type-owner evidence, then assert the exact User.Save target with a same-name decoy and an untyped/unsupported negative. |
|
||||
| CSHARP-02 | Documented boundary | Qualified nullable fields, including `Custom.List<User>?`, are refused until their written owner can be proven. | Establish namespace-aware ownership before adding support. Keep wrapper types distinct from element types and preserve external and workspace-tail collision negatives. |
|
||||
|
||||
## Code and regression entry points
|
||||
|
||||
- [Language provider directory](../../gitnexus/src/core/ingestion/languages/csharp/).
|
||||
- [Scope resolver](../../gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts).
|
||||
- [csharp-nullable-field-isolated-ownership.test.ts](../../gitnexus/test/integration/csharp-nullable-field-isolated-ownership.test.ts).
|
||||
- [csharp-nullable-field-generic-arguments.test.ts](../../gitnexus/test/integration/csharp-nullable-field-generic-arguments.test.ts).
|
||||
- [csharp-namespace-precedence-boundaries.test.ts](../../gitnexus/test/integration/csharp-namespace-precedence-boundaries.test.ts).
|
||||
|
||||
Evidence details: [receiver baseline notes](../../gitnexus/bench/receiver-resolution/BASELINE.md) and
|
||||
[current roadmap](../../type-resolution-roadmap.md#current-backlog).
|
||||
53
docs/languages/dart-ingestion-gaps.md
Normal file
53
docs/languages/dart-ingestion-gaps.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Dart Ingestion Gaps
|
||||
|
||||
Audited on 2026-10-23 with freshly built production sources at `c35806d24` and
|
||||
the accompanying benchmark changes. See the [measurement audit](receiver-resolution-audit.md)
|
||||
for commands, target checks, exclusions and test limits. Historical reports below
|
||||
remain separate from freshly measured receiver cases; broader ingestion is not
|
||||
exhaustively assessed.
|
||||
|
||||
## Receiver shape snapshot
|
||||
|
||||
States below come from [baseline.json](../../gitnexus/bench/receiver-resolution/baseline.json).
|
||||
The [harness](../../gitnexus/bench/receiver-resolution/measure.mjs) defines each
|
||||
spelling and its N/A reason. RESOLVES requires the exact expected declaration ID
|
||||
and no same-named call to a different owner. WRONG-TARGET records incorrect edges.
|
||||
|
||||
| Shape | Recorded state | Expression or exclusion |
|
||||
| ----------------------- | -------------- | --------------------------------------- |
|
||||
| `plainChain` | RESOLVES | `svc.getUser().save();` |
|
||||
| `plainDeepChain` | RESOLVES | `svc.getUser().address.save();` |
|
||||
| `optionalChain` | RESOLVES | `svc.getUser().address?.save();` |
|
||||
| `nonNullAssert` | VISIBLE-GAP | `svc.getUser().address!.save();` |
|
||||
| `awaitParen` | WRONG-TARGET | `(await svc.getUserAsync()).save();` |
|
||||
| `explicitTypeArgs` | VISIBLE-GAP | `svc.getTyped<User>().save();` |
|
||||
| `indexElement` | INVISIBLE-GAP | `repos[0].save();` |
|
||||
| `fieldReceiverCall` | RESOLVES | `this.repo.save();` |
|
||||
| `decoratedReceiverBase` | N/A | Dart `this` carries no type decoration. |
|
||||
| `decoratedFieldType` | RESOLVES | `this.repo?.save();` |
|
||||
|
||||
Typed plain, deep, optional and field chains resolve. The awaited case is WRONG-TARGET: `getUserAsync()` declares `Future<User>`, but the call reaches `Address.save` instead of `User.save`. The previous member-name-only gate incorrectly labeled this RESOLVES. The failing assertion spelling is `svc.getUser().address!.save()`; indexed access uses `List<User> repos`. The recorded statuses identify missing behavior but do not by themselves establish a root cause.
|
||||
|
||||
## Planning candidates
|
||||
|
||||
| ID | Evidence | Gap or boundary | Next planning step and acceptance |
|
||||
| ------- | --------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| DART-01 | Measured gap | `indexElement` is INVISIBLE-GAP: neither a call edge nor a receiver drop is recorded. | Trace reference-site capture, chain extraction and element typing separately. Add both a diagnostic assertion and an exact target assertion before selecting the implementation slice. |
|
||||
| DART-02 | Measured gap | `nonNullAssert` is VISIBLE-GAP. | Trace the postfix assertion through the typed field chain. Preserve working optional access and nullable-field controls; track awaited-call ownership separately in DART-04. |
|
||||
| DART-03 | Recorded boundary | Callable-return/tear-off invocation is a separate roadmap follow-up. | Create a positive case for the primary tear-off expression and an ambiguous callable negative; fixture-local direct calls alone do not prove support. |
|
||||
| DART-04 | Measured wrong target | `(await svc.getUserAsync()).save()` reaches Address.save instead of User.save. | Trace awaited return ownership before increasing recall. Assert the full User.save ID and reject the Address.save decoy; the baseline now exposes this precision defect. |
|
||||
| DART-05 | Measured gap | `svc.getTyped<User>().save()` is VISIBLE-GAP even when getTyped declares a concrete User return. | Trace explicit type-argument call capture and receiver folding. This fixture tests the call spelling, not substitution of a generic return type. |
|
||||
|
||||
## Code and regression entry points
|
||||
|
||||
- [Language provider directory](../../gitnexus/src/core/ingestion/languages/dart/).
|
||||
- [Scope resolver](../../gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts).
|
||||
- [dart-coverage.test.ts](../../gitnexus/test/integration/resolvers/dart-coverage.test.ts).
|
||||
- [dart.test.ts](../../gitnexus/test/integration/resolvers/dart.test.ts).
|
||||
- [dart-type-extractor.test.ts](../../gitnexus/test/unit/dart-type-extractor.test.ts).
|
||||
|
||||
Evidence details: [receiver baseline notes](../../gitnexus/bench/receiver-resolution/BASELINE.md) and
|
||||
[current roadmap](../../type-resolution-roadmap.md#current-backlog).
|
||||
|
||||
Dart permits explicit method type arguments; inference does not make this shape N/A.
|
||||
See the [Dart type-system reference](https://dart.dev/language/type-system#type-argument-inference).
|
||||
46
docs/languages/go-ingestion-gaps.md
Normal file
46
docs/languages/go-ingestion-gaps.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Go Ingestion Gaps
|
||||
|
||||
Audited on 2026-10-23 with freshly built production sources at `c35806d24` and
|
||||
the accompanying benchmark changes. See the [measurement audit](receiver-resolution-audit.md)
|
||||
for commands, target checks, exclusions and test limits. Historical reports below
|
||||
remain separate from freshly measured receiver cases; broader ingestion is not
|
||||
exhaustively assessed.
|
||||
|
||||
## Receiver shape snapshot
|
||||
|
||||
States below come from [baseline.json](../../gitnexus/bench/receiver-resolution/baseline.json).
|
||||
The [harness](../../gitnexus/bench/receiver-resolution/measure.mjs) defines each
|
||||
spelling and its N/A reason. RESOLVES requires the exact expected declaration ID
|
||||
and no same-named call to a different owner. WRONG-TARGET records incorrect edges.
|
||||
|
||||
| Shape | Recorded state | Expression or exclusion |
|
||||
| ----------------------- | -------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `plainChain` | RESOLVES | `svc.GetUser().Save()` |
|
||||
| `plainDeepChain` | RESOLVES | `svc.GetUser().Address.Save()` |
|
||||
| `optionalChain` | N/A | Go has no optional-chaining operator. |
|
||||
| `nonNullAssert` | N/A | Go has no non-null assertion operator. |
|
||||
| `awaitParen` | N/A | Go has no await expression; concurrency is channel-based. |
|
||||
| `explicitTypeArgs` | N/A | Go methods cannot declare type parameters, so there is no call-site type-argument spelling on a method. |
|
||||
| `indexElement` | RESOLVES | `repos[0].Save()` |
|
||||
| `fieldReceiverCall` | RESOLVES | `h.f.ValM()` |
|
||||
| `decoratedReceiverBase` | RESOLVES | `h.f.ValM()` |
|
||||
| `decoratedFieldType` | RESOLVES | `h.f.PtrM()` |
|
||||
|
||||
Every applicable cell in the checked-in row resolves, including slice indexing and decorated receiver/field types. This is a bounded receiver snapshot, not proof that every package, build variant or import-qualified spelling resolves.
|
||||
|
||||
## Planning candidates
|
||||
|
||||
| ID | Evidence | Gap or boundary | Next planning step and acceptance |
|
||||
| ----- | -------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| GO-01 | Coverage audit | No failing shape is recorded in this row; package ownership is exercised by separate tests. | Map those tests to package-qualified receivers, cross-file struct ownership and build variants. Add missing exact-owner cases before proposing a fix; independent modules with matching type names must remain isolated. |
|
||||
|
||||
## Code and regression entry points
|
||||
|
||||
- [Language provider directory](../../gitnexus/src/core/ingestion/languages/go/).
|
||||
- [Scope resolver](../../gitnexus/src/core/ingestion/languages/go/scope-resolver.ts).
|
||||
- [go-build-variant-owners.test.ts](../../gitnexus/test/integration/go-build-variant-owners.test.ts).
|
||||
- [go-cross-file-struct-owner.test.ts](../../gitnexus/test/integration/go-cross-file-struct-owner.test.ts).
|
||||
- [go-external-test-package.test.ts](../../gitnexus/test/integration/resolvers/go-external-test-package.test.ts).
|
||||
|
||||
Evidence details: [receiver baseline notes](../../gitnexus/bench/receiver-resolution/BASELINE.md) and
|
||||
[current roadmap](../../type-resolution-roadmap.md#current-backlog).
|
||||
47
docs/languages/java-ingestion-gaps.md
Normal file
47
docs/languages/java-ingestion-gaps.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Java Ingestion Gaps
|
||||
|
||||
Audited on 2026-10-23 with freshly built production sources at `c35806d24` and
|
||||
the accompanying benchmark changes. See the [measurement audit](receiver-resolution-audit.md)
|
||||
for commands, target checks, exclusions and test limits. Historical reports below
|
||||
remain separate from freshly measured receiver cases; broader ingestion is not
|
||||
exhaustively assessed.
|
||||
|
||||
## Receiver shape snapshot
|
||||
|
||||
States below come from [baseline.json](../../gitnexus/bench/receiver-resolution/baseline.json).
|
||||
The [harness](../../gitnexus/bench/receiver-resolution/measure.mjs) defines each
|
||||
spelling and its N/A reason. RESOLVES requires the exact expected declaration ID
|
||||
and no same-named call to a different owner. WRONG-TARGET records incorrect edges.
|
||||
|
||||
| Shape | Recorded state | Expression or exclusion |
|
||||
| ----------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `plainChain` | RESOLVES | `svc.getUser().save();` |
|
||||
| `plainDeepChain` | RESOLVES | `svc.getUser().address.save();` |
|
||||
| `optionalChain` | N/A | Java has no optional-chaining operator. |
|
||||
| `nonNullAssert` | N/A | Java has no non-null assertion operator. |
|
||||
| `awaitParen` | N/A | Java has no await expression in the core language. |
|
||||
| `explicitTypeArgs` | RESOLVES | `svc.<User>getTyped().save();` |
|
||||
| `indexElement` | VISIBLE-GAP | `repos[0].save();` |
|
||||
| `fieldReceiverCall` | RESOLVES | `this.repo.save();` |
|
||||
| `decoratedReceiverBase` | N/A | Java `this` carries no type decoration. |
|
||||
| `decoratedFieldType` | N/A | Java's only field-type decorations are containers (arrays, generics), which change the member set and are measured by indexElement rather than by a type-preserving strip. |
|
||||
|
||||
The matrix resolves plain/deep chains, explicit type arguments and field receivers. `repos[0].save()` with a `User[]` parameter is a visible gap. The explicit-type-argument fixture declares a concrete User return regardless of its type parameter. Its exact target passes, but it does not prove generic return substitution.
|
||||
|
||||
## Planning candidates
|
||||
|
||||
| ID | Evidence | Gap or boundary | Next planning step and acceptance |
|
||||
| ------- | -------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| JAVA-01 | Measured gap | `indexElement` has no call edge and records a receiver drop. | Trace the array annotation through type bindings and receiver folding. Assert the exact User.save declaration and retain ambiguous/import-shadowed negatives. |
|
||||
| JAVA-02 | Coverage audit | Receiver aliases and local types have separate regression coverage. | Include those ownership boundaries in the array plan; document any classpath/source-root assumptions instead of inferring compiler-grade lookup from a passing cell. |
|
||||
|
||||
## Code and regression entry points
|
||||
|
||||
- [Language provider directory](../../gitnexus/src/core/ingestion/languages/java/).
|
||||
- [Scope resolver](../../gitnexus/src/core/ingestion/languages/java/scope-resolver.ts).
|
||||
- [java-receiver-alias-boundaries-review.test.ts](../../gitnexus/test/integration/java-receiver-alias-boundaries-review.test.ts).
|
||||
- [java-javac-local-types.test.ts](../../gitnexus/test/integration/resolvers/java-javac-local-types.test.ts).
|
||||
- [java.test.ts](../../gitnexus/test/integration/resolvers/java.test.ts).
|
||||
|
||||
Evidence details: [receiver baseline notes](../../gitnexus/bench/receiver-resolution/BASELINE.md) and
|
||||
[current roadmap](../../type-resolution-roadmap.md#current-backlog).
|
||||
48
docs/languages/javascript-ingestion-gaps.md
Normal file
48
docs/languages/javascript-ingestion-gaps.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# JavaScript Ingestion Gaps
|
||||
|
||||
Audited on 2026-10-23 with freshly built production sources at `c35806d24` and
|
||||
the accompanying benchmark changes. See the [measurement audit](receiver-resolution-audit.md)
|
||||
for commands, target checks, exclusions and test limits. Historical reports below
|
||||
remain separate from freshly measured receiver cases; broader ingestion is not
|
||||
exhaustively assessed.
|
||||
|
||||
## Receiver shape snapshot
|
||||
|
||||
States below come from [baseline.json](../../gitnexus/bench/receiver-resolution/baseline.json).
|
||||
The [harness](../../gitnexus/bench/receiver-resolution/measure.mjs) defines each
|
||||
spelling and its N/A reason. RESOLVES requires the exact expected declaration ID
|
||||
and no same-named call to a different owner. WRONG-TARGET records incorrect edges.
|
||||
|
||||
| Shape | Recorded state | Expression or exclusion |
|
||||
| ----------------------- | -------------- | ----------------------------------------------------------------------------- |
|
||||
| `plainChain` | RESOLVES | `svc.getUser().save();` |
|
||||
| `untypedChain` | VISIBLE-GAP | `svc.getUser().save();` (intentional untyped control) |
|
||||
| `plainDeepChain` | RESOLVES | `svc.getUser().address.save();` |
|
||||
| `optionalChain` | RESOLVES | `svc?.getUser().save();` |
|
||||
| `nonNullAssert` | N/A | JavaScript has no non-null assertion operator. |
|
||||
| `awaitParen` | RESOLVES | `(await svc.getUserAsync()).save();` |
|
||||
| `explicitTypeArgs` | N/A | JavaScript has no type-argument syntax. |
|
||||
| `indexElement` | VISIBLE-GAP | `repos[0].save();` |
|
||||
| `fieldReceiverCall` | RESOLVES | `this.repo.save();` |
|
||||
| `decoratedReceiverBase` | N/A | JavaScript method receivers carry no type decoration. |
|
||||
| `decoratedFieldType` | N/A | JavaScript fields carry no declared type, so there is no decoration to strip. |
|
||||
|
||||
JSDoc supplies the types in the passing plain, deep, optional and awaited chains. `indexElement` remains a visible gap with `@param {User[]} repos`; JavaScript has no `ScopeResolver.elementTypeOf` hook. `untypedChain` is an intentional negative control, not a request to guess a target from its method name.
|
||||
|
||||
## Planning candidates
|
||||
|
||||
| ID | Evidence | Gap or boundary | Next planning step and acceptance |
|
||||
| ------------- | ---------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| JAVASCRIPT-01 | Measured gap | A typed array does not establish the receiver type for `repos[0].save()`. | Preserve the container/element distinction and add a provider-owned element route. Require the exact User.save target, a same-name decoy and unsupported-container negatives. |
|
||||
| JAVASCRIPT-02 | Negative control | `untypedChain` remains VISIBLE-GAP by design. | Keep the untyped receiver unresolved while adding typed coverage; resolving a same-named method elsewhere is a precision regression. |
|
||||
|
||||
## Code and regression entry points
|
||||
|
||||
- [Language provider directory](../../gitnexus/src/core/ingestion/languages/javascript/).
|
||||
- [Scope resolver](../../gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts).
|
||||
- [javascript.test.ts](../../gitnexus/test/integration/resolvers/javascript.test.ts).
|
||||
- [javascript-object-properties.test.ts](../../gitnexus/test/integration/resolvers/javascript-object-properties.test.ts).
|
||||
- [javascript-captures.test.ts](../../gitnexus/test/unit/scope-resolution/javascript/javascript-captures.test.ts).
|
||||
|
||||
Evidence details: [receiver baseline notes](../../gitnexus/bench/receiver-resolution/BASELINE.md) and
|
||||
[current roadmap](../../type-resolution-roadmap.md#current-backlog).
|
||||
47
docs/languages/kotlin-ingestion-gaps.md
Normal file
47
docs/languages/kotlin-ingestion-gaps.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Kotlin Ingestion Gaps
|
||||
|
||||
Audited on 2026-10-23 with freshly built production sources at `c35806d24` and
|
||||
the accompanying benchmark changes. See the [measurement audit](receiver-resolution-audit.md)
|
||||
for commands, target checks, exclusions and test limits. Historical reports below
|
||||
remain separate from freshly measured receiver cases; broader ingestion is not
|
||||
exhaustively assessed.
|
||||
|
||||
## Receiver shape snapshot
|
||||
|
||||
States below come from [baseline.json](../../gitnexus/bench/receiver-resolution/baseline.json).
|
||||
The [harness](../../gitnexus/bench/receiver-resolution/measure.mjs) defines each
|
||||
spelling and its N/A reason. RESOLVES requires the exact expected declaration ID
|
||||
and no same-named call to a different owner. WRONG-TARGET records incorrect edges.
|
||||
|
||||
| Shape | Recorded state | Expression or exclusion |
|
||||
| ----------------------- | -------------- | ----------------------------------------- |
|
||||
| `plainChain` | RESOLVES | `svc.getUser().save()` |
|
||||
| `plainDeepChain` | RESOLVES | `svc.getUser().address.save()` |
|
||||
| `optionalChain` | RESOLVES | `svc?.getUser()?.save()` |
|
||||
| `nonNullAssert` | RESOLVES | `svc!!.getUser().save()` |
|
||||
| `awaitParen` | RESOLVES | `(svc.getUserAsync()).save()` |
|
||||
| `explicitTypeArgs` | RESOLVES | `svc.getTyped<User>().save()` |
|
||||
| `indexElement` | RESOLVES | `repos[0].save()` |
|
||||
| `fieldReceiverCall` | RESOLVES | `this.repo.save()` |
|
||||
| `decoratedReceiverBase` | N/A | Kotlin `this` carries no type decoration. |
|
||||
| `decoratedFieldType` | RESOLVES | `this.repo?.save()` |
|
||||
|
||||
Every applicable shape in this row resolves. Separate qualified-factory controls retain conservative ownership boundaries: inherited receivers need re-established bases and canonical MRO evidence; companion fallback requires captured proof that the companion header has no supertype. This is not a complete build-system classpath model.
|
||||
|
||||
## Planning candidates
|
||||
|
||||
| ID | Evidence | Gap or boundary | Next planning step and acceptance |
|
||||
| --------- | ------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| KOTLIN-01 | Documented boundary | Complete companion inheritance is outside the delivered factory fallback. | Reproduce a bounded inherited-companion case before extending it. Keep unknown bases and shadowed factories unresolved and preserve worker/cache ownership evidence. |
|
||||
| KOTLIN-02 | Coverage audit | The passing matrix does not measure every package-qualified factory spelling. | Retain the identifier, implicit-receiver and shadow-boundary suites; identify a concrete missing case before proposing broader inference. |
|
||||
|
||||
## Code and regression entry points
|
||||
|
||||
- [Language provider directory](../../gitnexus/src/core/ingestion/languages/kotlin/).
|
||||
- [Scope resolver](../../gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts).
|
||||
- [kotlin-qualified-factory-implicit-receivers.test.ts](../../gitnexus/test/integration/kotlin-qualified-factory-implicit-receivers.test.ts).
|
||||
- [kotlin-qualified-factory-type-ownership.test.ts](../../gitnexus/test/integration/kotlin-qualified-factory-type-ownership.test.ts).
|
||||
- [kotlin-qualified-factory-identifier-review.test.ts](../../gitnexus/test/integration/kotlin-qualified-factory-identifier-review.test.ts).
|
||||
|
||||
Evidence details: [receiver baseline notes](../../gitnexus/bench/receiver-resolution/BASELINE.md) and
|
||||
[current roadmap](../../type-resolution-roadmap.md#current-backlog).
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue