Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39628adeea | ||
|
|
acda87a247 | ||
|
|
8ca25fe753 |
@@ -11,7 +11,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.6",
|
||||
"version": "1.3.3",
|
||||
"source": "./gitnexus-claude-plugin",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# GitNexus PR Reviewer Swarm — Claude Code adapter
|
||||
|
||||
This is the **Claude Code** entrypoint for the cross-CLI GitNexus PR reviewer swarm. The
|
||||
review logic itself is CLI-neutral and lives in **[`pr-swarm-review/`](../pr-swarm-review/README.md)**
|
||||
— that README is the canonical guide and covers every CLI (Claude Code, Gemini, Copilot,
|
||||
Cursor, Codex, and any AGENTS.md-aware agent).
|
||||
|
||||
## Invocation (Claude Code)
|
||||
|
||||
```
|
||||
/gitnexus-pr-swarm-review <PR URL or PR number>
|
||||
```
|
||||
|
||||
Runs in **Swarm mode**: the coordinator skill dispatches the seven `gitnexus-*` subagents in
|
||||
parallel (lanes 1–2 first, 3–6 in parallel, lane 7 last as a hard gate).
|
||||
|
||||
## Files in this adapter
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `.claude/skills/gitnexus-pr-swarm-review/SKILL.md` | Coordinator — runs Swarm mode per `pr-swarm-review/orchestration.md` |
|
||||
| `.claude/agents/gitnexus-*.md` | Seven thin subagent wrappers; each reads its canonical persona in `pr-swarm-review/personas/` |
|
||||
|
||||
Each subagent keeps valid Claude Code frontmatter (model, tools, etc.); the mechanical
|
||||
verifier lanes (`test-ci-verifier`, `branch-hygiene-reviewer`) run on Haiku, the analytical
|
||||
lanes on Sonnet.
|
||||
|
||||
## Key properties
|
||||
|
||||
- **Read-only.** Tools limited to Read/Grep/Glob/Bash, and every persona enforces an
|
||||
explicit permitted/prohibited Bash list. No agent edits files, commits, or posts.
|
||||
- **Evidence-grounded**; **missing visibility becomes verification work**; **manually invoked.**
|
||||
|
||||
## Editing
|
||||
|
||||
Edit review behavior in the canonical files under `pr-swarm-review/` (orchestration +
|
||||
personas), **not** in these wrappers. After adding or editing files in `.claude/agents/`,
|
||||
restart Claude Code so it reloads the agent definitions.
|
||||
|
||||
## Relationship to `/gitnexus-pr-review`
|
||||
|
||||
Coexists with the single-agent `/gitnexus-pr-review` skill (a linear checklist using GitNexus
|
||||
MCP tools). This swarm is the multi-persona deep production-readiness review.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: gitnexus-branch-hygiene-reviewer
|
||||
description: "GitNexus branch hygiene and mergeability reviewer. Use to classify merge state, conflicts, stale branches, merge-from-main commits, unrelated churn, mixed domains, and whether rebase or split is required."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-haiku-4-5-20251001
|
||||
maxTurns: 30
|
||||
---
|
||||
|
||||
# GitNexus Branch Hygiene & Mergeability Reviewer
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/02-branch-hygiene-reviewer.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: gitnexus-docs-dod-reviewer
|
||||
description: "GitNexus docs and Definition-of-Done reviewer. Use to translate repo guidance, linked issues, changed domains, docs requirements, release notes, and acceptance criteria into a PR-specific DoD."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-sonnet-4-6
|
||||
maxTurns: 30
|
||||
---
|
||||
|
||||
# GitNexus Docs & Definition-of-Done Reviewer
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/06-docs-dod-reviewer.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: gitnexus-pr-facts-historian
|
||||
description: "GitNexus PR facts and repository-history investigator. Use to gather PR identity, visible GitHub state, changed files, commits, linked issues, related PRs, historical fixes, regressions, stale follow-ups, and missing visibility."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-sonnet-4-6
|
||||
maxTurns: 40
|
||||
---
|
||||
|
||||
# GitNexus PR Facts & Repository-History Investigator
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/01-pr-facts-historian.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: gitnexus-risk-architect
|
||||
description: "GitNexus production-risk reviewer. Use for risk-model-first review of changed files, runtime behavior, multi-domain changes, user impact, failure modes, compatibility, and merge-blocking risk."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-sonnet-4-6
|
||||
maxTurns: 40
|
||||
---
|
||||
|
||||
# GitNexus Production-Risk Architect
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/03-risk-architect.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: gitnexus-security-boundary-reviewer
|
||||
description: "GitNexus security and trust-boundary reviewer. Use for auth, permissions, secrets, injection, unsafe parsing, external input handling, hidden Unicode, YAML/Docker/workflow risks, and suspicious non-ASCII hygiene."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-sonnet-4-6
|
||||
maxTurns: 35
|
||||
---
|
||||
|
||||
# GitNexus Security & Trust-Boundary Reviewer
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/05-security-boundary-reviewer.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: gitnexus-synthesis-critic
|
||||
description: "GitNexus final review synthesis critic. Use to check whether the final PR review is evidence-grounded, risk-prioritized, GitNexus-specific, non-generic, and follows required verdict rules."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-sonnet-4-6
|
||||
maxTurns: 25
|
||||
---
|
||||
|
||||
# GitNexus Final-Review Synthesis Critic
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/07-synthesis-critic.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: gitnexus-test-ci-verifier
|
||||
description: "GitNexus test and CI reviewer. Use to verify whether changed behavior is covered by targeted tests, whether CI actually runs those tests, and whether workflow changes weaken validation."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-haiku-4-5-20251001
|
||||
maxTurns: 35
|
||||
---
|
||||
|
||||
# GitNexus Test & CI Verifier
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/04-test-ci-verifier.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: gitnexus-pr-swarm-review
|
||||
description: "Run a GitNexus production-readiness pull request review using a coordinated reviewer swarm."
|
||||
---
|
||||
|
||||
# GitNexus PR Swarm Review (Claude Code adapter)
|
||||
|
||||
Use this skill to review a GitNexus pull request and produce a production-readiness review.
|
||||
|
||||
```
|
||||
/gitnexus-pr-swarm-review <PR URL or PR number>
|
||||
```
|
||||
|
||||
You are the **swarm coordinator**. The full review contract — lanes, dependencies,
|
||||
classifications, output structure, finding format, hidden-Unicode checks, and behavior
|
||||
rules — is the canonical, CLI-neutral spec:
|
||||
|
||||
**`pr-swarm-review/orchestration.md`** — read it now and follow it.
|
||||
|
||||
This adapter only pins the Claude Code specifics:
|
||||
|
||||
- **Run in Swarm mode.** Dispatch each lane as its own subagent via the Agent tool. The
|
||||
seven subagents are the project agents named `gitnexus-*` (one per persona); each reads
|
||||
its canonical persona under `pr-swarm-review/personas/`. Run lanes 1–2 first, lanes 3–6
|
||||
in parallel after, and lane 7 last on the draft.
|
||||
- **Lane 7 is a hard gate.** Do not emit the final review while the synthesis critic's
|
||||
"Required corrections before posting" section is non-empty — revise and re-run it.
|
||||
- Stay **read-only**: investigate and report; never edit, commit, or post.
|
||||
|
||||
Do not flatten the review into a generic checklist; delegate to the subagents and
|
||||
synthesize per `orchestration.md`.
|
||||
@@ -5,16 +5,14 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
|
||||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
|
||||
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
All commands work via `npx` — no global install required.
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
node .gitnexus/run.cjs analyze
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
@@ -23,14 +21,13 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
node .gitnexus/run.cjs status
|
||||
npx gitnexus status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
@@ -38,7 +35,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a
|
||||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
node .gitnexus/run.cjs clean
|
||||
npx gitnexus clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
@@ -51,7 +48,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
|
||||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
node .gitnexus/run.cjs wiki
|
||||
npx gitnexus wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
@@ -68,7 +65,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
|
||||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
node .gitnexus/run.cjs list
|
||||
npx gitnexus list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
||||
@@ -16,23 +16,23 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] query for error text or related code
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] context to see callers and callees
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] cypher for custom call chain traces if needed
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
@@ -40,7 +40,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `query` for error text → `context` on throw sites |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
@@ -48,24 +48,24 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
||||
|
||||
## Tools
|
||||
|
||||
**query** — find code related to error:
|
||||
**gitnexus_query** — find code related to error:
|
||||
|
||||
```
|
||||
query({query: "payment validation error"})
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**context** — full context for a suspect:
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**cypher** — custom call chain traces:
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
@@ -75,11 +75,11 @@ RETURN [n IN nodes(path) | n.name] AS chain
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. query({query: "payment error handling"})
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. context({name: "validatePayment"})
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
|
||||
@@ -18,20 +18,20 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
@@ -47,18 +47,18 @@ description: "Use when the user asks how code works, wants to understand archite
|
||||
|
||||
## Tools
|
||||
|
||||
**query** — find execution flows related to a concept:
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
query({query: "payment processing"})
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**context** — 360-degree view of a symbol:
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
context({name: "validateUser"})
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
@@ -68,10 +68,10 @@ context({name: "validateUser"})
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. query({query: "payment processing"})
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. context({name: "processPayment"})
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
|
||||
@@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
||||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
|
||||
@@ -17,22 +17,22 @@ description: "Use when the user wants to know what will break if they change som
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. detect_changes() → Map current git changes to affected flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] detect_changes() for pre-commit check
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
@@ -55,10 +55,10 @@ description: "Use when the user wants to know what will break if they change som
|
||||
|
||||
## Tools
|
||||
|
||||
**impact** — the primary tool for symbol blast radius:
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
impact({
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
@@ -73,10 +73,10 @@ impact({
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**detect_changes** — git-diff based impact analysis:
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
detect_changes({scope: "staged"})
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
@@ -86,7 +86,7 @@ detect_changes({scope: "staged"})
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. impact({target: "validateUser", direction: "upstream"})
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
|
||||
@@ -18,24 +18,24 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. context({name: "<key symbol>"}) → Understand callers/callees
|
||||
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] detect_changes to map changes to affected execution flows
|
||||
- [ ] impact on each non-trivial changed symbol
|
||||
- [ ] gitnexus_detect_changes to map changes to affected execution flows
|
||||
- [ ] gitnexus_impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] context on key changed symbols to understand full picture
|
||||
- [ ] gitnexus_context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
@@ -63,20 +63,20 @@ description: "Use when the user wants to review a pull request, understand what
|
||||
|
||||
## Tools
|
||||
|
||||
**detect_changes** — map PR diff to affected execution flows:
|
||||
**gitnexus_detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
detect_changes({scope: "compare", base_ref: "main"})
|
||||
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**impact** — blast radius per changed symbol:
|
||||
**gitnexus_impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream"})
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
@@ -86,20 +86,20 @@ impact({target: "validatePayment", direction: "upstream"})
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**impact with tests** — check test coverage:
|
||||
**gitnexus_impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**context** — understand a changed symbol's role:
|
||||
**gitnexus_context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
@@ -112,20 +112,20 @@ context({name: "validatePayment"})
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. detect_changes({scope: "compare", base_ref: "main"})
|
||||
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. impact({target: "validatePayment", direction: "upstream"})
|
||||
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. impact({target: "PaymentInput", direction: "upstream"})
|
||||
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. context({name: "formatAmount"})
|
||||
5. gitnexus_context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
|
||||
@@ -16,78 +16,78 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. query({query: "X"}) → Find execution flows involving X
|
||||
3. context({name: "X"}) → See all incoming/outgoing refs
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] context({name: target}) — understand all callees
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] detect_changes() — verify affected scope
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**rename** — automated multi-file rename:
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**impact** — map all dependents first:
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
|
||||
```
|
||||
impact({target: "validateUser", direction: "upstream"})
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**detect_changes** — verify your changes after refactoring:
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
detect_changes({scope: "all"})
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**cypher** — custom reference queries:
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
@@ -98,24 +98,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use rename for automated updates |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | query to find them |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. detect_changes({scope: "all"})
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
plans/
|
||||
@@ -1,17 +0,0 @@
|
||||
# GitNexus PR Swarm Review
|
||||
|
||||
You are the GitNexus PR review coordinator. Review the pull request named after this command
|
||||
(a PR URL or number for `https://github.com/abhigyanpatwari/GitNexus`). If none was given,
|
||||
ask for one.
|
||||
|
||||
Read `pr-swarm-review/orchestration.md` in this repository and follow it exactly — it is the
|
||||
canonical, CLI-neutral review contract (lanes, classifications, output structure, finding
|
||||
format, hidden-Unicode checks, behavior rules).
|
||||
|
||||
Run in **Solo mode**: you are a single agent, so perform all seven lanes yourself in
|
||||
dependency order, adopting each persona in `pr-swarm-review/personas/0N-*.md` in turn
|
||||
(lanes 1–2 first, then 3–6, then lane 7). Keep every lane's findings in context. Lane 7
|
||||
(synthesis critic) is a hard gate: do not emit the final review until its "Required
|
||||
corrections before posting" section is empty.
|
||||
|
||||
Stay strictly read-only: investigate and report; never edit files, commit, or post to GitHub.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# GitNexus — Cursor project rules
|
||||
|
||||
Last reviewed: 2026-03-24
|
||||
|
||||
Canonical agent instructions: **[AGENTS.md](../AGENTS.md)** (GitNexus MCP rules, monorepo commands, Cursor Cloud notes). **[CLAUDE.md](../CLAUDE.md)** adds Claude Code-specific notes and points back to AGENTS.md for GitNexus.
|
||||
|
||||
## Non-negotiables (always apply)
|
||||
|
||||
- NEVER edit a function/class/method without running `gitnexus_impact` first.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename`.
|
||||
- NEVER commit without running `gitnexus_detect_changes()`.
|
||||
- NEVER ignore HIGH/CRITICAL risk warnings from impact analysis.
|
||||
- NEVER run `npx gitnexus analyze` without `--embeddings` if `.gitnexus/meta.json` shows stored embeddings.
|
||||
|
||||
Full rules: **[AGENTS.md](../AGENTS.md)** (`gitnexus:start` block, Cursor Cloud section).
|
||||
|
||||
**Rule architecture:** Prefer this file plus optional `.cursor/rules/*.mdc` globs (YAML `globs` in frontmatter). Legacy `.cursorrules` is deprecated; content lives here.
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
globs:
|
||||
- "gitnexus/**"
|
||||
- "gitnexus-web/**"
|
||||
---
|
||||
|
||||
# GitNexus build/test quick refs
|
||||
|
||||
- CLI (`gitnexus/`): `npm test`; `npm run test:integration`; `npx tsc --noEmit`.
|
||||
- Web (`gitnexus-web/`): `npm test`; `npm run dev`; `npx tsc -b --noEmit`; `E2E=1 npx playwright test` (needs servers).
|
||||
- `npm install` in `gitnexus/` runs `prepare` (tsc build) and `postinstall` (tree-sitter patches); needs `python3`, `make`, `g++`.
|
||||
- LadybugDB locking tests may fail in containerized environments because of `/tmp` file locks (known issue, not a code bug).
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
globs:
|
||||
- "eval/**"
|
||||
---
|
||||
|
||||
# GitNexus eval harness (Python)
|
||||
|
||||
- **Run tests**: `cd eval && uv run pytest tests/`
|
||||
- **Run with coverage**: `cd eval && uv run coverage run -m pytest tests/ && uv run coverage report`
|
||||
- **Lint**: `cd eval && uv run ruff check .`
|
||||
- **Run eval**: `cd eval && uv run python run_eval.py --config configs/<config>.yaml`
|
||||
- Shared constants live in `eval/constants.py`; tool specs in `eval/tool_registry.py`.
|
||||
- Error logging uses `utils/errors.py` — set `GITNEXUS_EVAL_DEBUG=1` for full tracebacks.
|
||||
- Property-based tests use Hypothesis (`eval/tests/test_property_based.py`).
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Deprecated for Cursor Agent Mode
|
||||
# AI Agent Rules
|
||||
|
||||
Use **`.cursor/index.mdc`** (`alwaysApply: true`) for project rules. See [AGENTS.md](AGENTS.md).
|
||||
Follow .gitnexus/RULES.md for all project context and coding guidelines.
|
||||
|
||||
This file is kept only as a breadcrumb for older workflows.
|
||||
This project uses GitNexus MCP for code intelligence. See .gitnexus/RULES.md for available tools and best practices.
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Base image: Microsoft's TypeScript+Node devcontainer image. It works on both
|
||||
# linux/amd64 and linux/arm64, gets monthly security patches, and ships the
|
||||
# non-root `node` user (UID 1000, i.e. user ID 1000), zsh + Oh My Zsh, eslint
|
||||
# global, and the `gh` CLI.
|
||||
#
|
||||
# We pin the image by digest, not by tag. That way a silent upstream retag can't
|
||||
# change the build under us. This matches the Dockerfile.cli /
|
||||
# gitnexus/Dockerfile.test convention and issue #1451.
|
||||
#
|
||||
# We pin it as a bare `name@digest` with NO `:tag` prefix on purpose. The
|
||||
# production Dockerfiles use plain `docker build`, but this one is built by
|
||||
# `@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:
|
||||
# docker buildx imagetools inspect \
|
||||
# mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm \
|
||||
# --format '{{json .Manifest.Digest}}'
|
||||
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:7c2e711a4f7b02f32d2da16192d5e05aa7c95279be4ce889cff5df316f251c1d
|
||||
|
||||
# Build args. We deliberately set no version defaults here. devcontainer.json
|
||||
# `build.args` is the single source of truth for versions. A standalone
|
||||
# `docker build .devcontainer/` (for example, a CI smoke test) must pass each
|
||||
# version with --build-arg. Without a default, the build fails loudly instead of
|
||||
# silently drifting from the version pinned in devcontainer.json.
|
||||
ARG CLAUDE_CODE_VERSION
|
||||
ARG CODEX_VERSION
|
||||
# Cursor is pinned by version plus a per-arch tarball sha256 hash. The install
|
||||
# step below verifies that hash. All three values live in devcontainer.json
|
||||
# build.args. They follow the same rule as the others: one source of truth, and
|
||||
# no default so the build fails loudly if a value is missing.
|
||||
ARG CURSOR_VERSION
|
||||
ARG CURSOR_SHA256_X64
|
||||
ARG CURSOR_SHA256_ARM64
|
||||
# Bun is installed via the official remote script (bun.sh/install), pinned by
|
||||
# version. UNLIKE Cursor and the npm packages, this install path runs an
|
||||
# UNVERIFIED remote script — there is no tarball-hash check. Chosen explicitly
|
||||
# at request time over the pin-by-sha256 alternative for install-script
|
||||
# simplicity. To harden later, switch to a pinned tarball + per-arch sha256 in
|
||||
# the Cursor style (release artifacts at github.com/oven-sh/bun/releases).
|
||||
ARG BUN_VERSION
|
||||
ARG TZ=UTC
|
||||
ARG USERNAME=node
|
||||
|
||||
# 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
|
||||
# value lives in devcontainer.json `containerEnv`, and the runtime value wins
|
||||
# anyway.
|
||||
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
|
||||
CODEX_VERSION=${CODEX_VERSION} \
|
||||
CURSOR_VERSION=${CURSOR_VERSION} \
|
||||
BUN_VERSION=${BUN_VERSION} \
|
||||
BUN_INSTALL=/home/${USERNAME}/.bun \
|
||||
TZ=${TZ} \
|
||||
DEVCONTAINER=true \
|
||||
NODE_OPTIONS=--max-old-space-size=4096 \
|
||||
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 /
|
||||
# gitnexus/Dockerfile.test images.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
python3 make g++ git curl ca-certificates bash unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 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.
|
||||
RUN mkdir -p \
|
||||
/home/${USERNAME}/.npm \
|
||||
/home/${USERNAME}/.local/bin \
|
||||
/commandhistory \
|
||||
&& chown -R ${USERNAME}:${USERNAME} \
|
||||
/home/${USERNAME}/.npm \
|
||||
/home/${USERNAME}/.local \
|
||||
/commandhistory
|
||||
|
||||
USER ${USERNAME}
|
||||
|
||||
# Install Claude Code and the Codex CLI globally, as the `node` user. The base
|
||||
# image sets /usr/local/share/npm-global as the npm-global prefix and makes the
|
||||
# `npm` group writable by `node`. So `npm install -g` works without sudo. Both
|
||||
# versions come from build args. To upgrade, bump them in devcontainer.json and
|
||||
# rebuild.
|
||||
RUN npm install -g \
|
||||
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
|
||||
@openai/codex@${CODEX_VERSION}
|
||||
|
||||
# Install the Cursor CLI. It is pinned and hash-verified, and we run no remote
|
||||
# script. The cursor.com/install script just detects os/arch, downloads a
|
||||
# versioned tarball from
|
||||
# downloads.cursor.com/lab/<version>/<os>/<arch>/agent-cli-package.tar.gz,
|
||||
# extracts it, and symlinks `agent`/`cursor-agent` into ~/.local/bin. We do that
|
||||
# ourselves against a PINNED version plus a per-arch sha256 hash. So the build
|
||||
# runs no unverified remote code. This matches how we pin the base image and npm
|
||||
# packages by digest (issue #1451). The download is fail-closed: if the hash
|
||||
# does not match, the build aborts.
|
||||
#
|
||||
# To bump: set CURSOR_VERSION and both CURSOR_SHA256_* in devcontainer.json
|
||||
# build.args. Get each arch's hash with:
|
||||
# curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
|
||||
#
|
||||
# TARGETARCH is the per-platform build arg that BuildKit sets automatically. It
|
||||
# must be (re)declared in this stage to be visible. When the build is a
|
||||
# non-BuildKit `docker build`, TARGETARCH is unset, so we fall back to `dpkg
|
||||
# --print-architecture`.
|
||||
ARG TARGETARCH
|
||||
RUN set -eux; \
|
||||
arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \
|
||||
case "$arch" in \
|
||||
amd64) cursor_arch=x64; cursor_sha="${CURSOR_SHA256_X64}";; \
|
||||
arm64) cursor_arch=arm64; cursor_sha="${CURSOR_SHA256_ARM64}";; \
|
||||
*) echo "unsupported architecture for Cursor: $arch" >&2; exit 1;; \
|
||||
esac; \
|
||||
url="https://downloads.cursor.com/lab/${CURSOR_VERSION}/linux/${cursor_arch}/agent-cli-package.tar.gz"; \
|
||||
curl -fSL --retry 3 --max-time 120 -o /tmp/cursor.tgz "$url"; \
|
||||
echo "${cursor_sha} /tmp/cursor.tgz" | sha256sum -c -; \
|
||||
dir="/home/${USERNAME}/.local/share/cursor-agent/versions/${CURSOR_VERSION}"; \
|
||||
install -d "$dir" "/home/${USERNAME}/.local/bin"; \
|
||||
tar --strip-components=1 -xzf /tmp/cursor.tgz -C "$dir"; \
|
||||
test -x "$dir/cursor-agent"; \
|
||||
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/agent"; \
|
||||
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/cursor-agent"; \
|
||||
rm -f /tmp/cursor.tgz
|
||||
|
||||
# Install Bun via the official remote installer, pinned by version. The first
|
||||
# positional arg to `bash` is the release tag (`bun-vX.Y.Z`), so a specific
|
||||
# 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
|
||||
# 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}
|
||||
@@ -1,364 +0,0 @@
|
||||
# GitNexus Devcontainer
|
||||
|
||||
A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI, Cursor CLI, and Bun alongside the GitNexus native build chain. Supported hosts: **macOS, Linux, Windows 11 (native), and Windows 11 via WSL2.** Windows-native needs a **one-time `HOME` env var setup** — handled automatically by the `initializeCommand` on first run (see [Windows 11 setup](#windows-11-setup)).
|
||||
|
||||
> ### ⚠️ 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**.
|
||||
>
|
||||
> 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.
|
||||
>
|
||||
> The trade-off of the copy model: host and container config **diverge after first create.** A skill or plugin you add on the host later won't appear in the container until you wipe the config volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)). Edits you make inside the container persist across rebuilds but never reach the host.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Install [Docker Desktop](https://docs.docker.com/desktop/) (Windows/macOS) or Docker Engine (Linux).
|
||||
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.
|
||||
6. Authenticate the three CLIs once — see [First-time CLI authentication](#first-time-cli-authentication) below.
|
||||
|
||||
## Windows 11 setup
|
||||
|
||||
### Windows-native (one-time setup, then "just works")
|
||||
|
||||
The host bind mounts use `${localEnv:HOME}/.claude` (and `.codex`, `.cursor`, `.ssh`, `.config/git`, `.config/gh`, `.gitconfig`). VS Code resolves `${localEnv:HOME}` by reading its own process env, and Windows doesn't set `HOME` by default — it uses `USERPROFILE`. So the bind mounts can't resolve until you tell Windows to also expose your profile as `HOME`.
|
||||
|
||||
The `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`) handles this automatically:
|
||||
|
||||
1. **First time you Reopen in Container**, the script detects the missing `HOME`, runs `setx HOME "%USERPROFILE%"` (which writes to your user-level Windows env — no admin needed), prints a one-time setup banner, and exits.
|
||||
2. **Close all VS Code windows** (File → Exit) and reopen. VS Code picks up the new `HOME` at startup.
|
||||
3. **Reopen in Container again.** The script now sees `HOME=C:\Users\<you>`, skips the setup block, creates the bind-mount source dirs, and Docker brings the container up.
|
||||
|
||||
Subsequent rebuilds work normally with no extra steps. The `HOME` env var is set persistently in your Windows user environment, so it'll be there for every future VS Code session (and any other tool that wants `HOME`).
|
||||
|
||||
If you'd rather set it manually before opening the container:
|
||||
|
||||
```powershell
|
||||
setx HOME "%USERPROFILE%"
|
||||
# Close & reopen VS Code
|
||||
```
|
||||
|
||||
### Known trade-offs of Windows-native vs WSL2
|
||||
|
||||
Windows-native works, but Docker Desktop's Windows bind-mount layer has rough edges that WSL2 avoids:
|
||||
|
||||
- **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.
|
||||
|
||||
If you hit any of those and want to migrate to WSL2 later, the steps are below.
|
||||
|
||||
### WSL2 (faster, fewer edge cases)
|
||||
|
||||
To clone and open the repo inside WSL2:
|
||||
|
||||
```bash
|
||||
# 1. Install WSL2 and a Linux distro if you haven't already.
|
||||
wsl --install -d Ubuntu
|
||||
|
||||
# 2. Enter WSL.
|
||||
wsl
|
||||
|
||||
# 3. Clone the repo inside your WSL2 home directory.
|
||||
cd ~
|
||||
git clone https://github.com/abhigyanpatwari/GitNexus.git
|
||||
cd GitNexus
|
||||
|
||||
# 4. Launch VS Code from inside WSL — this opens VS Code attached to the WSL2
|
||||
# filesystem, so `${localEnv:HOME}` resolves to the WSL user's home and
|
||||
# subsequent "Reopen in Container" uses the WSL2-side path.
|
||||
code .
|
||||
```
|
||||
|
||||
Then run **Dev Containers: Reopen in Container**. The workspace will be bind-mounted from `\\wsl$\Ubuntu\home\<user>\GitNexus`, which is fast and gives reliable file-system events. **Make sure Docker Desktop's WSL integration is enabled** for your distro: Docker Desktop → Settings → Resources → WSL Integration → toggle on the distro you cloned into.
|
||||
|
||||
## macOS
|
||||
|
||||
Open the repo folder in VS Code → **Reopen in Container**. The image is multi-arch; on Apple Silicon you'll pull the `linux/arm64` variant automatically.
|
||||
|
||||
## Linux
|
||||
|
||||
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.
|
||||
|
||||
## 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).
|
||||
|
||||
| Mount | Source | Target | Mode | Purpose |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Container Claude config dir | _named volume_ `claude-config-${devcontainerId}` | `/home/node/.claude` | rw | Per-container credentials + identity |
|
||||
| Container Codex config dir | _named volume_ `codex-config-${devcontainerId}` | `/home/node/.codex` | rw | Per-container credentials |
|
||||
| Container Cursor config dir | _named volume_ `cursor-config-${devcontainerId}` | `/home/node/.cursor` | rw | Per-container credentials |
|
||||
| Container gh config dir | _named volume_ `gh-config-${devcontainerId}` | `/home/node/.config/gh` | rw | Per-container `gh` auth (`hosts.yml`/`config.yml`) seeded from host stage; in-container login persists |
|
||||
| **Claude sessions** (overlay on the config volume) | _named volume_ `…-claude-sessions-${devcontainerId}` | `/home/node/.claude/projects` | rw | `--resume` transcripts; survives the `<cli>-config` volume wipe — see [Session resume](#session-resume-across-container-recreation) |
|
||||
| **Codex sessions** | _named volume_ `…-codex-sessions-${devcontainerId}` | `/home/node/.codex/sessions` | rw | `codex resume` rollouts; SQLite index backfills on recreation |
|
||||
| **Cursor sessions** | _named volumes_ `…-cursor-sessions-${devcontainerId}`, `…-cursor-projects-${devcontainerId}` | `/home/node/.cursor/chats`, `/home/node/.cursor/projects` | rw | `cursor-agent resume` store (best-effort — layout reverse-engineered) |
|
||||
| **claude-mem store** | _named volume_ `claude-mem-${devcontainerId}` | `/home/node/.claude-mem` | rw | claude-mem's SQLite DB + Chroma vector store; **seeded once** from `/host/.claude-mem`, then container-private — see note below |
|
||||
| Host Claude state, read-only stage | `$HOME/.claude` | `/host/.claude` | **read-only** | `post-create.sh` reads credentials + identity from here on container-create |
|
||||
| claude-mem store, read-only stage | `$HOME/.claude-mem` | `/host/.claude-mem` | **read-only** | `post-create.sh` seeds the claude-mem volume from here on first create |
|
||||
| Host Codex state, read-only stage | `$HOME/.codex` | `/host/.codex` | **read-only** | Same purpose for Codex |
|
||||
| Host Cursor state, read-only stage | `$HOME/.cursor` | `/host/.cursor` | **read-only** | Same purpose for Cursor |
|
||||
| **Claude shareable subdirs** | _seeded into the config volume from_ `$HOME/.claude/{plugins/marketplaces,plugins/cache,skills,agents,memory,commands}` | same under `/home/node/.claude/` | n/a (copy) | **Seed-once** copy from the read-only stage; container owns its copy after |
|
||||
| **Codex shareable subdirs** | _seeded from_ `$HOME/.codex/{plugins,prompts,memories,skills}` | same under `/home/node/.codex/` | n/a (copy) | **Seed-once** copy (whole `plugins/` dir — no path-bearing registry inside it) |
|
||||
| **Cursor shareable subdirs** | _seeded from_ `$HOME/.cursor/{plugins/marketplaces,plugins/local,rules,commands,agents,skills}` | same under `/home/node/.cursor/` | n/a (copy) | **Seed-once** copy of the Cursor 2.5 plugin/rules/commands surface |
|
||||
|
||||
**What gets seeded once from the host (copy, not bind):**
|
||||
|
||||
- **Claude**: `plugins/marketplaces`, `plugins/cache`, `skills/`, `agents/`, `memory/`, `commands/`
|
||||
- **Codex**: `plugins/` (whole dir), `prompts/`, `memories/`, `skills/`
|
||||
- **Cursor**: `plugins/marketplaces`, `plugins/local`, `rules/`, `commands/`, `agents/`, `skills/`
|
||||
|
||||
On the **first** container-create, `post-create.sh` copies each of these out of the read-only `/host/.<cli>` stage into the per-container config volume, then writes a `.devcontainer-shareable-seeded` marker. On every later rebuild the marker is present, so the copy is skipped and the container keeps whatever it has accumulated. A plugin/skill/agent you install **inside** the container persists across rebuilds; one you add on the **host** after first create won't appear in the container until you remove the config volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)). Nothing here is writable back to the host — `/plugin marketplace add` inside the container installs into the container's own volume copy, not your host `~/.<cli>/plugins/`.
|
||||
|
||||
**Single config files are copied on container-create, not bind-mounted** — on Docker Desktop Windows a single-file bind is 9p while the named volume is ext4, and atomic config writes (`tmp` → rename onto target) trip EXDEV (this is what caused Codex's `config/batchWrite failed in TUI`). So these are synced from host on rebuild and the container rewrites its own copy until the next rebuild: `settings.json` + `$HOME/.claude.json` (Claude), `config.toml` (Codex), `cli-config.json` + `mcp.json` (Cursor). `hooks.json` (Cursor) is deliberately **not** synced — Cursor hooks execute shell commands, so sharing them would widen the supply-chain attack surface; add it yourself if you want it.
|
||||
|
||||
**Plugin registry files with absolute paths are translated, not copied verbatim** — Claude's `known_marketplaces.json` / `installed_plugins.json` / `plugin-catalog-cache.json` and Cursor's `installed_plugins.json` bake in `C:\Users\…` (Windows) or `/Users/…` (macOS) install paths. `post-create.sh` rewrites those to `/home/node/.<cli>/plugins/…` and writes the result into the named volume, so plugins resolve inside Linux instead of failing with `cache-miss`. This translation is **also seed-once per CLI** — it runs only for a CLI being seeded that create (`translate-plugin-registries.cjs claude cursor`), so it stays consistent with the seed-once `cache/` copy and won't overwrite a plugin you installed inside the container on a later rebuild. Codex needs no translation — its enablement registry is `config.toml` (git URLs + logical keys, no filesystem paths), so its whole `plugins/` dir is copied as-is.
|
||||
|
||||
**What stays per-container (in the named volume) and is synced from host on container-create:**
|
||||
|
||||
- `.credentials.json` (Claude OAuth tokens), `auth.json` (Codex), `cli-config.json` (Cursor) — credentials
|
||||
- `~/.claude/.claude.json` (Claude's identity-only file: `userID`, `oauthAccount`, migration tracking) — kept per-container so logging in via container doesn't overwrite host's stored identity
|
||||
|
||||
`post-create.sh` runs on every container-create, copies host's credentials into the volume if present, then container manages refresh from there. Sync is "always overwrite if host has the file, otherwise leave container alone". So:
|
||||
|
||||
- Host has credentials → container starts logged in.
|
||||
- Host has no credentials → `claude login` / `codex login --device-auth` / `cursor-agent login` inside container; credentials stay in the named volume across rebuilds (volume is keyed by `${devcontainerId}`, stable for the workspace path).
|
||||
- `claude logout` inside container clears volume credentials only; host is untouched.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**claude-mem is seeded once, then container-private.** The [claude-mem](https://github.com/thedotmack/claude-mem) store (`$HOME/.claude-mem` — a multi-GB SQLite DB `claude-mem.db` + `-wal`/`-shm`, plus a Chroma vector store `chroma/chroma.sqlite3` and its HNSW index binaries) is the one shareable-looking folder that is **deliberately not a host bind**, for the same SQLite reason as sessions below: a multi-GB WAL database over the 9p/virtiofs bind risks unreliable `fcntl` locking and corruption — sharply so if claude-mem ran on the host and in the container against the same DB at once. So it gets its own per-container named volume (`claude-mem-${devcontainerId}`), and `post-create.sh` **seeds it once** from the read-only `/host/.claude-mem` stage _only when the volume has no DB yet_. The first container-create copies the host's store in (a one-time copy, possibly several GB); every later rebuild keeps whatever the container accumulated and skips the copy. The container's memory and the host's **diverge from that seed point** — writes do not flow back — which is the price of keeping SQLite off a shared bind. To re-seed from the host's current store, remove the volume (`docker volume rm claude-mem-<id>`) and rebuild. `ensure-host-config-dirs.cjs` creates an empty `~/.claude-mem` on hosts that never installed claude-mem, so the read-only stage bind always resolves; the seed then finds no DB and the container simply starts with empty memory.
|
||||
|
||||
### Session resume across container recreation
|
||||
|
||||
`claude --resume`, `codex resume`, and `cursor-agent resume` all read **local** transcript files. Those live _inside_ each CLI's config dir, which is a per-container named volume — so they already survive an ordinary **Rebuild Container**. What they did _not_ survive were the very things this README tells you to do: `docker volume rm <cli>-config-${devcontainerId}` to force a re-login or clear an `EACCES`, a `${devcontainerId}` change, or a full delete-and-recreate. Each of those drops the config volume and takes your session history with it.
|
||||
|
||||
So the resume/transcript directories get their **own** named volumes (mount group 6 in `devcontainer.json`), keyed like the `node_modules` volumes (`${localWorkspaceFolderBasename}-…-${devcontainerId}`) and mounted _over_ the config volume at the session sub-paths:
|
||||
|
||||
| Resume command | Persisted volume → target | What's stored |
|
||||
| -------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `claude --resume` / `--continue` | `…-claude-sessions-…` → `~/.claude/projects` | `<encoded-cwd>/<uuid>.jsonl` transcripts + `sessions-index.json`. Container cwd is always `/workspace`, so only that slice is stored. Pure JSONL/JSON — no SQLite. |
|
||||
| `codex resume` / `resume --last` | `…-codex-sessions-…` → `~/.codex/sessions` | `YYYY/MM/DD/rollout-*.jsonl`. The `state_5.sqlite` thread index stays on the config volume (a single WAL file we don't split out); when it's absent after a recreation, Codex rebuilds it from these rollouts on the next start (a one-time backfill). |
|
||||
| `cursor-agent resume` / `ls` | `…-cursor-sessions-…` → `~/.cursor/chats`; `…-cursor-projects-…` → `~/.cursor/projects` | `chats/{hash}/{uuid}/store.db` (one SQLite db per session, each in its own dir) + `projects/.../agent-transcripts`. cursor-agent's layout is reverse-engineered, so treat this as best-effort. |
|
||||
|
||||
Because these are **separate** volumes from `<cli>-config-${devcontainerId}`, the re-login fix (`docker volume rm claude-config-…`) no longer destroys your sessions — that was the point.
|
||||
|
||||
**Survives:** Rebuild Container, Rebuild Without Cache, a full delete-and-recreate of the container, and the `docker volume rm <cli>-config-…` re-login / `EACCES` fix.
|
||||
|
||||
**Does _not_ survive** (same durability tier as the `node_modules` volumes): `docker volume prune`, a `${devcontainerId}` change (moving the checkout to a new path, or switching between Windows-native and WSL2), or moving to a new machine. To deliberately wipe sessions, remove the session volumes too — see [Rebuild / reset](#rebuild--reset). Two checkouts with the **same folder name** on one host would share session volumes only if they also share a `${devcontainerId}`; they don't, so they stay separate.
|
||||
|
||||
**First rebuild after adopting this, one-time:** if a container created _before_ these volumes existed already had sessions on the config volume (`~/.claude/projects`, `~/.codex/sessions`, …), the new empty session volume mounts _over_ that sub-path and **masks** the old content — same Docker-precedence shadowing described for plugins above. The old sessions are hidden, not deleted. To carry them forward once, copy them out of the config volume into the session volume; or just start fresh — new sessions land on the session volume from then on.
|
||||
|
||||
**Why sessions are container-private and not even seeded from the host.** The shareable config dirs are _seeded once_ from the host (you want your skills/agents/plugins in the container). Sessions are deliberately _not_ seeded and never touch the host, because a transcript can contain anything you pasted or the agent read — API keys, file contents, connection strings. Binding or copying them to/from the host would (a) spill that to host disk, (b) add a write-through surface a compromised dependency can reach (there's still [no egress firewall](#whats-not-included-yet)), and (c) leak _every other project's_ transcripts into the container (Codex `sessions/` and Cursor `chats/` aren't project-scoped). Container-private volumes avoid all three while still surviving recreation. And Claude/Codex transcripts embed the container cwd (`/workspace`), so even if you _did_ bind them to the host, the host CLI wouldn't natively `--resume` them — its encoded-cwd folder differs.
|
||||
|
||||
**Opt in to host-shared sessions anyway.** If you want transcripts visible/portable on the host and accept the trade-offs above, uncomment the host-bind block in `devcontainer.json` (just below the group-6 volumes) and add the matching source dirs to `ensure-host-config-dirs.cjs`'s `DIRS` so Docker can resolve the binds. That block scopes Claude to `/workspace`'s encoded subdir to limit the cross-project leak; the Codex and Cursor stores can't be scoped that way, so they expose every project's transcripts.
|
||||
|
||||
### Other host bind mounts
|
||||
|
||||
| Container path | Host source | Mode | Why |
|
||||
| --------------- | ------------------- | ------------- | -------------------------------------------------------------------------------------- |
|
||||
| `~/.config/git` | `$HOME/.config/git` | **read-only** | XDG-style git config / ignore / attributes |
|
||||
| `~/.ssh` | `$HOME/.ssh` | **read-only** | SSH commit signing + git push over SSH |
|
||||
| `~/.config/gh` | `$HOME/.config/gh` | **copy → volume** | `gh` CLI auth (PR/issue create, checks) — seeded from your host login on create into a per-container volume; in-container `gh auth login` persists across rebuilds and never writes back to the host |
|
||||
| `~/.docker` | `$HOME/.docker` | **read-only** | Container registry auth + buildx config (inert until you add Docker CLI via a Feature) |
|
||||
| `~/.aws` | `$HOME/.aws` | **read-only** | AWS CLI / SDK credentials (forward-compat — empty by default) |
|
||||
| `~/.azure` | `$HOME/.azure` | **read-only** | Azure CLI credentials (forward-compat — empty by default) |
|
||||
|
||||
**Why `ssh`/`aws`/`azure`/`docker` are read-only, and why `gh` is copied into a volume:** `ssh`/`aws`/`azure` are consumed read-only by their clients (the SSH client and the AWS/Azure SDKs only read their credential files), so a one-way mount loses nothing. `docker` _can_ write its own state (`docker login` / buildx write `config.json`), but a read-write host bind would let a compromised in-container dependency rewrite your host `~/.docker/config.json` (point a `credHelper` at an attacker-controlled binary) — a credential-takeover vector. The common case is _reading_ an existing host login, so `docker` stays **read-only**: registry pulls/pushes using your host creds work, only a `docker login` inside the container won't persist back. `gh` used to be read-only for the same reason, but that meant an in-container `gh auth login` had nowhere to write and silently failed. So `gh` now uses the **copy-into-volume** model (the same one the AI-CLI credentials use): the host `~/.config/gh` is a read-only _stage_ at `/host/.config/gh`, and `post-create.sh` copies `hosts.yml`/`config.yml` out of it into the per-container `gh-config` volume on create. The container gets a **writable** copy — `gh auth login` / `gh auth refresh` inside the container now work and persist across rebuilds — while the read-only stage guarantees nothing is ever written back to the host's token. If you want `docker` to behave the same way, give it the same treatment (a `/host/.docker` stage + a docker-config volume + a copy step in `post-create.sh`).
|
||||
|
||||
`~/.gitconfig` is **not** bind-mounted — VS Code's Dev Containers extension auto-copies the host's gitconfig into the container at attach time (this is built-in behavior, not something this devcontainer configures). The bind-mount approach conflicts with that auto-copy mechanism, so we let VS Code own it. The end result is the same: your host's `user.name` / `user.email` are available inside the container.
|
||||
|
||||
If a host source dir doesn't exist when the container is first created, the `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`) creates it empty — so the bind mount always has a valid source.
|
||||
|
||||
### Per-CLI quirks worth knowing
|
||||
|
||||
- **Claude Code on macOS** stores credentials in the system Keychain, not in `~/.claude/.credentials.json`. The sync silently no-ops; run `claude login` inside the container once and the named volume persists it.
|
||||
- **Codex on macOS / Linux with `cli_auth_credentials_store = "keyring"`** stores auth in the OS keyring (Keychain / Secret Service), so `~/.codex/auth.json` may not exist on host. Same fallback: `codex login --device-auth` inside the container.
|
||||
- **Cursor CLI inside containers** has [known upstream auth issues](https://forum.cursor.com/t/cursor-agent-authentication-issue-inside-docker/143995) — even with a correctly-synced `cli-config.json`, you may need to re-run `cursor-agent login` inside the container.
|
||||
- **Stale named volumes from old rebuilds can carry forward.** If you delete and re-create the same workspace, or if a prior container left interim state with a different `userID`, deleting the named volumes before rebuild guarantees a clean sync: `docker volume rm claude-config-${devcontainerId} codex-config-${devcontainerId} cursor-config-${devcontainerId}` (look them up with `docker volume ls | grep -config-`).
|
||||
- **User-scope MCP servers with absolute host paths won't resolve in-container.** `~/.claude.json` (Claude), `~/.codex/config.toml` (Codex), and `~/.cursor/mcp.json` (Cursor) are copied from host on container-create, so their user-scope `mcpServers` entries come along. But an entry whose `command` is an absolute host path (`C:\tools\foo.exe`, `/usr/local/bin/foo`) points at a binary that doesn't exist in the container — that server silently fails to launch. Only registry/`npx`-based servers (like this repo's `.mcp.json`, which uses `npx -y gitnexus@latest mcp`) and remote/URL servers work unchanged. The path-translation pass only rewrites `*/.<cli>/plugins/*` registry paths, **not** arbitrary `mcpServers` command paths (there's no correct container target for a host-local binary). Install such MCP servers inside the container, or use `npx`/remote ones.
|
||||
- **Host config is seeded once per devcontainer, then diverges — this now applies to everything.** A `mcpServers` entry, setting, plugin, skill, agent, or command you add **on the host after** the container was created is not visible in the container until you remove the config volume and rebuild. Single config files (`mcpServers`, `settings.json`, …) are copy-on-create; the shareable dirs (plugins/skills/agents/memory/commands/prompts/rules) are copy-on-**first**-create (they persist across ordinary rebuilds and aren't even re-copied). Both diverge from the host after their copy. To pull host-side changes in, wipe the relevant volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)).
|
||||
- **Plugins/skills/agents installed in-container persist; they do not reach the host.** A `/plugin marketplace add` (or `codex plugin add`, or a new skill/agent) inside the container writes to the container's own config volume and survives ordinary rebuilds. It never appears on the host — the host dirs are read-only sources, not bind targets. To get a plugin onto the host, install it on the host (then wipe + rebuild to seed it into the container).
|
||||
- **No cross-checkout plugin contention.** Because each container copies plugins into its own per-`${devcontainerId}` volume rather than sharing one host bind source, two containers (or checkouts) installing plugins at the same time no longer interleave git clones/extractions against a shared host dir. Each writes only its own copy.
|
||||
|
||||
### What you still don't have inside the container
|
||||
|
||||
These are commonly-needed CLIs that aren't installed by default — adding them would be follow-up work, not in this PR's scope:
|
||||
|
||||
- **Docker CLI** (for `docker push` / `docker build` from inside the container). Add via `ghcr.io/devcontainers/features/docker-outside-of-docker:1` to the `features` block — `~/.docker/` is already mounted **read-only**, so your host `docker login` state works immediately for pulls/pushes; an in-container `docker login` won't persist to the host (drop `,readonly` on that mount if you need it to).
|
||||
- **AWS CLI / Azure CLI / gcloud / kubectl** — same pattern: add the matching Feature, the host config dirs already flow through.
|
||||
- **Private npm registry auth** (`~/.npmrc`) — you don't have a global one on this host. If you ever start using private packages, add `source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonly` to the mounts.
|
||||
|
||||
That means:
|
||||
|
||||
- **Authentication is shared.** If you're already logged in on the host (`claude login`, `codex login`, `cursor-agent login`, `gh auth login`), you're already logged in inside the container. No second login step.
|
||||
- **Plugins, skills, agents, memory, and commands are seeded from the host once, then container-private.** On first create the container copies your host's plugins/skills/agents/memory/commands (and Codex prompts/memories, Cursor rules) into its own volume. After that they're independent: install or edit inside the container and it stays in the container (persists across rebuilds); add a plugin or agent on the host and the container won't see it until you wipe the config volume and rebuild. Nothing the container does reaches the host. (`settings.json` and the user-scope `~/.claude.json` are copy-on-create the same way; `~/.claude/projects/` is container-local by design.)
|
||||
- **Git identity comes from the host.** Commits from inside the container use your host's `user.name` / `user.email` — VS Code's Dev Containers extension auto-copies your `~/.gitconfig` into the container at attach time. Any XDG-style config under `~/.config/git/` flows through via the read-only bind mount. To change git identity, edit `~/.gitconfig` on the host (container-side `git config --global` writes to a container-local file that's discarded on rebuild).
|
||||
- **SSH keys flow through (read-only).** Push over SSH remotes and SSH commit signing work inside the container using your host keys. The mount is read-only so container code can't exfiltrate or modify private keys — agent-perspective, this means you get git operations but the keys stay vendor-side.
|
||||
- **`gh` auth is shared, and in-container logins persist.** If you're logged in on the host, `gh pr create`, `gh pr checks`, `gh issue create` work inside the container without re-authenticating. If you're not, run `gh auth login` inside the container once — because `gh` config lives in a writable per-container volume (seeded from the host stage), that login persists across rebuilds and never touches the host's token.
|
||||
- **No per-workspace duplication.** All your devcontainers across all your projects see the same host CLI state, just like all your host shells do.
|
||||
|
||||
The bind mount source directories are guaranteed to exist by the `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`), which runs on the host before container create. It's a Node script (not a shell one-liner) so the same command works on Windows `cmd.exe` and POSIX shells. It creates the top-level bind-mount source dirs — `~/.claude`, `~/.codex`, `~/.cursor`, `~/.claude-mem`, plus `~/.ssh`, `~/.docker`, `~/.aws`, `~/.azure`, `~/.config/{gh,git}`. It deliberately does **not** pre-create the shareable subdirs (skills/agents/plugins/…): those are no longer bind sources (they're copied out of the whole-`~/.<cli>` read-only stage), and pre-creating empty ones would needlessly write into the host of someone who never used that CLI.
|
||||
|
||||
### Trust boundary, concretely
|
||||
|
||||
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
|
||||
- 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'
|
||||
- Your **`gh` token** (`~/.config/gh`)
|
||||
- Your **SSH private keys** (`~/.ssh/`)
|
||||
- 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.
|
||||
|
||||
**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).
|
||||
|
||||
**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.
|
||||
|
||||
**If a workspace dep is ever found compromised**, rotate credentials at the vendor side — local file deletion is insufficient because tokens may have already left:
|
||||
|
||||
- Anthropic: [console.anthropic.com → Settings → Keys](https://console.anthropic.com/settings/keys), revoke the OAuth session under Account
|
||||
- OpenAI / Codex: [platform.openai.com/api-keys](https://platform.openai.com/api-keys), revoke session under Profile
|
||||
- Cursor: dashboard → Integrations, rotate API key + revoke CLI session
|
||||
- GitHub: `gh auth refresh` or revoke the token at github.com/settings/tokens
|
||||
|
||||
For high-trust enterprise environments where the container should not even be able to **read** host CLI state, remove the three read-only stage binds (`/host/.claude`, `/host/.codex`, `/host/.cursor`) — plus `/host/.claude-mem` and `/host/.claude.json` — from `.devcontainer/devcontainer.json`. With no stage to copy from, `post-create.sh`'s seed and credential-sync steps quietly do nothing (their `[ -f ]` / `[ -d ]` guards), and each devcontainer starts with empty, fully isolated config and credentials (Anthropic's reference pattern). You give up seeding your host setup into the container in exchange for removing host config/credentials from the container's read surface entirely; log in inside each container instead.
|
||||
|
||||
## First-time CLI authentication
|
||||
|
||||
Each CLI works either way:
|
||||
|
||||
- **Log in on host first** → the container picks it up automatically on the next rebuild (`sync_from_host` copies the credential file into the named volume during `post-create.sh`). Host stays the source of truth.
|
||||
- **Log in inside the container** → credentials write to the named volume. They persist across ordinary rebuilds (volume is keyed by `${devcontainerId}`, which is stable for a given workspace folder). The host's credentials are untouched.
|
||||
|
||||
You can mix and match per-CLI. A common setup is "Claude logged in on host, Codex/Cursor logged in inside container".
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
claude login
|
||||
```
|
||||
|
||||
Opens a browser auth flow. VS Code's port forwarding handles the OAuth callback automatically. After auth, `~/.claude/` is populated and visible from both host and container. The `DISABLE_AUTOUPDATER=1` env var prevents the in-container CLI from auto-updating — rebuild the container to pick up a newer Claude Code.
|
||||
|
||||
### OpenAI Codex CLI
|
||||
|
||||
```bash
|
||||
codex login --device-auth
|
||||
```
|
||||
|
||||
The device-code flow prints a URL and a one-time code. Visit the URL on your host browser, paste the code, and the CLI authenticates without needing a callback listener — this is the most reliable path inside containers. Credentials land in `~/.codex/auth.json` (shared with host).
|
||||
|
||||
`codex login` (browser-callback variant) also works but can be flaky in some headless contexts; prefer `--device-auth`.
|
||||
|
||||
### Cursor CLI
|
||||
|
||||
```bash
|
||||
cursor-agent login
|
||||
```
|
||||
|
||||
Opens a browser auth flow; VS Code's port forwarding handles the callback. Credentials persist in `~/.cursor/cli-config.json` (shared with host).
|
||||
|
||||
Verify any time with `cursor-agent status`.
|
||||
|
||||
## Alternative: API key authentication (CI / headless)
|
||||
|
||||
For non-interactive use (CI runners, automated scripts), all three CLIs accept API keys via env vars:
|
||||
|
||||
| CLI | Env var | Where to get the key |
|
||||
| ----------- | ------------------- | --------------------------------------------- |
|
||||
| Claude Code | `ANTHROPIC_API_KEY` | <https://console.anthropic.com/settings/keys> |
|
||||
| Codex | `OPENAI_API_KEY` | <https://platform.openai.com/api-keys> |
|
||||
| Cursor | `CURSOR_API_KEY` | Cursor dashboard → Integrations |
|
||||
|
||||
These env vars are intentionally **not** injected into the container from the host. `${localEnv:VAR}` resolves an unset host variable to an empty string, and some CLIs (Cursor in particular) treat a set-but-empty key as "use this key" rather than "fall back to stored login" — which would silently break the login flow for everyone who hasn't pre-set the host var.
|
||||
|
||||
To use an API key inside the container, export it in your terminal session:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
# or OPENAI_API_KEY, or CURSOR_API_KEY
|
||||
```
|
||||
|
||||
For persistence across container shells, carry the export via your VS Code [dotfiles repository](https://code.visualstudio.com/docs/devcontainers/containers#_personalizing-with-dotfile-repositories). VS Code clones the dotfiles repo into the container on attach and runs your install command, so the export lands in `~/.bashrc` / `~/.zshrc` per your own setup — and your API keys stay out of this repo's committed `devcontainer.json`.
|
||||
|
||||
A non-empty API key env var takes precedence over stored login credentials for each CLI.
|
||||
|
||||
## Port forwarding
|
||||
|
||||
| Port | Service | Notes |
|
||||
| ------ | -------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `5173` | Vite dev server (`gitnexus-web`) | Auto-forwarded with notification |
|
||||
| `4747` | `gitnexus serve` HTTP API | **Must not be remapped** — `gitnexus-web` hardcodes `http://localhost:4747` as the default backend URL |
|
||||
| `4173` | Static web (Vite preview) | Silently forwarded |
|
||||
|
||||
VS Code's Ports panel shows forwarded ports once their listener starts.
|
||||
|
||||
## Known gotchas
|
||||
|
||||
- **LadybugDB integration tests may fail in containers** (file-locking, `AGENTS.md` § Testing). Default to `npm run test:unit` inside the container; run integration tests on the host. Tracking issue: documented as a known limitation.
|
||||
- **Single-writer LadybugDB constraint** (`GUARDRAILS.md` § LadybugDB lock). Don't run `gitnexus analyze` on the host and inside the container against the same `.gitnexus/` directory simultaneously — the second writer will get `database busy`.
|
||||
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift grammars build during `gitnexus`'s `postinstall`. To skip them (loses parsing for those three languages), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` in your shell or add it to `remoteEnv` and rebuild.
|
||||
- **`tree-sitter-kotlin` warnings on install** are expected (per `AGENTS.md`). Ignore them.
|
||||
- **`.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`.
|
||||
|
||||
## Rebuild / reset
|
||||
|
||||
- **Rebuild Container** (Command Palette) — re-runs the Dockerfile build and `postCreateCommand` against the existing named volumes (auth, history, **and sessions** persist).
|
||||
- **Rebuild Container Without Cache** — fresh image layers, same volumes.
|
||||
- **To force a re-login / clear an `EACCES`** — remove the per-container _config_ volumes and rebuild. As of the session-volume change this **no longer drops your `--resume` history** (sessions are on separate volumes — see [Session resume](#session-resume-across-container-recreation)):
|
||||
```bash
|
||||
docker volume ls | grep -- -config- # the credential / identity volumes
|
||||
docker volume rm claude-config-<id> codex-config-<id> cursor-config-<id> gh-config-<id>
|
||||
```
|
||||
⚠️ Since the shareable dirs are now seeded into the config volume (not bind-mounted), wiping `<cli>-config` **also discards any plugin/skill/agent/command you installed _inside_ the container** and re-seeds those dirs from the host on the next rebuild. That is the intended way to pull host-side config changes in, but if you have in-container-only plugins you want to keep, reinstall them after the rebuild (or install them on the host first so the re-seed brings them along).
|
||||
- **To also wipe session history** (a true clean slate) — remove the session volumes too (`<name>` is your workspace folder name):
|
||||
```bash
|
||||
docker volume ls | grep -E -- '-(sessions|cursor-projects)-' # the group-6 volumes
|
||||
docker volume rm <name>-claude-sessions-<id> <name>-codex-sessions-<id> \
|
||||
<name>-cursor-sessions-<id> <name>-cursor-projects-<id>
|
||||
```
|
||||
Then rebuild.
|
||||
- **To re-seed claude-mem from the host** (the container's memory has diverged and you want the host's current store back) — remove the claude-mem volume and rebuild; `post-create.sh` copies the host store in again on the next create:
|
||||
```bash
|
||||
docker volume rm claude-mem-<id>
|
||||
```
|
||||
|
||||
## 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`.
|
||||
|
||||
## 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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Integration tests fail with `database busy` | LadybugDB single-writer constraint | Don't run host-side `gitnexus analyze` while the container is also analyzing the same repo; choose one writer |
|
||||
| API key env vars not visible inside the container | They are intentionally not auto-propagated from the host (so an empty/stale host var can't silently break `*-login` for everyone else) | `export ANTHROPIC_API_KEY=...` / `OPENAI_API_KEY=...` / `CURSOR_API_KEY=...` inside the container shell, or carry it via your VS Code [dotfiles repo](https://code.visualstudio.com/docs/devcontainers/containers#_personalizing-with-dotfile-repositories) for persistence |
|
||||
| `git commit` produces commits with empty author | `~/.gitconfig` is missing or empty on the host (VS Code's auto-copy had nothing to copy) | Set `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` from the host shell, then rebuild the container |
|
||||
| `gh: not logged in` inside the container | Not logged in on the host (nothing to seed), or the `gh-config` volume is empty | Just run `gh auth login` **inside the container** — `gh` config lives in a writable per-container volume, so the login persists across rebuilds. (Logging in on the host instead also works: it seeds in on the next container create.) |
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
|
||||
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
// Devcontainer for GitNexus. It pre-installs Claude Code, the OpenAI Codex
|
||||
// CLI, and the Cursor CLI, plus the Node.js native build chain. It works on
|
||||
// macOS, Linux, Windows via WSL2, and Windows native. Windows native needs a
|
||||
// one-time HOME setup. That setup runs automatically via initializeCommand.
|
||||
// See .devcontainer/README.md § Windows 11 setup. Open it with the VS Code
|
||||
// Dev Containers extension.
|
||||
//
|
||||
// For first-time setup, auth flows, and troubleshooting, see
|
||||
// .devcontainer/README.md.
|
||||
{
|
||||
"name": "GitNexus AI CLI Devcontainer",
|
||||
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".",
|
||||
"args": {
|
||||
"CLAUDE_CODE_VERSION": "2.1.156",
|
||||
"CODEX_VERSION": "0.134.0",
|
||||
// Cursor: a pinned version plus one sha256 hash per CPU arch. The
|
||||
// Dockerfile checks the tarball against the hash at build time, so it
|
||||
// never runs a remote install script. Bump all three values together.
|
||||
// Re-hash each arch with:
|
||||
// curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
|
||||
"CURSOR_VERSION": "2026.05.28-a70ca7c",
|
||||
"CURSOR_SHA256_X64": "7f8b6a09393e0b84b288cc6952b292fc98d15775f644cc01b0b9aa4f04b268df",
|
||||
"CURSOR_SHA256_ARM64": "05a0ab361e038729aba25fe7f407531b3e8432912e499d0bffdf1dda0e7833e9",
|
||||
// Bun: pinned by version. Installed by the official bun.sh/install
|
||||
// script, which accepts the release tag as its first positional arg
|
||||
// (`bash -s bun-vX.Y.Z`). UNLIKE Cursor, the install path runs an
|
||||
// unverified remote script — chosen at request time for simplicity.
|
||||
// To bump: pick a tag from github.com/oven-sh/bun/releases and update
|
||||
// this value.
|
||||
"BUN_VERSION": "1.3.14",
|
||||
"TZ": "${localEnv:TZ:UTC}"
|
||||
}
|
||||
},
|
||||
|
||||
// Runs on the HOST, not the container, before the container is created. We
|
||||
// write it as a single string on purpose. The spec treats the single-string
|
||||
// form as one command that each OS runs its own way. The object form means
|
||||
// "named parallel tasks", not per-OS dispatch. We run it with Node so the
|
||||
// same command works in cmd.exe on Windows and in bash/zsh on Linux, macOS,
|
||||
// and WSL. The script reads `os.homedir()`, which respects $HOME on
|
||||
// Linux/macOS and %USERPROFILE% on Windows. It then creates the host-side
|
||||
// bind mount source folders, and it is safe to re-run. Host prerequisite:
|
||||
// Node on PATH. That is the only host-side tool needed beyond Docker Desktop
|
||||
// and the VS Code Dev Containers extension.
|
||||
"initializeCommand": "node .devcontainer/ensure-host-config-dirs.cjs",
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
|
||||
"remoteUser": "node",
|
||||
"updateRemoteUserUID": true,
|
||||
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
|
||||
"workspaceFolder": "/workspace",
|
||||
|
||||
// Mount topology, by group:
|
||||
//
|
||||
// 1. AI CLI host config — a READ-ONLY stage at /host/.<cli>. On container-
|
||||
// create, `post-create.sh` COPIES out of it: credentials, identity, and
|
||||
// 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
|
||||
// volume and rebuild. See README § "Trust boundary, concretely".
|
||||
//
|
||||
// 2. AI CLI container config — one named volume per devcontainer. CODEX_HOME
|
||||
// points here. CLAUDE_CONFIG_DIR is left unset on purpose, so it resolves
|
||||
// to the default ~/.claude, which is this same path. Credentials,
|
||||
// identity, and single config files (.credentials.json,
|
||||
// ~/.claude/.claude.json, settings.json, config.toml, cli-config.json,
|
||||
// mcp.json) live here with correct Linux permissions. They are NOT
|
||||
// bind-mounted, because single-file binds break on Docker Desktop Windows
|
||||
// (the EXDEV error — see the SINGLE-FILE note below). Container-managed
|
||||
// state (sessions, history, caches, IDE locks) stays separate per
|
||||
// devcontainer. So two GitNexus checkouts on the same host can't corrupt
|
||||
// each other.
|
||||
//
|
||||
// 3. Other host config — read-only bind mounts for credential and identity
|
||||
// folders that lack the permission-flattening and onboarding-state
|
||||
// complications Claude Code has (ssh, aws, azure, git config, plus gh and
|
||||
// docker). gh and docker are read-only so a compromised dependency can't
|
||||
// rewrite the GitHub token or the Docker credHelper. See the inline note
|
||||
// at those mounts. `~/.gitconfig` is not mounted here. VS Code auto-copies
|
||||
// it separately.
|
||||
//
|
||||
// 4. Per-instance state — scoped by `${devcontainerId}`: shell history and
|
||||
// the npm cache. These survive rebuilds and stay separate between sibling
|
||||
// instances.
|
||||
//
|
||||
// 5. Per-workspace-name AND per-instance state — the workspace `node_modules`
|
||||
// volumes use both `${localWorkspaceFolderBasename}` (so you can spot them
|
||||
// in `docker volume ls`) and `${devcontainerId}` (so sibling instances of
|
||||
// the same repo never collide). This keeps tree-sitter native binaries and
|
||||
// onnxruntime off the workspace bind mount, which is faster on Windows and
|
||||
// macOS.
|
||||
//
|
||||
// 6. Per-workspace session state — dedicated named volumes for each CLI's
|
||||
// resume/transcript dirs (Claude projects/, Codex sessions/, Cursor chats/
|
||||
// + projects/). Same `${localWorkspaceFolderBasename}` + `${devcontainerId}`
|
||||
// keying as group 5, but SEPARATE volumes from the group-2 config volumes.
|
||||
// That separation is the point: the `docker volume rm <cli>-config-*`
|
||||
// re-login / EACCES fix (README § Rebuild/reset) no longer wipes sessions,
|
||||
// so `claude --resume`, `codex resume`, and `cursor-agent resume` survive a
|
||||
// rebuild, a full delete-and-recreate, AND that wipe. They overlay the
|
||||
// config volume at the session sub-paths (Docker precedence: more specific
|
||||
// path wins). Container-private by design — transcripts can hold pasted
|
||||
// secrets, and like the group-1 config (read-only stage, copy-once) these
|
||||
// add NO host write-through surface and leak no other projects' transcripts.
|
||||
// They do
|
||||
// NOT survive `docker volume prune`, a `${devcontainerId}` change (moving
|
||||
// the checkout, WSL vs native), or a new machine — same tier as group 5.
|
||||
// To make sessions host-visible/portable instead, see the commented
|
||||
// host-bind block below and README § "Session resume across recreation".
|
||||
"mounts": [
|
||||
// One named volume per container for credentials and identity state. Each
|
||||
// CLI's real `~/.<cli>` config folder lives in a volume. That keeps
|
||||
// credentials (with correct Linux 600 permissions) and per-container
|
||||
// session state separate from the host. Logging in inside the container
|
||||
// and logging in on the host are independent. The bind mounts BELOW these
|
||||
// volumes override the volume's contents at the paths they cover. Docker
|
||||
// mount precedence is: the more specific path wins.
|
||||
"source=claude-config-${devcontainerId},target=/home/node/.claude,type=volume",
|
||||
"source=codex-config-${devcontainerId},target=/home/node/.codex,type=volume",
|
||||
"source=cursor-config-${devcontainerId},target=/home/node/.cursor,type=volume",
|
||||
|
||||
// gh CLI config as a per-container named volume, same model as the AI CLI
|
||||
// configs above: post-create.sh COPIES hosts.yml/config.yml out of the
|
||||
// read-only /host/.config/gh stage into this volume on container-create.
|
||||
// The container then owns a WRITABLE copy, so `gh auth login` /
|
||||
// `gh auth refresh` run INSIDE the container persist across rebuilds — and
|
||||
// still never write back to the host (the stage is read-only). If the host
|
||||
// is logged in, that login seeds in; if not, an in-container login sticks.
|
||||
"source=gh-config-${devcontainerId},target=/home/node/.config/gh,type=volume",
|
||||
|
||||
// claude-mem store. UNLIKE the shareable dirs below (skills/agents/memory),
|
||||
// this is NOT a host bind. $HOME/.claude-mem is a large, multi-GB SQLite +
|
||||
// Chroma vector store (claude-mem.db + -wal/-shm, chroma/chroma.sqlite3, HNSW
|
||||
// index binaries). A read-write host bind would (a) push every byte over the
|
||||
// 9p/virtiofs share, and (b) expose those SQLite WAL files to unreliable
|
||||
// fcntl locking across that boundary — with a real corruption risk if
|
||||
// claude-mem ran on the host and in the container against the same DB at
|
||||
// once. So it gets its OWN per-container named volume here, same durability
|
||||
// tier as the config volumes (survives Rebuild Container and a
|
||||
// delete-and-recreate; keyed by ${devcontainerId}). post-create.sh SEEDS it
|
||||
// ONCE from the /host/.claude-mem read-only stage when the volume is empty,
|
||||
// then the container owns its copy — rebuilds never clobber it, and changes
|
||||
// do NOT flow back to the host. (Container and host memory diverge from the
|
||||
// seed point on; that is the price of safe SQLite.) Removed by the same
|
||||
// `docker volume rm` reset flow as the other volumes — see README.
|
||||
"source=claude-mem-${devcontainerId},target=/home/node/.claude-mem,type=volume",
|
||||
|
||||
// Per-workspace SESSION volumes (mount group 6). These OVERLAY the config
|
||||
// volumes above at the session sub-paths so "resume my last session"
|
||||
// survives container recreation the way the seeded config dirs do.
|
||||
// They are SEPARATE volumes from <cli>-config-${devcontainerId}, so the
|
||||
// README's `docker volume rm <cli>-config-${devcontainerId}` re-login fix
|
||||
// does not touch them. post-create.sh chowns each one explicitly (its
|
||||
// `find -xdev` stops at the config-volume filesystem boundary and won't
|
||||
// descend into these).
|
||||
//
|
||||
// KEEP IN SYNC: if you add/rename/remove a session sub-path, update all
|
||||
// three places that name it — (1) the mount line here, (2) the DIRS array
|
||||
// in post-create.sh (so its chown covers the volume), and (3) the mount
|
||||
// table + Session-resume section in README.md.
|
||||
//
|
||||
// Claude: projects/ holds <encoded-cwd>/<uuid>.jsonl transcripts plus the
|
||||
// sessions-index.json that the `/resume` picker reads. The container cwd is
|
||||
// always /workspace (encodes to the `-workspace` subdir), so this is the
|
||||
// container's own slice only. Pure JSONL/JSON — no SQLite/WAL, so a volume
|
||||
// here is clean. `claude --resume` / `--continue` read straight from it.
|
||||
"source=${localWorkspaceFolderBasename}-claude-sessions-${devcontainerId},target=/home/node/.claude/projects,type=volume",
|
||||
// Codex: sessions/ holds YYYY/MM/DD/rollout-*.jsonl transcripts. The thread
|
||||
// index (state_5.sqlite + -wal/-shm) stays at the ~/.codex root on the
|
||||
// config volume — it is a single WAL file we must NOT split onto a host
|
||||
// bind. On a recreation that drops the config volume, that index is cleanly
|
||||
// absent and Codex rebuilds it from these rollout files on the next start
|
||||
// (backfill). See README for the one-time-rebuild and corruption caveats.
|
||||
"source=${localWorkspaceFolderBasename}-codex-sessions-${devcontainerId},target=/home/node/.codex/sessions,type=volume",
|
||||
// Cursor: chats/{hash}/{uuid}/store.db is one SQLite db per session, each in
|
||||
// its own leaf dir — a DIRECTORY volume keeps each db beside its -wal/-shm
|
||||
// sidecar, so there is no cross-filesystem single-file hazard. projects/
|
||||
// (agent-transcripts) is added too. cursor-agent's on-disk layout is
|
||||
// community-reverse-engineered (LOW confidence), so this is best-effort;
|
||||
// keeping it container-private means a wrong guess can't corrupt host state.
|
||||
"source=${localWorkspaceFolderBasename}-cursor-sessions-${devcontainerId},target=/home/node/.cursor/chats,type=volume",
|
||||
"source=${localWorkspaceFolderBasename}-cursor-projects-${devcontainerId},target=/home/node/.cursor/projects,type=volume",
|
||||
//
|
||||
// OPT-IN: host-shared sessions (like the plugin/skill binds). Uncomment to
|
||||
// put transcripts on the host — fully visible and portable, but they then
|
||||
// land on host disk and become a write-through surface for a compromised
|
||||
// in-container dependency, and the whole-dir binds expose OTHER projects'
|
||||
// transcripts to the container. Claude is scoped to /workspace's encoded
|
||||
// subdir to limit that leak; Codex/Cursor stores are not project-scoped, so
|
||||
// they expose every project. If you enable these, also add the matching
|
||||
// source dirs to ensure-host-config-dirs.cjs — to its DIRS array (these are
|
||||
// directory binds), not FILES (which is only for single-file bind sources
|
||||
// like ~/.claude.json) — so Docker can resolve the binds. Read README
|
||||
// § "Session resume across recreation" first.
|
||||
// "source=${localEnv:HOME}/.claude/projects/-workspace,target=/home/node/.claude/projects/-workspace,type=bind",
|
||||
// "source=${localEnv:HOME}/.codex/sessions,target=/home/node/.codex/sessions,type=bind",
|
||||
// "source=${localEnv:HOME}/.cursor/chats,target=/home/node/.cursor/chats,type=bind",
|
||||
// "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
|
||||
// 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.
|
||||
"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
|
||||
// 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",
|
||||
|
||||
// NO read-write bind mounts for the shareable subfolders. They USED to be
|
||||
// bound here (Claude skills/agents/memory/commands/plugins; Codex plugins/
|
||||
// prompts/memories/skills; Cursor rules/commands/agents/skills/plugins) so
|
||||
// host and container shared one copy both ways. That bind was a write-through
|
||||
// hole: a compromised in-container dependency could drop a malicious skill,
|
||||
// agent, command, or plugin straight onto the host, which the next HOST
|
||||
// session would auto-load. To protect the host's on-disk setup, these are
|
||||
// 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.
|
||||
// - 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.)
|
||||
// - The plugin REGISTRY JSONs (Claude known_marketplaces.json /
|
||||
// installed_plugins.json / plugin-catalog-cache.json; Cursor
|
||||
// installed_plugins.json) carry absolute OS-native paths, so they can't
|
||||
// be copied verbatim — post-create.sh translates their paths to the
|
||||
// container's Linux paths, also seed-once, alongside the cache/ copy so
|
||||
// the two stay consistent. Codex needs no translation (config.toml holds
|
||||
// git URLs, not paths), so its whole plugins/ dir is copied as-is.
|
||||
// - The old read-only-stage-plus-symlink design failed `/plugin marketplace
|
||||
// add` in the container with EROFS; copy-into-a-writable-volume avoids
|
||||
// that — the container writes to its own copy, not a read-only mount.
|
||||
//
|
||||
// SINGLE-FILE binds for settings.json, .claude.json, and config.toml are
|
||||
// deliberately ABSENT. On Docker Desktop Windows the named volume sits on
|
||||
// one filesystem (ext4, /dev/sdd) and a single-file bind from the host sits
|
||||
// on another (the 9p drvfs share). Apps save a config by writing `foo.tmp`
|
||||
// and renaming it over `foo`. That rename can't cross filesystems: it hits
|
||||
// the EXDEV error and fails with `Device or resource busy` or `inter-device
|
||||
// move failed`. Codex's TUI shows this as "config/batchWrite failed in
|
||||
// TUI"; Claude just silently loses the write the same way. Instead, we use
|
||||
// a read-only host stage at /host/.claude, and post-create.sh copies these
|
||||
// files into the named volume on every container-create. Host changes show
|
||||
// up on the next rebuild. Container changes stay inside the container until
|
||||
// a rebuild.
|
||||
"source=${localEnv:HOME}/.claude.json,target=/host/.claude.json,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.config/git,target=/home/node/.config/git,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,readonly",
|
||||
// gh uses the COPY-INTO-VOLUME model (read-only host stage at
|
||||
// /host/.config/gh + the gh-config named volume above). post-create.sh seeds
|
||||
// hosts.yml/config.yml from this stage into the volume on create, so the
|
||||
// container has a writable copy: an in-container `gh auth login` persists
|
||||
// across rebuilds, and nothing is ever written back to the host because this
|
||||
// stage is read-only. docker stays a direct READ-ONLY bind: the container
|
||||
// reads your EXISTING host login (the common case), and a compromised
|
||||
// in-container dependency can't rewrite ~/.docker/config.json (the registry
|
||||
// credHelper, which points at a binary). A `docker login` run inside the
|
||||
// container won't persist back to the host — re-run it on the host, or give
|
||||
// docker the same copy-into-volume treatment as gh. See README § Trust boundary.
|
||||
"source=${localEnv:HOME}/.config/gh,target=/host/.config/gh,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.docker,target=/home/node/.docker,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.aws,target=/home/node/.aws,type=bind,readonly",
|
||||
"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=${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",
|
||||
"source=${localWorkspaceFolderBasename}-gitnexus-shared-node-modules-${devcontainerId},target=/workspace/gitnexus-shared/node_modules,type=volume"
|
||||
],
|
||||
|
||||
// Interactive login is the default way to authenticate for all three CLIs.
|
||||
// Credentials live in the per-container named volumes (claude-config,
|
||||
// codex-config, cursor-config), NOT in the host bind mounts. They are copied
|
||||
// from the read-only /host/.<cli> stage into the volume on container-create.
|
||||
// Single-file binds would break on Docker Desktop Windows (the EXDEV error).
|
||||
// Shareable content (plugins, skills, agents, memory, commands) is NOT bound
|
||||
// read-write — it is copied once from the read-only /host/.<cli> stage into
|
||||
// the volume on first create, so the host's on-disk setup stays protected.
|
||||
// API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, CURSOR_API_KEY) are NOT
|
||||
// injected via containerEnv. `${localEnv:VAR}` turns an unset host var into
|
||||
// an empty string. Cursor in particular treats `CURSOR_API_KEY=""` as "use
|
||||
// this empty key" instead of "fall back to the stored login", which would
|
||||
// silently break `cursor-agent login`. If you need API-key auth, `export`
|
||||
// the var in your container shell, or carry it in your VS Code dotfiles repo
|
||||
// (see .devcontainer/README.md).
|
||||
// CLAUDE_CONFIG_DIR is left unset on purpose. The Claude default is
|
||||
// `$HOME/.claude` (= `/home/node/.claude`), which is exactly where the
|
||||
// claude-config named volume mounts. Setting the env var would change which
|
||||
// file Claude reads `hasCompletedOnboarding` from. With the var set, Claude
|
||||
// reads `$CLAUDE_CONFIG_DIR/.claude.json`, the small identity file. Without
|
||||
// it, Claude reads `$HOME/.claude.json`, the big onboarding-state file that
|
||||
// actually holds `hasCompletedOnboarding`, the user-scope MCP config, and
|
||||
// per-project trust. Leaving the var unset matches host behavior. It also
|
||||
// lets post-create.sh's sync of `$HOME/.claude.json` skip the setup wizard
|
||||
// on every container-create.
|
||||
//
|
||||
// CODEX_HOME is kept even though it matches the Codex default, as a canary.
|
||||
// If we ever move the Codex named volume target, this env var makes the
|
||||
// dependency explicit instead of silently following the default.
|
||||
"containerEnv": {
|
||||
"CODEX_HOME": "/home/node/.codex",
|
||||
"DISABLE_AUTOUPDATER": "1",
|
||||
// post-create.sh removes `installMethod` from the seeded ~/.claude.json so
|
||||
// the npm-global binary detects its own install method. This is a backup
|
||||
// safeguard for Claude Code issue #17289. The install-checks routine probes
|
||||
// ~/.local/bin/claude just because that directory EXISTS. It does exist
|
||||
// here, because Cursor drops agent and cursor-agent symlinks there. So even
|
||||
// when installMethod is non-native, the routine reports a false "claude
|
||||
// command not found at ~/.local/bin/claude". DISABLE_AUTOUPDATER does NOT
|
||||
// turn that routine off. DISABLE_INSTALLATION_CHECKS is its dedicated kill
|
||||
// switch.
|
||||
"DISABLE_INSTALLATION_CHECKS": "1",
|
||||
"HISTFILE": "/commandhistory/.zsh_history"
|
||||
},
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"anthropic.claude-code",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"eamodio.gitlens"
|
||||
],
|
||||
"settings": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||
"terminal.integrated.profiles.linux": {
|
||||
"bash": { "path": "bash", "icon": "terminal-bash" },
|
||||
"zsh": { "path": "zsh" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Do not remap port 4747 (gitnexus serve). gitnexus-web hardcodes
|
||||
// http://localhost:4747 as its default backend URL.
|
||||
"forwardPorts": [5173, 4747, 4173],
|
||||
"portsAttributes": {
|
||||
"5173": {
|
||||
"label": "Vite dev (gitnexus-web)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"4747": {
|
||||
"label": "gitnexus serve HTTP API",
|
||||
"onAutoForward": "notify",
|
||||
"requireLocalPort": true
|
||||
},
|
||||
"4173": {
|
||||
"label": "Static web (Vite preview)",
|
||||
"onAutoForward": "silent"
|
||||
}
|
||||
},
|
||||
|
||||
// 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.
|
||||
// - `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
|
||||
// executable bit surviving the workspace bind mount.
|
||||
"updateContentCommand": "bash .devcontainer/install-deps.sh",
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh"
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
// This runs on the HOST, not inside the container, before the dev container is
|
||||
// created. devcontainer.json calls it via `initializeCommand`. Its job is to
|
||||
// make sure the bind-mount source folders listed in devcontainer.json already
|
||||
// exist on the host. Docker rejects a bind mount when its source is missing,
|
||||
// which happens if a CLI has never been used.
|
||||
//
|
||||
// It works on every platform. `os.homedir()` returns the home folder ($HOME on
|
||||
// Mac/Linux, %USERPROFILE% on Windows). `fs.mkdirSync({recursive: true})`
|
||||
// creates folders. It is safe to run repeatedly: a path that already exists is
|
||||
// left alone. We deliberately do NOT handle `~/.gitconfig` here. VS Code's Dev
|
||||
// Containers extension copies the host gitconfig into the container when you
|
||||
// attach, and a bind mount fights with that, so it was removed.
|
||||
//
|
||||
// The path-creating logic is exported (ensurePaths/DIRS/FILES) so tests can use
|
||||
// it. The Windows HOME side effect only runs when this file is run directly as
|
||||
// the initializeCommand. That keeps tests able to drive it against a temp dir
|
||||
// without touching the real home or calling `setx`.
|
||||
//
|
||||
// Host prerequisite: Node.js must be on PATH. That is the only host requirement
|
||||
// beyond Docker Desktop and the VS Code Dev Containers extension. Everything
|
||||
// else runs inside the container.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Folders that are bind-mount sources in devcontainer.json. Docker rejects a
|
||||
// bind mount whose source is missing, so we create each one.
|
||||
//
|
||||
// We create the TOP per-CLI folders (~/.claude, ~/.codex, ~/.cursor) and
|
||||
// ~/.claude-mem. These back the /host/.<cli> and /host/.claude-mem read-only
|
||||
// STAGE mounts that post-create.sh copies from on container-create. We do NOT
|
||||
// create the shareable subfolders (skills/agents/plugins/memory/commands/...)
|
||||
// here anymore: they used to be read-write bind sources, but they are now
|
||||
// copied once out of the read-only stage into the per-container volume, so they
|
||||
// are no longer bind sources and pre-creating empty ones would needlessly write
|
||||
// into the host of someone who never used that CLI. post-create.sh's seed step
|
||||
// simply skips any subfolder the host doesn't have. The read-only stage bind is
|
||||
// the whole ~/.<cli> dir, so whatever shareable subfolders DO exist are visible
|
||||
// to the seed without being listed here.
|
||||
const DIRS = [
|
||||
'.claude',
|
||||
// claude-mem store ($HOME/.claude-mem). A SEPARATE top-level folder from
|
||||
// ~/.claude, holding claude-mem's SQLite DB + Chroma vector store. It is NOT
|
||||
// bind-mounted (a multi-GB SQLite/WAL store is unsafe over a 9p bind on Docker
|
||||
// Desktop Windows). post-create.sh SEEDS it once into a per-container named
|
||||
// volume from the /host/.claude-mem read-only stage. We create the source here
|
||||
// so that stage bind resolves even for a host that never ran claude-mem
|
||||
// (Docker rejects a missing bind source); the seed then finds no DB to copy
|
||||
// and the container starts with empty memory.
|
||||
'.claude-mem',
|
||||
'.codex',
|
||||
'.cursor',
|
||||
'.ssh',
|
||||
'.docker',
|
||||
'.aws',
|
||||
'.azure',
|
||||
path.join('.config', 'gh'),
|
||||
path.join('.config', 'git'),
|
||||
];
|
||||
|
||||
// Files to pre-create. Only `~/.claude.json` is created here. It is the one
|
||||
// source that is bound as a single file (read-only at /host/.claude.json). If
|
||||
// that source is missing, Docker would create a FOLDER in its place, so it has
|
||||
// to exist as a file first. `~/.claude/settings.json` and
|
||||
// `~/.codex/config.toml` are NOT single-file binds. post-create.sh copies them
|
||||
// out of the /host/.<cli> read-only folder stage, and `sync_from_host` simply
|
||||
// does nothing when they are absent (the `[ -f ]` guard). Creating them here
|
||||
// would needlessly write to the host of someone who never ran that CLI, so we
|
||||
// don't.
|
||||
const FILES = ['.claude.json'];
|
||||
|
||||
// Create every folder and touch every file under `home`. Safe to run again:
|
||||
// an existing path is left untouched. The root is a parameter so tests can run
|
||||
// it against a temp dir.
|
||||
function ensurePaths(home, dirs = DIRS, files = FILES) {
|
||||
for (const dir of dirs) {
|
||||
const full = path.join(home, dir);
|
||||
if (!fs.existsSync(full)) {
|
||||
fs.mkdirSync(full, { recursive: true });
|
||||
}
|
||||
}
|
||||
for (const file of files) {
|
||||
const full = path.join(home, file);
|
||||
if (!fs.existsSync(full)) {
|
||||
fs.closeSync(fs.openSync(full, 'a'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ensurePaths, DIRS, FILES };
|
||||
|
||||
if (require.main === module) {
|
||||
// One-time setup for native Windows. VS Code fills in the bind-mount sources
|
||||
// using `${localEnv:HOME}`, which reads its own process environment. Windows
|
||||
// does not set `HOME` by default; it uses `USERPROFILE`. With no `HOME`, the
|
||||
// bind sources shrink to filesystem-root paths (`/.claude`, `/.codex`, ...)
|
||||
// and Docker rejects them with `bind source path does not exist`.
|
||||
//
|
||||
// The fix is to save `HOME=%USERPROFILE%` into the user's environment with
|
||||
// `setx`. `setx` writes to `HKCU\Environment`. Every process the user starts
|
||||
// after that inherits the new value, including VS Code once it restarts. The
|
||||
// current VS Code process can't see the change, because its environment was
|
||||
// set when it launched. So we tell the user to restart VS Code once.
|
||||
//
|
||||
// Later runs see that `HOME` is set, skip this block, and continue normally.
|
||||
// Mac, Linux, and WSL hosts already have `HOME` set by the shell, so this
|
||||
// block does nothing on those platforms.
|
||||
if (process.platform === 'win32' && !process.env.HOME) {
|
||||
const userprofile = process.env.USERPROFILE;
|
||||
if (userprofile) {
|
||||
try {
|
||||
require('child_process').execFileSync('setx', ['HOME', userprofile], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
console.error('');
|
||||
console.error('='.repeat(70));
|
||||
console.error(' GitNexus devcontainer one-time Windows setup');
|
||||
console.error('='.repeat(70));
|
||||
console.error('');
|
||||
console.error(`HOME has been set to %USERPROFILE% (${userprofile}).`);
|
||||
console.error("VS Code reads this at startup, so the current session can't pick it up.");
|
||||
console.error('');
|
||||
console.error(' 1. Close ALL VS Code windows (File > Exit, not just the window).');
|
||||
console.error(' 2. Reopen VS Code, open this folder, and re-run Reopen in Container.');
|
||||
console.error('');
|
||||
console.error('This is a one-time setup. Subsequent rebuilds work normally.');
|
||||
console.error('='.repeat(70));
|
||||
process.exit(1);
|
||||
} catch (err) {
|
||||
console.error('ERROR: failed to set HOME automatically: ' + err.message);
|
||||
console.error('');
|
||||
console.error('Run this in a Windows shell, then restart VS Code:');
|
||||
console.error(' setx HOME "%USERPROFILE%"');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error('ERROR: neither HOME nor USERPROFILE is set on this host.');
|
||||
console.error('');
|
||||
console.error('Set HOME to your user profile directory and restart VS Code:');
|
||||
console.error(' setx HOME "%USERPROFILE%"');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
ensurePaths(os.homedir());
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/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
|
||||
# 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
|
||||
# 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"
|
||||
# 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
|
||||
# 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
|
||||
# symlink itself instead of following it to its target. Without `-h`, a symlink
|
||||
# in the tree (one a dependency's postinstall drops, or a dangling
|
||||
# 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.
|
||||
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 {} +
|
||||
done
|
||||
|
||||
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`.
|
||||
# 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] 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] done"
|
||||
@@ -1,310 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Devcontainer postCreate script. It runs once, right after the container is
|
||||
# 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.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "[post-create] 1/4: chown AI CLI named-volume mount points"
|
||||
# Fix ownership on the named volumes (~/.claude, ~/.codex, ~/.cursor,
|
||||
# /commandhistory). When they first mount, they take the user ID baked into the
|
||||
# image, before any realignment. Then `updateRemoteUserUID: true` shifts the
|
||||
# `node` user to a new ID. Now the volumes are owned by the old, stale ID, and
|
||||
# writes into them fail. (~/.local is a directory in the image, not a volume.
|
||||
# We chown it too, just to be safe.) install-deps.sh fixes the workspace side.
|
||||
# This script fixes the AI CLI side, so each lifecycle hook handles its own part.
|
||||
#
|
||||
# There are two separate guards here, and they do different things. `-xdev`
|
||||
# keeps find from descending into other filesystems. The shareable dirs (skills,
|
||||
# agents, plugins, memory, commands, prompts, rules) are no longer host bind
|
||||
# mounts — they now live INSIDE the config volume (seeded in step 3/4), so
|
||||
# `-xdev` correctly walks and chowns them as the container-private volume files
|
||||
# they are. What `-xdev` still stops at are the SESSION volumes (mount group 6),
|
||||
# which remain separate filesystems mounted at sub-paths (see below). `-h` tells
|
||||
# chown to act on a symlink ITSELF instead of following it, so it never lands on
|
||||
# a target across a filesystem boundary and never aborts on a broken symlink
|
||||
# under `set -e` (a legacy Option-B symlink could still exist on a carried-over
|
||||
# volume). For regular files and directories `-h` does nothing extra.
|
||||
#
|
||||
# The session volumes (mount group 6: .claude/projects, .codex/sessions,
|
||||
# .cursor/chats, .cursor/projects) are their OWN filesystems mounted at
|
||||
# sub-paths, so `-xdev` rooted at the config-volume parent deliberately skips
|
||||
# them. That is why each one is listed as its own root below: rooted there,
|
||||
# `-xdev` walks just that volume and chowns its top level, so the CLI's first
|
||||
# write doesn't hit EACCES on a stale image UID. These are container-private
|
||||
# volumes, not the host's own files — the read-only /host/.<cli> stages we copy
|
||||
# from are mounted elsewhere and are never chowned.
|
||||
DIRS=(
|
||||
/home/node/.claude
|
||||
/home/node/.claude/projects
|
||||
/home/node/.codex
|
||||
/home/node/.codex/sessions
|
||||
/home/node/.cursor
|
||||
/home/node/.cursor/chats
|
||||
/home/node/.cursor/projects
|
||||
/home/node/.config/gh
|
||||
/home/node/.local
|
||||
/commandhistory
|
||||
)
|
||||
# claude-mem volume: chown it ONLY on first create (its completion sentinel is
|
||||
# absent). The step-4/4 seed copies the store as the node user, so a populated
|
||||
# claude-mem volume is already node-owned on every later rebuild — a recursive
|
||||
# `find` over a multi-GB store (the 7GB+ DB plus the Chroma index) just to
|
||||
# re-stamp ownership that is already correct would add real latency to every
|
||||
# rebuild for nothing. On first create the volume is empty, so this chown of the
|
||||
# bare mount point is trivial and lets the seed write into it.
|
||||
[ -f /home/node/.claude-mem/.claude-mem-seeded ] || DIRS+=(/home/node/.claude-mem)
|
||||
for d in "${DIRS[@]}"; do
|
||||
# Skip a root that isn't present rather than aborting the whole run under
|
||||
# `set -e`. Docker creates every declared volume's mount point before this
|
||||
# script runs, so in the normal case all roots exist and this is a no-op.
|
||||
# The guard matters only if a session volume is later removed from
|
||||
# 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 {} +
|
||||
done
|
||||
|
||||
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
|
||||
# (e.g. /home/node/.claude/plugins -> /host/.claude/plugins). A write through
|
||||
# such a symlink would land on a read-only host file and fail. Delete any that
|
||||
# survive on a carried-over volume. The shareable dirs are now real directories
|
||||
# in the named volume, seeded from the host in step 3/4 below.
|
||||
for p in plugins skills agents memory commands; do
|
||||
[ -L "/home/node/.claude/$p" ] && rm "/home/node/.claude/$p"
|
||||
done
|
||||
for p in plugins prompts memories skills config.toml; do
|
||||
[ -L "/home/node/.codex/$p" ] && rm "/home/node/.codex/$p"
|
||||
done
|
||||
for p in plugins rules commands agents skills; do
|
||||
[ -L "/home/node/.cursor/$p" ] && rm "/home/node/.cursor/$p"
|
||||
done
|
||||
mkdir -p /home/node/.claude/plugins /home/node/.cursor/plugins
|
||||
|
||||
# Shareable content (skills, agents, plugins, memory, commands, prompts, rules)
|
||||
# is NO LONGER bind-mounted. It is COPIED once from the read-only host stage into
|
||||
# the named volume in step 3/4 below, so a compromised in-container dependency
|
||||
# can't write through to the host's on-disk CLI setup. This step handles only the
|
||||
# credentials, identity, and single config files. Those stay per-container in the
|
||||
# named volume and are COPIED from the host once when the container is created:
|
||||
# - .credentials.json (Claude OAuth tokens)
|
||||
# - .claude/.claude.json (Claude identity: userID, oauthAccount, and
|
||||
# migration tracking — a different file from $HOME/.claude.json)
|
||||
# - settings.json (Claude), config.toml (Codex), mcp.json (Cursor). These are
|
||||
# single config files, and single files can't be bind-mounted on Windows
|
||||
# (the EXDEV error explained below).
|
||||
# - auth.json (Codex), cli-config.json (Cursor — which mixes auth and settings)
|
||||
# - the plugin registry JSONs that contain absolute paths (Claude + Cursor).
|
||||
# Those are translated below.
|
||||
#
|
||||
# How the sync behaves: it ALWAYS overwrites from the host when the container is
|
||||
# created. A fresh container then starts logged in as the host's user, if the
|
||||
# host had credentials. From that point the container manages its own login,
|
||||
# until the next rebuild copies the host files again. Logging out inside the
|
||||
# container does NOT log out the host. Per-container login is the goal, and
|
||||
# bind-mounting these files would instead make a logout shared between both.
|
||||
|
||||
sync_from_host() {
|
||||
local src=$1
|
||||
local dst=$2
|
||||
local mode=${3:-600}
|
||||
if [ -f "$src" ]; then
|
||||
rm -f "$dst"
|
||||
cp "$src" "$dst"
|
||||
chmod "$mode" "$dst"
|
||||
fi
|
||||
}
|
||||
|
||||
sync_from_host \
|
||||
/host/.claude/.credentials.json /home/node/.claude/.credentials.json
|
||||
sync_from_host \
|
||||
/host/.claude/.claude.json /home/node/.claude/.claude.json 644
|
||||
|
||||
# These config files are COPIED from the host, not bind-mounted. We tried
|
||||
# bind-mounting them as single files and it didn't work. On Docker Desktop for
|
||||
# Windows the named volume (ext4) and the host bind mount (9p drvfs) are
|
||||
# different filesystems. Apps save a config by writing a temp file and renaming
|
||||
# it over the real one, and that rename fails across filesystems (the "EXDEV" or
|
||||
# "Device or resource busy" error). So copy the host's version into the named
|
||||
# volume when the container is created. The container can then rewrite it freely
|
||||
# until the next rebuild copies the host version again.
|
||||
sync_from_host /host/.claude/settings.json /home/node/.claude/settings.json 644
|
||||
sync_from_host /host/.codex/config.toml /home/node/.codex/config.toml 644
|
||||
|
||||
# Seed $HOME/.claude.json from the host, but NOT as a straight copy. That file
|
||||
# mixes two kinds of state. Some is portable account and onboarding state we
|
||||
# want to keep: hasCompletedOnboarding, oauthAccount, userID, projects,
|
||||
# tipsHistory. The rest describes how Claude is installed on the host, and that
|
||||
# part is never valid here. This image installs Claude with `npm install -g`,
|
||||
# but the host's `installMethod` (for example "native") makes Claude look for
|
||||
# ~/.local/bin/claude and fail with
|
||||
# "claude command not found at /home/node/.local/bin/claude". The fix strips the
|
||||
# machine-specific fields and forces hasCompletedOnboarding, while handling a
|
||||
# host file that isn't a JSON object. That logic lives in seed-claude-config.cjs
|
||||
# so it can be unit-tested and prettier-checked
|
||||
# (translate-plugin-registries.test.cjs).
|
||||
node "$SCRIPT_DIR/seed-claude-config.cjs"
|
||||
|
||||
# Codex auth. Some hosts store credentials in the OS keyring instead of on disk
|
||||
# (`cli_auth_credentials_store = "keyring"`, the default on macOS). Those hosts
|
||||
# have no auth.json file, so the copy below quietly does nothing. In that case,
|
||||
# log in inside the container with `codex login --device-auth`.
|
||||
sync_from_host \
|
||||
/host/.codex/auth.json /home/node/.codex/auth.json
|
||||
|
||||
# Cursor CLI. Its cli-config.json holds both auth and settings in one file.
|
||||
# Cursor has known upstream problems authenticating inside Docker, even when the
|
||||
# config is copied correctly. If `cursor-agent` reports auth errors after the
|
||||
# copy, run `cursor-agent login` again inside the container. mcp.json (Cursor's
|
||||
# MCP server config) is also a single file, so it is copied on create rather
|
||||
# than bind-mounted, for the same EXDEV reason as above. hooks.json is left out
|
||||
# on purpose. Cursor hooks run shell commands, and sharing the host's hooks
|
||||
# would widen the supply-chain attack surface inside the container. Copy it in
|
||||
# yourself if you want the host's hooks in the container.
|
||||
sync_from_host \
|
||||
/host/.cursor/cli-config.json /home/node/.cursor/cli-config.json
|
||||
sync_from_host \
|
||||
/host/.cursor/mcp.json /home/node/.cursor/mcp.json 644
|
||||
|
||||
# gh CLI auth + settings. Same copy-into-volume model as the credentials above:
|
||||
# hosts.yml holds the GitHub token (mode 600), config.yml holds settings (644).
|
||||
# Copied from the read-only /host/.config/gh stage into the gh-config named
|
||||
# volume on create. Because the volume is writable, an in-container
|
||||
# `gh auth login` / `gh auth refresh` persists across rebuilds; because the
|
||||
# stage is read-only, nothing flows back to the host. If the host had no login,
|
||||
# both copies quietly no-op and whatever the container wrote is kept.
|
||||
sync_from_host /host/.config/gh/hosts.yml /home/node/.config/gh/hosts.yml
|
||||
sync_from_host /host/.config/gh/config.yml /home/node/.config/gh/config.yml 644
|
||||
|
||||
echo "[post-create] 3/4: seed shareable config dirs from host (first create only)"
|
||||
# The shareable dirs (Claude skills/agents/memory/commands/plugins; Codex
|
||||
# plugins/prompts/memories/skills; Cursor rules/commands/agents/skills/plugins)
|
||||
# used to be read-write host bind mounts, so a write inside the container landed
|
||||
# directly on the host's files. That exposed the host's on-disk CLI setup: a
|
||||
# compromised workspace dependency running in the container could drop a malicious
|
||||
# skill, agent, command, or plugin into the host's folders, which the next HOST
|
||||
# session would then auto-load. To protect the host, these are no longer bound.
|
||||
# Instead we COPY them once from the read-only /host/.<cli> stage into the
|
||||
# per-container named volume, exactly like claude-mem (step 4/4) and the session
|
||||
# volumes. The container gets its own writable copy and can NEVER write back to
|
||||
# the host. The container also avoids the old read-only-stage EROFS failure,
|
||||
# because it writes to its own volume copy, not a read-only mount.
|
||||
#
|
||||
# Seed-once, persist: a per-CLI marker file records that the copy has happened.
|
||||
# On the first container-create the marker is absent, so we copy; on every later
|
||||
# rebuild the marker is present, so we skip and keep whatever the container has
|
||||
# accumulated. Host edits made AFTER the first create do NOT reach the container
|
||||
# until you remove the config volume and rebuild (see README § Rebuild/reset).
|
||||
seed_shareable() {
|
||||
# seed_shareable <cli> <subdir>...: copy each /host/.<cli>/<subdir> into the
|
||||
# named volume, once. Skips a subdir the host doesn't have. We use `cp -r`,
|
||||
# NOT `cp -a`/`cp -p`: this script runs as the non-root node user, and the
|
||||
# host-stage files are owned by a different UID, so trying to preserve
|
||||
# ownership would fail with EPERM and abort the run under `set -e` (the same
|
||||
# reason sync_from_host uses plain cp). `cp -r` copies contents owned by node
|
||||
# — exactly what we want — and preserves symlinks as symlinks (GNU default).
|
||||
local cli=$1
|
||||
shift
|
||||
local marker="/home/node/.$cli/.devcontainer-shareable-seeded"
|
||||
[ -f "$marker" ] && return 0
|
||||
for sub in "$@"; do
|
||||
local src="/host/.$cli/$sub"
|
||||
local dst="/home/node/.$cli/$sub"
|
||||
[ -d "$src" ] || continue
|
||||
mkdir -p "$dst"
|
||||
cp -r "$src/." "$dst/"
|
||||
done
|
||||
}
|
||||
|
||||
# Decide which plugin registries to translate BEFORE seeding sets the markers.
|
||||
# We translate only a CLI being seeded this run, so a plugin installed inside the
|
||||
# container isn't overwritten by the host's registry on a later rebuild. Codex
|
||||
# has no path-bearing registry (config.toml holds git URLs), so it's never here.
|
||||
TRANSLATE_CLIS=()
|
||||
[ -f /home/node/.claude/.devcontainer-shareable-seeded ] || TRANSLATE_CLIS+=(claude)
|
||||
[ -f /home/node/.cursor/.devcontainer-shareable-seeded ] || TRANSLATE_CLIS+=(cursor)
|
||||
|
||||
seed_shareable claude skills agents memory commands plugins/marketplaces plugins/cache
|
||||
seed_shareable codex plugins prompts memories skills
|
||||
seed_shareable cursor rules commands agents skills plugins/marketplaces plugins/local
|
||||
|
||||
# Translate the path-bearing plugin registries (Claude + Cursor) for the CLIs we
|
||||
# just seeded. They store absolute, OS-native install paths
|
||||
# (`C:\Users\X\.claude\plugins\...` on Windows), which the Linux container can't
|
||||
# resolve — it would fail with `cache-miss`. translate-plugin-registries.cjs
|
||||
# rewrites those to the container's paths and writes the result into the volume.
|
||||
if [ "${#TRANSLATE_CLIS[@]}" -gt 0 ]; then
|
||||
node "$SCRIPT_DIR/translate-plugin-registries.cjs" "${TRANSLATE_CLIS[@]}"
|
||||
fi
|
||||
|
||||
# Record that each CLI's shareable surface is seeded, so later rebuilds keep the
|
||||
# container's copy. Touch even when the host had nothing to copy — an empty CLI
|
||||
# is still "seeded", and we don't want to re-scan the host on every rebuild.
|
||||
#
|
||||
# ORDERING INVARIANT — do NOT move these touches earlier (e.g. into
|
||||
# seed_shareable per-CLI). The markers must be written only AFTER the registry
|
||||
# translation above, because seed (cache copy) and translate (registry rewrite)
|
||||
# are logically atomic: a marker set between them would let a later rebuild skip
|
||||
# translation for an already-seeded CLI, leaving its cache/ in place but its
|
||||
# registry still pointing at host paths (`cache-miss`). Writing all markers here,
|
||||
# after translate, means any abort mid-seed leaves NO markers, so the next create
|
||||
# re-runs the whole seed+translate. The cost is re-copying an already-copied CLI
|
||||
# on retry; `cp -r` overwrites in place, so that is idempotent and cheap relative
|
||||
# to a broken plugin registry.
|
||||
for cli in claude codex cursor; do
|
||||
touch "/home/node/.$cli/.devcontainer-shareable-seeded"
|
||||
done
|
||||
|
||||
echo "[post-create] 4/4: seed claude-mem store from host (first create only)"
|
||||
# claude-mem keeps its memory in $HOME/.claude-mem — a SQLite DB (claude-mem.db
|
||||
# plus -wal/-shm) and a Chroma vector store (chroma/chroma.sqlite3 + HNSW index
|
||||
# binaries). It is mounted as a per-container named volume, NOT a host bind:
|
||||
# pushing a multi-GB SQLite/WAL store over the 9p/virtiofs bind risks unreliable
|
||||
# fcntl locking and corruption, especially if claude-mem ran on the host and in
|
||||
# the container against the same files at once (see devcontainer.json).
|
||||
#
|
||||
# So seed it ONCE, then let the container own its copy. On every later rebuild
|
||||
# we skip the copy and keep whatever the container has accumulated since —
|
||||
# rebuilds never clobber it. The container's memory and the host's diverge from
|
||||
# this seed point on; that is the deliberate cost of keeping SQLite off a shared
|
||||
# bind. To re-seed from the host, remove the volume (`docker volume rm
|
||||
# claude-mem-<id>`) and rebuild.
|
||||
#
|
||||
# The skip guard is a COMPLETION SENTINEL (.claude-mem-seeded), NOT the presence
|
||||
# of claude-mem.db. Keying on the DB file would be a trap: a multi-GB `cp -r` can
|
||||
# be interrupted (disk full, I/O error) and abort the script under `set -e`,
|
||||
# leaving a PARTIAL claude-mem.db behind. The next create would then see that
|
||||
# truncated file and treat the store as "already seeded", sticking the container
|
||||
# with a corrupt DB forever. With a sentinel touched only AFTER `cp` returns 0,
|
||||
# an interrupted seed leaves no sentinel; the next create clears the half-copied
|
||||
# store and retries cleanly. CONSISTENCY: copying a live WAL database is only
|
||||
# crash-consistent if claude-mem is NOT writing on the host during the copy — do
|
||||
# not run claude-mem on the host during a first-create or a re-seed rebuild.
|
||||
#
|
||||
# `cp -r` (not `cp -a`/`cp -p`) copies the DB together with its -wal/-shm
|
||||
# sidecars in one pass. We avoid preserving ownership for the same reason as the
|
||||
# shareable seed above: this runs as the non-root node user against host-owned
|
||||
# files, so `cp -a` would fail with EPERM and abort under `set -e`. `cp -r`
|
||||
# leaves the copies owned by node. The host stage is read-only, so this can
|
||||
# never write back to the host's live DB.
|
||||
if [ -f /host/.claude-mem/claude-mem.db ] && [ ! -f /home/node/.claude-mem/.claude-mem-seeded ]; then
|
||||
echo "[post-create] seeding ~/.claude-mem from host (one-time copy, may be several GB)"
|
||||
# Clear any partial store left by a previously-interrupted seed (mindepth 1
|
||||
# so the volume mount point itself is never removed), then copy and only then
|
||||
# write the sentinel. A partial store is node-owned (cp runs as node, and
|
||||
# step 1 re-chowns the volume whenever the sentinel is absent), so no sudo.
|
||||
find /home/node/.claude-mem -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
cp -r /host/.claude-mem/. /home/node/.claude-mem/
|
||||
touch /home/node/.claude-mem/.claude-mem-seeded
|
||||
else
|
||||
echo "[post-create] skipping claude-mem seed (already seeded, or host has no store)"
|
||||
fi
|
||||
|
||||
echo "[post-create] done"
|
||||
@@ -1,83 +0,0 @@
|
||||
// Builds the container's $HOME/.claude.json from the host's copy. It does NOT
|
||||
// copy the host file verbatim. The host's ~/.claude.json holds two kinds of
|
||||
// data. Some is portable account and onboarding state: hasCompletedOnboarding,
|
||||
// oauthAccount, userID, projects, tipsHistory. We keep that. The rest tracks
|
||||
// how Claude was installed on the host machine, and that is never right inside
|
||||
// this container.
|
||||
//
|
||||
// Here is why the install fields break things. The image installs Claude with
|
||||
// `npm install -g`. But if the host's `installMethod` says something like
|
||||
// "native", Claude looks for ~/.local/bin/claude and fails with
|
||||
// "claude command not found at /home/node/.local/bin/claude". So we drop the
|
||||
// install and machine fields. With them gone, the npm-global binary detects its
|
||||
// own install method. We also force hasCompletedOnboarding so the setup wizard
|
||||
// is skipped, even when the host has never run Claude before.
|
||||
//
|
||||
// This logic was pulled out of a heredoc in post-create.sh. As its own file the
|
||||
// transform can be unit-tested and prettier-checked (see seed-claude-config.test
|
||||
// via the translate-plugin-registries test harness). DISABLE_AUTOUPDATER=1 in
|
||||
// containerEnv already stops runtime updates. This file only quiets the doctor
|
||||
// mismatch and the native-path probe.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Fields that describe how Claude was installed on the host machine. They are
|
||||
// never valid in an `npm install -g` container. Removing them lets Claude
|
||||
// detect the npm-global install on its own.
|
||||
const MACHINE_FIELDS = [
|
||||
'installMethod',
|
||||
'autoUpdates',
|
||||
'autoUpdatesProtectedForNative',
|
||||
'shiftEnterKeyBindingInstalled',
|
||||
];
|
||||
|
||||
// Pure transform: take whatever the host file parsed to and return a config
|
||||
// object suitable for the container. It also guards against a host file that is
|
||||
// valid JSON but not an object. A bare number, string, or array would pass the
|
||||
// parse try/catch. Then the field deletes would do nothing, the
|
||||
// hasCompletedOnboarding assignment would silently fail, and onboarding would
|
||||
// trigger again on every rebuild. The guard replaces such a value with {}.
|
||||
function sanitizeClaudeConfig(parsed) {
|
||||
let cfg = parsed;
|
||||
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
||||
cfg = {};
|
||||
}
|
||||
for (const k of MACHINE_FIELDS) {
|
||||
delete cfg[k];
|
||||
}
|
||||
cfg.hasCompletedOnboarding = true; // skip the wizard, even on a first-time host
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function readHostConfig(src) {
|
||||
try {
|
||||
if (fs.existsSync(src) && fs.statSync(src).size > 0) {
|
||||
return JSON.parse(fs.readFileSync(src, 'utf8'));
|
||||
}
|
||||
} catch {
|
||||
// Host file is malformed or unreadable. Fall back to an empty config so the
|
||||
// container still gets a valid file that carries hasCompletedOnboarding.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const src = process.argv[2] || '/host/.claude.json';
|
||||
const dst = process.argv[3] || '/home/node/.claude.json';
|
||||
const cfg = sanitizeClaudeConfig(readHostConfig(src));
|
||||
try {
|
||||
fs.writeFileSync(dst, JSON.stringify(cfg, null, 2));
|
||||
fs.chmodSync(dst, 0o644);
|
||||
} catch (err) {
|
||||
console.error(`[post-create] ERROR: failed to seed ${dst}: ${err && err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sanitizeClaudeConfig, readHostConfig, MACHINE_FIELDS };
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// Rewrites the host paths inside Claude and Cursor plugin-registry JSON files
|
||||
// so they point at the container's Linux paths, then writes the results into
|
||||
// the named volume.
|
||||
//
|
||||
// Why: both CLIs store absolute, OS-native install paths in their registry
|
||||
// JSONs. On Windows that looks like `C:\Users\X\.claude\plugins\...`; on macOS
|
||||
// like `/Users/X/.cursor/...`. The Linux container can't use those paths. If we
|
||||
// just bind-mounted the host files in, the CLI would try to resolve a Windows
|
||||
// path under Linux and fail with `cache-miss`. So for each CLI we read the host
|
||||
// registry, rewrite every absolute path ending in `/.<cli>/plugins/<rest>` to
|
||||
// `/home/node/.<cli>/plugins/<rest>`, and write the result into the named volume.
|
||||
//
|
||||
// Codex is left alone. Its registry is config.toml and holds git URLs, not
|
||||
// filesystem paths, so there's nothing to translate — its whole plugins/ dir is
|
||||
// copied as-is into the container volume instead (seeded once by post-create.sh).
|
||||
//
|
||||
// This code lived inside a post-create.sh heredoc. We pulled it out so the regex
|
||||
// and the deep rewrite can be unit-tested and prettier-checked. The regex has
|
||||
// had path-handling bugs before.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Build a regex that matches an absolute path containing
|
||||
// `<sep>.<cli><sep>plugins<sep><rest>`, where <sep> is `/` or `\`. It's anchored
|
||||
// at the start of the string. The lazy `.*?` eats the home prefix up to the
|
||||
// FIRST `.<cli>/plugins` segment.
|
||||
function buildRe(cliName) {
|
||||
return new RegExp(`^(?:[A-Za-z]:)?[\\\\/].*?[\\\\/]\\.${cliName}[\\\\/]plugins[\\\\/](.*)$`);
|
||||
}
|
||||
|
||||
// Walk `obj` and rewrite every string value that matches `re`. A match is
|
||||
// remapped under `ctr`, the container's plugins dir. Windows backslashes in the
|
||||
// matched part are switched to forward slashes.
|
||||
function rewriteDeep(obj, re, ctr) {
|
||||
if (Array.isArray(obj)) return obj.map((v) => rewriteDeep(v, re, ctr));
|
||||
if (obj && typeof obj === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) out[k] = rewriteDeep(v, re, ctr);
|
||||
return out;
|
||||
}
|
||||
if (typeof obj === 'string') {
|
||||
return obj.replace(re, (_, rest) => `${ctr}/${rest.replace(/\\/g, '/')}`);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
const REGISTRIES = [
|
||||
{
|
||||
cli: 'claude',
|
||||
host: '/host/.claude/plugins',
|
||||
ctr: '/home/node/.claude/plugins',
|
||||
files: ['known_marketplaces.json', 'installed_plugins.json', 'plugin-catalog-cache.json'],
|
||||
},
|
||||
{
|
||||
cli: 'cursor',
|
||||
host: '/host/.cursor/plugins',
|
||||
ctr: '/home/node/.cursor/plugins',
|
||||
files: ['installed_plugins.json'],
|
||||
},
|
||||
];
|
||||
|
||||
function translate(registries) {
|
||||
for (const reg of registries) {
|
||||
const re = buildRe(reg.cli);
|
||||
try {
|
||||
fs.mkdirSync(reg.ctr, { recursive: true });
|
||||
} catch (err) {
|
||||
console.error(`[post-create] ERROR: failed to create ${reg.ctr}: ${err && err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const name of reg.files) {
|
||||
const src = path.join(reg.host, name);
|
||||
const dst = path.join(reg.ctr, name);
|
||||
if (!fs.existsSync(src) || fs.statSync(src).size === 0) continue;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(src, 'utf8'));
|
||||
} catch {
|
||||
continue; // Skip a malformed host registry instead of aborting.
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(dst, JSON.stringify(rewriteDeep(data, re, reg.ctr), null, 2));
|
||||
} catch (err) {
|
||||
console.error(`[post-create] ERROR: failed to write ${dst}: ${err && err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter the registry table by CLI name. post-create.sh passes the CLIs it is
|
||||
// seeding this run (e.g. `claude`), so a registry is only (re)generated on the
|
||||
// FIRST container-create for that CLI — never on a rebuild, where it would
|
||||
// clobber a plugin the user installed inside the container. An empty filter
|
||||
// (no args) means "translate every registry" — the original behavior.
|
||||
function selectRegistries(registries, only) {
|
||||
return only && only.length ? registries.filter((r) => only.includes(r.cli)) : registries;
|
||||
}
|
||||
|
||||
module.exports = { buildRe, rewriteDeep, REGISTRIES, translate, selectRegistries };
|
||||
|
||||
if (require.main === module) {
|
||||
translate(selectRegistries(REGISTRIES, process.argv.slice(2)));
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
// Unit tests for the devcontainer host->container config transforms.
|
||||
//
|
||||
// This code used to live inside post-create.sh heredocs, where lint could not
|
||||
// see it and tests could not reach it. We test three things:
|
||||
// - plugin-registry path translation (buildRe + rewriteDeep + the real
|
||||
// filesystem translate() driver). Path handling here has had bugs before.
|
||||
// - the strip of machine-specific fields from $HOME/.claude.json
|
||||
// (sanitizeClaudeConfig + readHostConfig + the seed-claude-config main()
|
||||
// entry point)
|
||||
// - the host bind-source bootstrap (ensurePaths). One test guards against a
|
||||
// regression: ensurePaths must NOT pre-create settings.json / config.toml
|
||||
// on the host.
|
||||
//
|
||||
// We test both pure functions and code that touches the filesystem. The
|
||||
// filesystem tests use throwaway directories under os.tmpdir() and delete them
|
||||
// when done. So they run in CI with no mounts and never touch the real home dir.
|
||||
//
|
||||
// Run with the built-in Node test runner (no extra dependencies):
|
||||
// node --test .devcontainer/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const {
|
||||
buildRe,
|
||||
rewriteDeep,
|
||||
translate,
|
||||
selectRegistries,
|
||||
} = require('./translate-plugin-registries.cjs');
|
||||
const { sanitizeClaudeConfig, readHostConfig } = require('./seed-claude-config.cjs');
|
||||
const { ensurePaths, DIRS, FILES } = require('./ensure-host-config-dirs.cjs');
|
||||
|
||||
const CLAUDE = '/home/node/.claude/plugins';
|
||||
const CURSOR = '/home/node/.cursor/plugins';
|
||||
|
||||
// Make a fresh throwaway directory under the OS temp root. mkdtemp picks a
|
||||
// unique name on every call, so we don't need Date.now() or random names.
|
||||
function tmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gn-dc-'));
|
||||
}
|
||||
|
||||
function rw(value, cli, ctr) {
|
||||
return rewriteDeep(value, buildRe(cli), ctr);
|
||||
}
|
||||
|
||||
test('claude: Windows backslash absolute path -> container path', () => {
|
||||
assert.equal(
|
||||
rw('C:\\Users\\gergo\\.claude\\plugins\\cache\\x\\1.0', 'claude', CLAUDE),
|
||||
'/home/node/.claude/plugins/cache/x/1.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('claude: Windows forward-slash absolute path -> container path', () => {
|
||||
assert.equal(
|
||||
rw('C:/Users/gergo/.claude/plugins/marketplaces/m', 'claude', CLAUDE),
|
||||
'/home/node/.claude/plugins/marketplaces/m',
|
||||
);
|
||||
});
|
||||
|
||||
test('claude: macOS POSIX path -> container path', () => {
|
||||
assert.equal(
|
||||
rw('/Users/alice/.claude/plugins/marketplaces/m', 'claude', CLAUDE),
|
||||
'/home/node/.claude/plugins/marketplaces/m',
|
||||
);
|
||||
});
|
||||
|
||||
test('claude: Linux POSIX path -> container path', () => {
|
||||
assert.equal(
|
||||
rw('/home/bob/.claude/plugins/cache/foo', 'claude', CLAUDE),
|
||||
'/home/node/.claude/plugins/cache/foo',
|
||||
);
|
||||
});
|
||||
|
||||
test('cursor: Windows path -> container cursor path', () => {
|
||||
assert.equal(
|
||||
rw('C:\\Users\\gergo\\.cursor\\plugins\\local\\myplug', 'cursor', CURSOR),
|
||||
'/home/node/.cursor/plugins/local/myplug',
|
||||
);
|
||||
});
|
||||
|
||||
test('cross-CLI isolation: claude regex leaves a .cursor path untouched', () => {
|
||||
const input = 'C:\\Users\\g\\.cursor\\plugins\\x';
|
||||
assert.equal(rw(input, 'claude', CLAUDE), input);
|
||||
});
|
||||
|
||||
test('non-path strings pass through unchanged', () => {
|
||||
assert.equal(rw('not-a-path', 'claude', CLAUDE), 'not-a-path');
|
||||
assert.equal(
|
||||
rw('https://github.com/EveryInc/x.git', 'claude', CLAUDE),
|
||||
'https://github.com/EveryInc/x.git',
|
||||
);
|
||||
});
|
||||
|
||||
test('non-string scalars pass through unchanged', () => {
|
||||
assert.equal(rw(42, 'claude', CLAUDE), 42);
|
||||
assert.equal(rw(null, 'claude', CLAUDE), null);
|
||||
assert.equal(rw(true, 'claude', CLAUDE), true);
|
||||
});
|
||||
|
||||
test('nested objects/arrays are rewritten deeply', () => {
|
||||
const input = {
|
||||
'compound-engineering@m': [
|
||||
{ installPath: 'C:\\Users\\g\\.claude\\plugins\\cache\\ce\\3.9.2', version: '3.9.2' },
|
||||
],
|
||||
nested: { installLocation: '/Users/g/.claude/plugins/marketplaces/m' },
|
||||
};
|
||||
const out = rw(input, 'claude', CLAUDE);
|
||||
assert.equal(
|
||||
out['compound-engineering@m'][0].installPath,
|
||||
'/home/node/.claude/plugins/cache/ce/3.9.2',
|
||||
);
|
||||
assert.equal(out['compound-engineering@m'][0].version, '3.9.2');
|
||||
assert.equal(out.nested.installLocation, '/home/node/.claude/plugins/marketplaces/m');
|
||||
});
|
||||
|
||||
test('sanitizeClaudeConfig: strips machine fields, forces hasCompletedOnboarding', () => {
|
||||
const out = sanitizeClaudeConfig({
|
||||
installMethod: 'native',
|
||||
autoUpdates: false,
|
||||
autoUpdatesProtectedForNative: true,
|
||||
shiftEnterKeyBindingInstalled: true,
|
||||
userID: 'abc',
|
||||
oauthAccount: { emailAddress: 'x@y.z' },
|
||||
});
|
||||
assert.equal(out.installMethod, undefined);
|
||||
assert.equal(out.autoUpdates, undefined);
|
||||
assert.equal(out.autoUpdatesProtectedForNative, undefined);
|
||||
assert.equal(out.shiftEnterKeyBindingInstalled, undefined);
|
||||
assert.equal(out.userID, 'abc');
|
||||
assert.equal(out.oauthAccount.emailAddress, 'x@y.z');
|
||||
assert.equal(out.hasCompletedOnboarding, true);
|
||||
});
|
||||
|
||||
test('sanitizeClaudeConfig: non-object inputs become a valid onboarding-bearing object', () => {
|
||||
for (const bad of [42, 'x', null, ['a'], true]) {
|
||||
const out = sanitizeClaudeConfig(bad);
|
||||
assert.equal(typeof out, 'object');
|
||||
assert.equal(Array.isArray(out), false);
|
||||
assert.equal(out.hasCompletedOnboarding, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('sanitizeClaudeConfig: empty object still gets hasCompletedOnboarding', () => {
|
||||
assert.deepEqual(sanitizeClaudeConfig({}), { hasCompletedOnboarding: true });
|
||||
});
|
||||
|
||||
// --- readHostConfig: reading the file, and the fallbacks when it fails ------
|
||||
|
||||
test('readHostConfig: missing file -> {}', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
assert.deepEqual(readHostConfig(path.join(dir, 'nope.json')), {});
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readHostConfig: empty (zero-byte) file -> {}', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const f = path.join(dir, 'empty.json');
|
||||
fs.writeFileSync(f, '');
|
||||
assert.deepEqual(readHostConfig(f), {});
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readHostConfig: malformed JSON -> {}', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const f = path.join(dir, 'bad.json');
|
||||
fs.writeFileSync(f, '{ not valid json');
|
||||
assert.deepEqual(readHostConfig(f), {});
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readHostConfig: valid object is parsed through', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const f = path.join(dir, 'ok.json');
|
||||
fs.writeFileSync(f, JSON.stringify({ userID: 'u', hasCompletedOnboarding: false }));
|
||||
const out = readHostConfig(f);
|
||||
assert.equal(out.userID, 'u');
|
||||
assert.equal(out.hasCompletedOnboarding, false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- translate(): runs against real registry files on disk ------------------
|
||||
|
||||
test('translate: rewrites host absolute paths and writes into the ctr dir', () => {
|
||||
const hostDir = tmp();
|
||||
const ctrParent = tmp();
|
||||
const ctrDir = path.join(ctrParent, 'plugins'); // need not exist yet; translate creates it
|
||||
try {
|
||||
const reg = [{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['installed_plugins.json'] }];
|
||||
fs.writeFileSync(
|
||||
path.join(hostDir, 'installed_plugins.json'),
|
||||
JSON.stringify({ 'p@m': [{ installPath: 'C:\\Users\\g\\.claude\\plugins\\cache\\p\\1.0' }] }),
|
||||
);
|
||||
translate(reg);
|
||||
const out = JSON.parse(fs.readFileSync(path.join(ctrDir, 'installed_plugins.json'), 'utf8'));
|
||||
assert.equal(out['p@m'][0].installPath, `${ctrDir}/cache/p/1.0`);
|
||||
} finally {
|
||||
fs.rmSync(hostDir, { recursive: true, force: true });
|
||||
fs.rmSync(ctrParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('translate: idempotent — a second run reproduces byte-identical output', () => {
|
||||
const hostDir = tmp();
|
||||
const ctrParent = tmp();
|
||||
const ctrDir = path.join(ctrParent, 'plugins');
|
||||
try {
|
||||
const reg = [{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['installed_plugins.json'] }];
|
||||
fs.writeFileSync(
|
||||
path.join(hostDir, 'installed_plugins.json'),
|
||||
JSON.stringify({ 'p@m': [{ installPath: 'C:\\Users\\g\\.claude\\plugins\\cache\\p\\1.0' }] }),
|
||||
);
|
||||
translate(reg);
|
||||
const first = fs.readFileSync(path.join(ctrDir, 'installed_plugins.json'), 'utf8');
|
||||
translate(reg);
|
||||
const second = fs.readFileSync(path.join(ctrDir, 'installed_plugins.json'), 'utf8');
|
||||
assert.equal(first, second);
|
||||
} finally {
|
||||
fs.rmSync(hostDir, { recursive: true, force: true });
|
||||
fs.rmSync(ctrParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('translate: malformed host registry is skipped, dst not written', () => {
|
||||
const hostDir = tmp();
|
||||
const ctrParent = tmp();
|
||||
const ctrDir = path.join(ctrParent, 'plugins');
|
||||
try {
|
||||
const reg = [{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['installed_plugins.json'] }];
|
||||
fs.writeFileSync(path.join(hostDir, 'installed_plugins.json'), '{ broken');
|
||||
translate(reg);
|
||||
assert.equal(fs.existsSync(path.join(ctrDir, 'installed_plugins.json')), false);
|
||||
} finally {
|
||||
fs.rmSync(hostDir, { recursive: true, force: true });
|
||||
fs.rmSync(ctrParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('translate: empty and missing host registries are skipped without error', () => {
|
||||
const hostDir = tmp();
|
||||
const ctrParent = tmp();
|
||||
const ctrDir = path.join(ctrParent, 'plugins');
|
||||
try {
|
||||
const reg = [
|
||||
{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['empty.json', 'missing.json'] },
|
||||
];
|
||||
fs.writeFileSync(path.join(hostDir, 'empty.json'), ''); // we never create missing.json
|
||||
translate(reg);
|
||||
assert.equal(fs.existsSync(path.join(ctrDir, 'empty.json')), false);
|
||||
assert.equal(fs.existsSync(path.join(ctrDir, 'missing.json')), false);
|
||||
} finally {
|
||||
fs.rmSync(hostDir, { recursive: true, force: true });
|
||||
fs.rmSync(ctrParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- selectRegistries: the per-CLI filter post-create.sh drives translate with
|
||||
|
||||
test('selectRegistries: no filter -> all registries (original behavior)', () => {
|
||||
const regs = [{ cli: 'claude' }, { cli: 'cursor' }];
|
||||
assert.deepEqual(selectRegistries(regs, []), regs);
|
||||
assert.deepEqual(selectRegistries(regs, undefined), regs);
|
||||
});
|
||||
|
||||
test('selectRegistries: filter keeps only the named CLIs', () => {
|
||||
const regs = [{ cli: 'claude' }, { cli: 'cursor' }];
|
||||
assert.deepEqual(selectRegistries(regs, ['claude']), [{ cli: 'claude' }]);
|
||||
assert.deepEqual(selectRegistries(regs, ['cursor']), [{ cli: 'cursor' }]);
|
||||
assert.deepEqual(selectRegistries(regs, ['claude', 'cursor']), regs);
|
||||
});
|
||||
|
||||
test('selectRegistries: an unknown CLI name selects nothing', () => {
|
||||
const regs = [{ cli: 'claude' }, { cli: 'cursor' }];
|
||||
assert.deepEqual(selectRegistries(regs, ['codex']), []);
|
||||
});
|
||||
|
||||
test('selectRegistries: empty registry table stays empty under any filter', () => {
|
||||
assert.deepEqual(selectRegistries([], ['claude']), []);
|
||||
assert.deepEqual(selectRegistries([], []), []);
|
||||
});
|
||||
|
||||
// --- seed-claude-config main(): end-to-end, through the real CLI entry point
|
||||
|
||||
const SEED_SCRIPT = path.join(__dirname, 'seed-claude-config.cjs');
|
||||
|
||||
test('seed main: strips machine fields, keeps account, sets onboarding, chmod 644', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const src = path.join(dir, 'host.claude.json');
|
||||
const dst = path.join(dir, 'out.claude.json');
|
||||
fs.writeFileSync(
|
||||
src,
|
||||
JSON.stringify({
|
||||
installMethod: 'native',
|
||||
userID: 'abc',
|
||||
oauthAccount: { emailAddress: 'x@y.z' },
|
||||
}),
|
||||
);
|
||||
execFileSync(process.execPath, [SEED_SCRIPT, src, dst]);
|
||||
const out = JSON.parse(fs.readFileSync(dst, 'utf8'));
|
||||
assert.equal(out.installMethod, undefined);
|
||||
assert.equal(out.userID, 'abc');
|
||||
assert.equal(out.oauthAccount.emailAddress, 'x@y.z');
|
||||
assert.equal(out.hasCompletedOnboarding, true);
|
||||
if (process.platform !== 'win32') {
|
||||
assert.equal(fs.statSync(dst).mode & 0o777, 0o644);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('seed main: missing host file still writes a valid onboarding-bearing file', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const dst = path.join(dir, 'out.claude.json');
|
||||
execFileSync(process.execPath, [SEED_SCRIPT, path.join(dir, 'nope.json'), dst]);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(dst, 'utf8')), { hasCompletedOnboarding: true });
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('seed main: chmodSync widens a pre-existing restrictive dst to 0o644', () => {
|
||||
// This checks the file's permission bits, which only exist on POSIX systems.
|
||||
//
|
||||
// The catch: CI's default umask is 022, so a plain writeFileSync already
|
||||
// creates files at mode 0o644. Asserting 0o644 right after a fresh write
|
||||
// would therefore NOT prove the explicit chmodSync did anything.
|
||||
//
|
||||
// So we pre-create dst at the stricter mode 0o600. Opening a file in 'w'
|
||||
// mode replaces its contents but KEEPS the mode of a file that already
|
||||
// exists. That means the only way dst can end up at 0o644 is the chmodSync
|
||||
// inside seed-claude-config.cjs. This pins the test to the chmod and not to
|
||||
// the umask: delete the chmodSync line and this test fails, while the other
|
||||
// seed test still passes.
|
||||
if (process.platform === 'win32') return;
|
||||
const dir = tmp();
|
||||
try {
|
||||
const src = path.join(dir, 'host.claude.json');
|
||||
const dst = path.join(dir, 'out.claude.json');
|
||||
fs.writeFileSync(src, JSON.stringify({ userID: 'u' }));
|
||||
fs.writeFileSync(dst, '{}');
|
||||
fs.chmodSync(dst, 0o600);
|
||||
execFileSync(process.execPath, [SEED_SCRIPT, src, dst]);
|
||||
assert.equal(fs.statSync(dst).mode & 0o777, 0o644);
|
||||
assert.equal(JSON.parse(fs.readFileSync(dst, 'utf8')).userID, 'u');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- ensurePaths: sets up the host paths the bind mounts point at -----------
|
||||
|
||||
test('ensurePaths: creates every DIR and FILE under a temp home, idempotently', () => {
|
||||
const home = tmp();
|
||||
try {
|
||||
ensurePaths(home);
|
||||
for (const d of DIRS) {
|
||||
assert.equal(fs.statSync(path.join(home, d)).isDirectory(), true, `not a dir: ${d}`);
|
||||
}
|
||||
for (const f of FILES) {
|
||||
assert.equal(fs.statSync(path.join(home, f)).isFile(), true, `not a file: ${f}`);
|
||||
}
|
||||
// Running it again must not throw and must not overwrite existing content.
|
||||
fs.writeFileSync(path.join(home, '.claude.json'), '{"keep":true}');
|
||||
ensurePaths(home);
|
||||
assert.equal(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'), '{"keep":true}');
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensurePaths: does NOT pre-create settings.json / config.toml (no gratuitous host mutation)', () => {
|
||||
const home = tmp();
|
||||
try {
|
||||
ensurePaths(home);
|
||||
assert.equal(fs.existsSync(path.join(home, '.claude', 'settings.json')), false);
|
||||
assert.equal(fs.existsSync(path.join(home, '.codex', 'config.toml')), false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensurePaths: does NOT pre-create the shareable subdirs (now copied, not bound)', () => {
|
||||
// The shareable dirs are seeded into the per-container volume from the
|
||||
// read-only /host stage, so they are no longer bind-mount sources. Pre-creating
|
||||
// empty ones would needlessly write into the host of someone who never used a
|
||||
// CLI. This pins the DIRS trim: re-adding any of these would fail the test.
|
||||
const home = tmp();
|
||||
const mustNotExist = [
|
||||
path.join('.claude', 'skills'),
|
||||
path.join('.claude', 'agents'),
|
||||
path.join('.claude', 'memory'),
|
||||
path.join('.claude', 'commands'),
|
||||
path.join('.claude', 'plugins'),
|
||||
path.join('.codex', 'plugins'),
|
||||
path.join('.codex', 'prompts'),
|
||||
path.join('.codex', 'memories'),
|
||||
path.join('.codex', 'skills'),
|
||||
path.join('.cursor', 'rules'),
|
||||
path.join('.cursor', 'commands'),
|
||||
path.join('.cursor', 'agents'),
|
||||
path.join('.cursor', 'skills'),
|
||||
path.join('.cursor', 'plugins'),
|
||||
];
|
||||
try {
|
||||
ensurePaths(home);
|
||||
for (const sub of mustNotExist) {
|
||||
assert.equal(fs.existsSync(path.join(home, sub)), false, `should not pre-create: ${sub}`);
|
||||
}
|
||||
// The top-level stage roots that ARE still bind sources must exist.
|
||||
for (const top of ['.claude', '.codex', '.cursor', '.claude-mem']) {
|
||||
assert.equal(fs.statSync(path.join(home, top)).isDirectory(), true, `missing root: ${top}`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
dist
|
||||
**/dist
|
||||
coverage
|
||||
**/coverage
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
**/*.tsbuildinfo
|
||||
|
||||
.gitnexus
|
||||
gitnexus-web/playwright-report
|
||||
gitnexus-web/test-results
|
||||
@@ -1,19 +0,0 @@
|
||||
# Images (signed Cosign keyless on every push from main / vX.Y.Z tags).
|
||||
# Available from both GHCR (default below) and Docker Hub — pick one:
|
||||
# GHCR: ghcr.io/abhigyanpatwari/gitnexus{,-web}:latest
|
||||
# Docker Hub: akonlabs/gitnexus{,-web}:latest
|
||||
# Both registries receive the same digest from a single signed build.
|
||||
SERVER_IMAGE=ghcr.io/abhigyanpatwari/gitnexus:latest
|
||||
WEB_IMAGE=ghcr.io/abhigyanpatwari/gitnexus-web:latest
|
||||
|
||||
# Container names
|
||||
SERVER_CONTAINER_NAME=gitnexus-server
|
||||
WEB_CONTAINER_NAME=gitnexus-web
|
||||
|
||||
# Host ports — the web UI expects the server on http://localhost:4747 by default.
|
||||
SERVER_HOST_PORT=4747
|
||||
WEB_HOST_PORT=4173
|
||||
|
||||
# Optional read-only mount, exposed to the server as /workspace.
|
||||
# Override with the directory that contains the repos you want to index.
|
||||
WORKSPACE_DIR=./
|
||||
@@ -1,19 +0,0 @@
|
||||
description = "GitNexus production-readiness PR swarm review (Solo mode)"
|
||||
|
||||
prompt = """
|
||||
You are the GitNexus PR review coordinator. Review this pull request: {{args}}
|
||||
(a PR URL or number for https://github.com/abhigyanpatwari/GitNexus). If no target was
|
||||
given, ask for one.
|
||||
|
||||
Read `pr-swarm-review/orchestration.md` in this repository and follow it exactly. It is the
|
||||
canonical, CLI-neutral review contract (lanes, classifications, output structure, finding
|
||||
format, hidden-Unicode checks, behavior rules).
|
||||
|
||||
Run in **Solo mode**: you are a single agent, so perform all seven lanes yourself in
|
||||
dependency order, adopting each persona in `pr-swarm-review/personas/0N-*.md` in turn
|
||||
(lanes 1-2 first, then 3-6, then lane 7). Keep every lane's findings in context. Lane 7
|
||||
(synthesis critic) is a hard gate: do not emit the final review until its "Required
|
||||
corrections before posting" section is empty — revise and re-run it otherwise.
|
||||
|
||||
Stay strictly read-only: investigate and report; never edit files, commit, or post to GitHub.
|
||||
"""
|
||||
@@ -1,5 +0,0 @@
|
||||
# Prettier initial formatting (2026-03-28)
|
||||
afcc3d1523f99c77ff67c4fd1af12334660113f6
|
||||
|
||||
# ESLint unused import removal (2026-03-28)
|
||||
1491826bc8da5436d3b1eb092d9274f2c9f028a7
|
||||
@@ -1,17 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
.husky/* text eol=lf
|
||||
|
||||
# Shell scripts: force LF unconditionally so devcontainer scripts
|
||||
# (e.g. anything COPYed into a Linux container) execute correctly when
|
||||
# checked out on Windows hosts with core.autocrlf=true.
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
|
||||
# Native and binary assets shouldn't be treated as text under any
|
||||
# auto-detection or eol normalization.
|
||||
*.node binary
|
||||
*.wasm binary
|
||||
*.onnx binary
|
||||
*.so binary
|
||||
*.dll binary
|
||||
*.dylib binary
|
||||
@@ -1,4 +0,0 @@
|
||||
# Code owners
|
||||
|
||||
* @Arvuno
|
||||
* @magyargergo
|
||||
@@ -1,86 +0,0 @@
|
||||
name: Bug report
|
||||
description: Report unexpected behavior or a regression
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Goal:** capture enough context to reproduce and fix the issue quickly.
|
||||
Use **one issue per bug**; split unrelated problems.
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
description: Where does the problem show up?
|
||||
options:
|
||||
- gitnexus (CLI / core / indexing / MCP server)
|
||||
- gitnexus-web (browser UI / WASM / workers)
|
||||
- CI / GitHub Actions
|
||||
- Documentation / developer experience
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One sentence — what went wrong?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: What were you trying to do? Any relevant links, PRs, or commits?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Ordered steps, sample repo or minimal case, commands run.
|
||||
placeholder: |
|
||||
1. …
|
||||
2. …
|
||||
3. …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: OS, Node version, browser (if web), GitNexus version or commit SHA.
|
||||
placeholder: |
|
||||
- OS:
|
||||
- Node:
|
||||
- Browser (if applicable):
|
||||
- Commit / version:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / screenshots
|
||||
description: Paste errors, stack traces, or attach screenshots (redact secrets).
|
||||
validations:
|
||||
required: false
|
||||
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Feature request
|
||||
description: Propose a new capability or improvement
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Goal:** describe the problem and desired outcome so maintainers can size and prioritize.
|
||||
Prefer **small, shippable** requests; split large ideas into phases.
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
description: Primary part of the monorepo this relates to.
|
||||
options:
|
||||
- gitnexus (CLI / core / indexing / MCP server)
|
||||
- gitnexus-web (browser UI / WASM / workers)
|
||||
- CI / release / packaging
|
||||
- Documentation / developer experience
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or opportunity
|
||||
description: What pain point or gap exists today?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: What should happen instead? User-visible behavior, APIs, or UX.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other approaches you considered and why this one is preferred.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: acceptance
|
||||
attributes:
|
||||
label: Acceptance criteria
|
||||
description: Testable conditions for “done” (bullets or checkboxes in prose).
|
||||
placeholder: |
|
||||
- When … then …
|
||||
- Documentation / tests updated where appropriate
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: constraints
|
||||
attributes:
|
||||
label: Constraints
|
||||
description: Compatibility, performance, security, or “must not change” boundaries.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: willing
|
||||
attributes:
|
||||
label: Contribution
|
||||
options:
|
||||
- label: I am willing to open a PR for this (may need design discussion first).
|
||||
required: false
|
||||
@@ -1,52 +0,0 @@
|
||||
## Summary
|
||||
|
||||
<!-- One or two sentences: what does this PR change? -->
|
||||
|
||||
## Motivation / context
|
||||
|
||||
<!-- Why is this change needed? Link issues, ADRs, or prior discussion. -->
|
||||
|
||||
## Areas touched
|
||||
|
||||
<!-- Check all that apply -->
|
||||
|
||||
- [ ] `gitnexus/` (CLI / core / MCP server)
|
||||
- [ ] `gitnexus-web/` (Vite / React UI)
|
||||
- [ ] `.github/` (workflows, actions)
|
||||
- [ ] `eval/` or other tooling
|
||||
- [ ] Docs / agent config only (`AGENTS.md`, `CLAUDE.md`, `.cursor/`, `llms.txt`, etc.)
|
||||
|
||||
## Scope & constraints
|
||||
|
||||
**In scope**
|
||||
|
||||
- <!-- bullets -->
|
||||
|
||||
**Explicitly out of scope / not done here**
|
||||
|
||||
- <!-- bullets — prevents reviewers assuming missing work is an oversight -->
|
||||
|
||||
## Implementation notes
|
||||
|
||||
<!-- Optional: design choices, tradeoffs, follow-ups -->
|
||||
|
||||
## Testing & verification
|
||||
|
||||
<!-- What you ran; paste commands. Omit sections that do not apply. -->
|
||||
|
||||
- [ ] `cd gitnexus && npm test`
|
||||
- [ ] `cd gitnexus && npm run test:integration` *(if core/indexing/MCP paths changed)*
|
||||
- [ ] `cd gitnexus && npx tsc --noEmit`
|
||||
- [ ] `cd gitnexus-web && npm test` *(if web changed)*
|
||||
- [ ] `cd gitnexus-web && npx tsc -b --noEmit` *(if web changed)*
|
||||
- [ ] Manual / Playwright E2E *(note environment — see `gitnexus-web/e2e/`)*
|
||||
|
||||
## Risk & rollout
|
||||
|
||||
<!-- Breaking changes, migrations, index refresh (`npx gitnexus analyze`), release notes -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] PR body meets repo minimum length (workflow may label short descriptions)
|
||||
- [ ] If `AGENTS.md` / overlays changed: headers, scope block, and changelog updated per project conventions
|
||||
- [ ] No secrets, tokens, or machine-specific paths committed
|
||||
@@ -1,105 +0,0 @@
|
||||
# Wraps docker/build-push-action with one automatic retry. Upstream explicitly
|
||||
# keeps retry out of the action (docker/build-push-action#1422); a local
|
||||
# composite keeps docker.yml readable and pins the same action SHA in one place.
|
||||
name: Docker build-push (with retry)
|
||||
description: >-
|
||||
Runs docker/build-push-action twice on failure with a configurable backoff,
|
||||
then exposes the digest from whichever attempt succeeded.
|
||||
|
||||
inputs:
|
||||
context:
|
||||
description: Build context path
|
||||
required: false
|
||||
default: '.'
|
||||
file:
|
||||
description: Dockerfile path (relative to repo root)
|
||||
required: true
|
||||
platforms:
|
||||
description: Comma-separated platforms list for buildx
|
||||
required: true
|
||||
push:
|
||||
description: Whether to push (string 'true' or 'false')
|
||||
required: true
|
||||
tags:
|
||||
description: Newline-separated image tags (from docker/metadata-action)
|
||||
required: true
|
||||
labels:
|
||||
description: Labels string (from docker/metadata-action)
|
||||
required: true
|
||||
cache-from:
|
||||
description: buildx cache-from value
|
||||
required: true
|
||||
cache-to:
|
||||
description: buildx cache-to value (include ignore-error=true for GHA cache flakes)
|
||||
required: true
|
||||
retry-wait-seconds:
|
||||
description: Seconds to sleep before the second attempt
|
||||
required: false
|
||||
default: '45'
|
||||
|
||||
outputs:
|
||||
digest:
|
||||
description: Manifest digest from the successful build attempt
|
||||
value: ${{ steps.resolve.outputs.digest }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Build and push (attempt 1)
|
||||
id: try1
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
file: ${{ inputs.file }}
|
||||
platforms: ${{ inputs.platforms }}
|
||||
push: ${{ inputs.push == 'true' }}
|
||||
tags: ${{ inputs.tags }}
|
||||
labels: ${{ inputs.labels }}
|
||||
cache-from: ${{ inputs.cache-from }}
|
||||
cache-to: ${{ inputs.cache-to }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
|
||||
- name: Backoff before Docker build retry
|
||||
if: steps.try1.outcome == 'failure'
|
||||
shell: bash
|
||||
env:
|
||||
RETRY_WAIT_SECONDS: ${{ inputs.retry-wait-seconds }}
|
||||
run: |
|
||||
echo "::warning::Docker build-push attempt 1 failed; retrying in ${RETRY_WAIT_SECONDS}s…"
|
||||
sleep "${RETRY_WAIT_SECONDS}"
|
||||
|
||||
- name: Build and push (attempt 2)
|
||||
id: try2
|
||||
if: steps.try1.outcome == 'failure'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
file: ${{ inputs.file }}
|
||||
platforms: ${{ inputs.platforms }}
|
||||
push: ${{ inputs.push == 'true' }}
|
||||
tags: ${{ inputs.tags }}
|
||||
labels: ${{ inputs.labels }}
|
||||
cache-from: ${{ inputs.cache-from }}
|
||||
cache-to: ${{ inputs.cache-to }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
|
||||
- name: Resolve image digest
|
||||
id: resolve
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ steps.try1.outcome }}" = "success" ]; then
|
||||
echo "digest=${{ steps.try1.outputs.digest }}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "${{ steps.try2.outcome }}" = "success" ]; then
|
||||
echo "::notice::docker-build-push retry succeeded (attempt 2); investigate if this recurs across runs."
|
||||
echo "digest=${{ steps.try2.outputs.digest }}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::Docker build and push failed after two attempts (registry/cache flake or real build error)."
|
||||
exit 1
|
||||
@@ -1,22 +0,0 @@
|
||||
name: Setup GitNexus Web
|
||||
description: Setup Node.js 22, build gitnexus-shared, install web dependencies
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
# Vite 7 requires Node ^20.19.0 || >=22.12.0 (require(esm) support).
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus-web/package-lock.json
|
||||
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
shell: bash
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- name: Install web dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: gitnexus-web
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Setup GitNexus
|
||||
description: Setup Node.js 22, install dependencies, and optionally build
|
||||
description: Setup Node.js 20, install dependencies, and optionally build
|
||||
|
||||
inputs:
|
||||
build:
|
||||
@@ -10,17 +10,12 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
shell: bash
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Keep third-party Actions SHA pins current. See CONTRIBUTING.md — when
|
||||
# reviewing these bumps, verify the SHA corresponds to the claimed tag by
|
||||
# running `gh api repos/<owner>/<action>/git/refs/tags/<tag>` before merge.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
# Keep pinned Docker base-image digests current for the root Dockerfiles.
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
# Keep the nested test-image Docker base digest current as well.
|
||||
- package-ecosystem: docker
|
||||
directory: /gitnexus
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
# Gitnexus npm deps — tree-sitter grammars checked daily so we catch
|
||||
# new releases that unblock the tree-sitter 0.25 upgrade 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.
|
||||
- package-ecosystem: npm
|
||||
directory: /gitnexus
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
groups:
|
||||
tree-sitter-grammars:
|
||||
patterns:
|
||||
- tree-sitter-*
|
||||
exclude-patterns:
|
||||
- tree-sitter
|
||||
- tree-sitter-cli
|
||||
ignore:
|
||||
# Pin the tree-sitter runtime at 0.21.x until the drift check
|
||||
# reports all grammars are peer-dep compatible with 0.25.
|
||||
- dependency-name: tree-sitter
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
- version-update:semver-minor
|
||||
# tree-sitter-cli follows the runtime's version cadence. Bump when
|
||||
# regenerating vendor/tree-sitter-proto/src/parser.c, not on a schedule.
|
||||
- dependency-name: tree-sitter-cli
|
||||
|
||||
# gitnexus-web (thin frontend client).
|
||||
- package-ecosystem: npm
|
||||
directory: /gitnexus-web
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- frontend
|
||||
|
||||
# Shared types package.
|
||||
- package-ecosystem: npm
|
||||
directory: /gitnexus-shared
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
description: 'GitNexus production-readiness PR swarm review (Solo mode)'
|
||||
mode: 'agent'
|
||||
---
|
||||
|
||||
You are the GitNexus PR review coordinator. Review the pull request the user names (a PR URL
|
||||
or number for `https://github.com/abhigyanpatwari/GitNexus`). If none was given, ask for one.
|
||||
|
||||
Read `pr-swarm-review/orchestration.md` in this repository and follow it exactly — it is the
|
||||
canonical, CLI-neutral review contract (lanes, classifications, output structure, finding
|
||||
format, hidden-Unicode checks, behavior rules).
|
||||
|
||||
Run in **Solo mode**: you are a single agent, so perform all seven lanes yourself in
|
||||
dependency order, adopting each persona in `pr-swarm-review/personas/0N-*.md` in turn
|
||||
(lanes 1–2 first, then 3–6, then lane 7). Keep every lane's findings in context. Lane 7
|
||||
(synthesis critic) is a hard gate: do not emit the final review until its "Required
|
||||
corrections before posting" section is empty.
|
||||
|
||||
Stay strictly read-only: investigate and report; never edit files, commit, or post to GitHub.
|
||||
@@ -1,53 +0,0 @@
|
||||
# release-drafter config — used only for PR autolabeling by
|
||||
# `.github/workflows/pr-labeler.yml` (the workflow passes `disable-releaser: true`,
|
||||
# so the draft-release side of release-drafter never runs).
|
||||
#
|
||||
# The labels applied here are the same ones `.github/release.yml` maps to
|
||||
# categorized release-notes sections.
|
||||
#
|
||||
# `sync-labels: true` removes managed autolabels that no longer match the PR —
|
||||
# critical for the breaking-change case: if a PR title drops the `!` or the body
|
||||
# drops `BREAKING CHANGE:`, the `breaking` label is pulled off automatically.
|
||||
|
||||
# Required by release-drafter; not used because releaser is disabled.
|
||||
name-template: 'unused'
|
||||
tag-template: 'unused'
|
||||
template: |
|
||||
$CHANGES
|
||||
|
||||
sync-labels: true
|
||||
|
||||
autolabeler:
|
||||
- label: enhancement
|
||||
title:
|
||||
- '/^feat(\([^)]+\))?!?:/i'
|
||||
- label: bug
|
||||
title:
|
||||
- '/^fix(\([^)]+\))?!?:/i'
|
||||
- label: performance
|
||||
title:
|
||||
- '/^perf(\([^)]+\))?!?:/i'
|
||||
- label: refactor
|
||||
title:
|
||||
- '/^refactor(\([^)]+\))?!?:/i'
|
||||
- label: documentation
|
||||
title:
|
||||
- '/^docs(\([^)]+\))?!?:/i'
|
||||
- label: test
|
||||
title:
|
||||
- '/^test(\([^)]+\))?!?:/i'
|
||||
- label: ci
|
||||
title:
|
||||
- '/^ci(\([^)]+\))?!?:/i'
|
||||
- label: dependencies
|
||||
title:
|
||||
- '/^(build|deps)(\([^)]+\))?!?:/i'
|
||||
- label: chore
|
||||
title:
|
||||
- '/^(chore|revert)(\([^)]+\))?!?:/i'
|
||||
# Breaking-change marker: either `!` in the type prefix or `BREAKING CHANGE:` in body.
|
||||
- label: breaking
|
||||
title:
|
||||
- '/^[a-z]+(\([^)]+\))?!:/i'
|
||||
body:
|
||||
- '/BREAKING[ -]CHANGE:/i'
|
||||
+1
-1
@@ -35,7 +35,7 @@ changelog:
|
||||
- dependencies
|
||||
- title: "\U0001F4DD Other Changes"
|
||||
labels:
|
||||
- '*'
|
||||
- "*"
|
||||
exclude:
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
@@ -1,919 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Monitor tree-sitter 0.25 upgrade readiness.
|
||||
|
||||
Tracks two things Dependabot cannot see:
|
||||
|
||||
1. Peer-dep compatibility. Each tree-sitter-* grammar declares a peer
|
||||
dependency on the tree-sitter runtime. We want to know when every
|
||||
grammar's *latest npm release* satisfies tree-sitter@0.25.0 so we
|
||||
can upgrade without --legacy-peer-deps.
|
||||
|
||||
2. Vendored upstream drift. vendor/tree-sitter-proto/ is a snapshot of
|
||||
coder3101/tree-sitter-proto's parser.c. When upstream moves, we want
|
||||
to know whether we can pick it up.
|
||||
|
||||
Invoked from .github/workflows/tree-sitter-upgrade-readiness.yml daily.
|
||||
Runs locally too:
|
||||
|
||||
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py
|
||||
|
||||
Outputs Markdown to stdout. Exit 0 when every grammar is upgrade-ready
|
||||
and the vendored proto is in sync. Exit 1 when blockers remain (the
|
||||
workflow uses this to open or update a tracking issue).
|
||||
|
||||
No external deps -- stdlib only, so it runs on any vanilla runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
GITNEXUS_DIR = REPO_ROOT / "gitnexus"
|
||||
|
||||
# ── 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_MAJOR_MINOR = ".".join(TARGET_RUNTIME.split(".")[:2])
|
||||
|
||||
# Tree-sitter runtime -> (min_abi, max_abi) it can load. Only the current
|
||||
# and target entries matter; extend when changing TARGET_RUNTIME.
|
||||
RUNTIME_ABI_RANGES: dict[str, tuple[int, int]] = {
|
||||
"0.21": (13, 14),
|
||||
"0.25": (13, 15),
|
||||
}
|
||||
|
||||
assert TARGET_RUNTIME_MAJOR_MINOR in RUNTIME_ABI_RANGES, (
|
||||
f"RUNTIME_ABI_RANGES has no entry for {TARGET_RUNTIME_MAJOR_MINOR!r}. "
|
||||
f"Add the ABI range after auditing the upstream release notes."
|
||||
)
|
||||
|
||||
# Grammars we use. Values are the upstream GitHub repos to check for
|
||||
# unreleased ABI bumps (owner/repo, branch, parser.c path).
|
||||
GRAMMARS: dict[str, tuple[str, str, str]] = {
|
||||
"tree-sitter-c": ("tree-sitter/tree-sitter-c", "master", "src/parser.c"),
|
||||
"tree-sitter-c-sharp": ("tree-sitter/tree-sitter-c-sharp", "master", "src/parser.c"),
|
||||
"tree-sitter-cpp": ("tree-sitter/tree-sitter-cpp", "master", "src/parser.c"),
|
||||
"tree-sitter-dart": ("UserNobody14/tree-sitter-dart", "master", "src/parser.c"),
|
||||
"tree-sitter-go": ("tree-sitter/tree-sitter-go", "master", "src/parser.c"),
|
||||
"tree-sitter-java": ("tree-sitter/tree-sitter-java", "master", "src/parser.c"),
|
||||
"tree-sitter-javascript": ("tree-sitter/tree-sitter-javascript", "master", "src/parser.c"),
|
||||
"tree-sitter-kotlin": ("fwcd/tree-sitter-kotlin", "main", "src/parser.c"),
|
||||
"tree-sitter-php": ("tree-sitter/tree-sitter-php", "master", "php/src/parser.c"),
|
||||
"tree-sitter-python": ("tree-sitter/tree-sitter-python", "master", "src/parser.c"),
|
||||
"tree-sitter-ruby": ("tree-sitter/tree-sitter-ruby", "master", "src/parser.c"),
|
||||
"tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"),
|
||||
"tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"),
|
||||
"tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"),
|
||||
# Vendored parsers — kept here so the upstream coords for drift
|
||||
# detection are co-located with every other grammar's coords.
|
||||
"tree-sitter-proto": ("coder3101/tree-sitter-proto", "main", "src/parser.c"),
|
||||
}
|
||||
|
||||
# Grammars deliberately held below npm latest. The readiness report surfaces
|
||||
# these so reviewers can tell intentional pins apart from drift, and so the
|
||||
# context for each pin (which issue motivated it) is visible at a glance.
|
||||
# Add an entry whenever you pin a grammar below npm latest.
|
||||
INTENTIONAL_PINS: dict[str, str] = {
|
||||
"tree-sitter-c": (
|
||||
"#1242 — last release built against the tree-sitter@0.21 ABI; "
|
||||
"tree-sitter-c@0.23.x prebuilds segfault on Windows under tree-sitter@0.21.1"
|
||||
),
|
||||
"tree-sitter-cpp": (
|
||||
"#1242 — last 0.23.x release before tree-sitter-cpp added a runtime "
|
||||
"dep on the broken-ABI tree-sitter-c@^0.23.1; pinning here removes "
|
||||
"the need for a transitive override"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_package_json() -> dict:
|
||||
return json.loads((GITNEXUS_DIR / "package.json").read_text())
|
||||
|
||||
|
||||
def read_current_runtime() -> str:
|
||||
"""Return the tree-sitter runtime version pinned in package.json (e.g. '0.21')."""
|
||||
pkg = _load_package_json()
|
||||
raw = pkg["dependencies"]["tree-sitter"]
|
||||
match = re.search(r"(\d+)\.(\d+)", raw)
|
||||
if not match:
|
||||
raise SystemExit(f"could not parse tree-sitter version: {raw!r}")
|
||||
return f"{match.group(1)}.{match.group(2)}"
|
||||
|
||||
|
||||
def read_pinned_grammar_versions() -> dict[str, str]:
|
||||
"""Return the grammar version range pinned in gitnexus/package.json.
|
||||
|
||||
Looks at both runtime and optional dependencies. Returns the raw range
|
||||
string (e.g. '0.21.4', '^0.23.0', 'file:./vendor/...') so the report can
|
||||
expose how flexible each pin is.
|
||||
"""
|
||||
pkg = _load_package_json()
|
||||
pinned: dict[str, str] = {}
|
||||
for section in ("dependencies", "optionalDependencies"):
|
||||
for name, spec in (pkg.get(section) or {}).items():
|
||||
if name.startswith("tree-sitter-"):
|
||||
pinned[name] = spec
|
||||
return pinned
|
||||
|
||||
|
||||
def npm_view_json(pkg: str) -> dict | None:
|
||||
"""Fetch package metadata from the npm registry via HTTPS.
|
||||
|
||||
Uses the registry API directly so we don't depend on the npm CLI
|
||||
being available (it's a batch file on Windows which complicates
|
||||
subprocess calls).
|
||||
"""
|
||||
url = f"https://registry.npmjs.org/{pkg}/latest"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def satisfies_target(peer_range: str | None, target: str) -> bool:
|
||||
"""Check if a semver range like '^0.22.4' or '^0.25.0' satisfies the target.
|
||||
|
||||
Simple heuristic: extract the minimum version from the range and check
|
||||
if target >= min. For caret ranges (^X.Y.Z), the upper bound is the
|
||||
next major (for X>0) or next minor (for X==0). We check both bounds.
|
||||
"""
|
||||
if peer_range is None:
|
||||
# No peer dep declared = no constraint = compatible.
|
||||
return True
|
||||
match = re.search(r"(\d+)\.(\d+)\.(\d+)", peer_range)
|
||||
if not match:
|
||||
return False
|
||||
min_major, min_minor, min_patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
|
||||
t_match = re.search(r"(\d+)\.(\d+)\.(\d+)", target)
|
||||
if not t_match:
|
||||
return False
|
||||
t_major, t_minor, t_patch = int(t_match.group(1)), int(t_match.group(2)), int(t_match.group(3))
|
||||
|
||||
# Target must be >= minimum.
|
||||
target_tuple = (t_major, t_minor, t_patch)
|
||||
min_tuple = (min_major, min_minor, min_patch)
|
||||
if target_tuple < min_tuple:
|
||||
return False
|
||||
|
||||
# For caret ranges with major 0: ^0.X.Y allows [0.X.Y, 0.(X+1).0).
|
||||
if peer_range.startswith("^") and min_major == 0:
|
||||
if t_major != 0 or t_minor >= min_minor + 1:
|
||||
return False
|
||||
# For caret ranges with major >0: ^X.Y.Z allows [X.Y.Z, (X+1).0.0).
|
||||
elif peer_range.startswith("^") and min_major > 0:
|
||||
if t_major >= min_major + 1:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
_GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
|
||||
|
||||
|
||||
def fetch_text(url: str, timeout: int = 8) -> str | None:
|
||||
"""Fetch a URL and return its text, or None on failure.
|
||||
|
||||
Adds an Authorization header for github.com URLs when GITHUB_TOKEN is
|
||||
set (raises the rate limit from 60 to 5 000 requests/hour).
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
# Parse the URL and check the hostname rather than substring-matching
|
||||
# on the full URL string (CodeQL py/incomplete-url-substring-sanitization).
|
||||
# `https://evil.com/?u=github.com` would have passed the substring check.
|
||||
try:
|
||||
parsed_host = urllib.parse.urlparse(url).hostname or ""
|
||||
except ValueError:
|
||||
parsed_host = ""
|
||||
is_github_host = parsed_host == "github.com" or parsed_host.endswith(
|
||||
(".github.com", ".githubusercontent.com")
|
||||
) or parsed_host == "githubusercontent.com"
|
||||
if _GITHUB_TOKEN and is_github_host:
|
||||
headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read().decode("utf-8", errors="ignore")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_abi_from_text(text: str) -> int | None:
|
||||
"""Extract LANGUAGE_VERSION from parser.c text."""
|
||||
match = re.search(r"#define\s+LANGUAGE_VERSION\s+(\d+)", text[:4096])
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def extract_language_version(parser_c: pathlib.Path) -> int | None:
|
||||
"""Return the LANGUAGE_VERSION defined in a parser.c, or None if absent."""
|
||||
if not parser_c.is_file():
|
||||
return None
|
||||
with parser_c.open("r", encoding="utf-8", errors="ignore") as fh:
|
||||
head = fh.read(4096)
|
||||
return extract_abi_from_text(head)
|
||||
|
||||
|
||||
def md_h(text: str, level: int = 2) -> str:
|
||||
return f"{'#' * level} {text}\n"
|
||||
|
||||
|
||||
def _first_sentence(text: str) -> str:
|
||||
"""Return the leading sentence of a free-form rationale string.
|
||||
|
||||
Vendor package.json `_vendoredBy` fields often look like
|
||||
"<reason>. <install-script breadcrumb>. Do NOT <warning>." — the
|
||||
first sentence is what reviewers actually want to read; the rest is
|
||||
noise in this context. Match a sentence-ending '.' followed by
|
||||
whitespace; fall back to the whole string if nothing matches.
|
||||
"""
|
||||
text = text.strip()
|
||||
match = re.search(r"\.\s+[A-Z]", text)
|
||||
return text[: match.start() + 1] if match else text
|
||||
|
||||
|
||||
def range_includes(spec: str | None, version: str) -> bool:
|
||||
"""Return True if pinned-range `spec` accepts the concrete `version`.
|
||||
|
||||
Handles the spec shapes we actually use in package.json:
|
||||
- exact pins ('0.21.4')
|
||||
- caret / tilde ranges ('^0.23.0', '~0.23.5')
|
||||
- non-registry pins ('file:./vendor/...', 'git+...') — always False,
|
||||
because there's no meaningful "behind npm latest" comparison.
|
||||
"""
|
||||
if not spec or spec == "—":
|
||||
return False
|
||||
if spec.startswith(("file:", "git", "http")):
|
||||
return False
|
||||
if spec.startswith(("^", "~")):
|
||||
return satisfies_target(spec, version)
|
||||
return spec.strip() == version.strip()
|
||||
|
||||
|
||||
def is_vendored_pin(spec: str | None) -> bool:
|
||||
return bool(spec) and spec.startswith(("file:", "git", "http"))
|
||||
|
||||
|
||||
def vendored_drift_summary(
|
||||
name: str, upstream_repo: str, upstream_branch: str, parser_path: str
|
||||
) -> dict:
|
||||
"""Inspect a vendored grammar under gitnexus/vendor/<name>.
|
||||
|
||||
Returns the vendored package.json's ``version`` and ``_vendoredBy``
|
||||
fields (which carry the human rationale for vendoring), the vendored
|
||||
parser's ABI, and a comparison against upstream main. We deliberately
|
||||
rely on ``_vendoredBy`` rather than a parallel registry in this
|
||||
script: the rationale belongs next to the vendored sources, not in
|
||||
a daily-running CI script.
|
||||
"""
|
||||
vendor_dir = GITNEXUS_DIR / "vendor" / name
|
||||
pkg: dict = {}
|
||||
pkg_path = vendor_dir / "package.json"
|
||||
if pkg_path.is_file():
|
||||
try:
|
||||
pkg = json.loads(pkg_path.read_text(encoding="utf-8", errors="ignore"))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
vendored_parser = vendor_dir / parser_path
|
||||
if not vendored_parser.is_file():
|
||||
vendored_parser = vendor_dir / "src" / "parser.c"
|
||||
vendored_abi = extract_language_version(vendored_parser)
|
||||
|
||||
upstream_url = (
|
||||
f"https://raw.githubusercontent.com/{upstream_repo}/"
|
||||
f"{upstream_branch}/{parser_path}"
|
||||
)
|
||||
upstream_text = fetch_text(upstream_url)
|
||||
upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None
|
||||
|
||||
sha_text = fetch_text(
|
||||
f"https://api.github.com/repos/{upstream_repo}/commits/{upstream_branch}"
|
||||
)
|
||||
upstream_sha = "?"
|
||||
if sha_text:
|
||||
try:
|
||||
upstream_sha = json.loads(sha_text).get("sha", "?")[:12]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
local_text = (
|
||||
vendored_parser.read_text(encoding="utf-8", errors="ignore")
|
||||
if vendored_parser.is_file()
|
||||
else ""
|
||||
)
|
||||
in_sync = bool(
|
||||
upstream_text
|
||||
and local_text.replace("\r\n", "\n") == upstream_text.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"vendored_version": pkg.get("version", "?"),
|
||||
"vendored_by": pkg.get("_vendoredBy"),
|
||||
"vendored_abi": vendored_abi,
|
||||
"upstream_repo": upstream_repo,
|
||||
"upstream_branch": upstream_branch,
|
||||
"upstream_sha": upstream_sha,
|
||||
"upstream_abi": upstream_abi,
|
||||
"in_sync": in_sync,
|
||||
}
|
||||
|
||||
|
||||
# ── Assert mode (CI gate) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def assert_current() -> int:
|
||||
"""Assert every grammar's ABI is loadable by the CURRENT runtime.
|
||||
|
||||
Unlike the readiness report (which probes the npm registry + upstream
|
||||
main for the *target* runtime), this mode is hermetic and offline: it
|
||||
reads only what's checked out / installed locally and asserts each
|
||||
grammar's compiled ABI lies within the current runtime's
|
||||
``RUNTIME_ABI_RANGES`` window. It is the static half of the #1922 ABI
|
||||
gate; the runtime load-smoke (`parser-loader-abi.test.ts`) is the
|
||||
dynamic half.
|
||||
|
||||
Coverage, reusing the existing helpers:
|
||||
- npm-installed grammars: ABI from node_modules/<name>/<parser.c>.
|
||||
- vendored grammars (dart/proto/swift): ABI via ``vendored_drift_summary``.
|
||||
- Swift is prebuilt-only (no parser.c) → not introspectable here;
|
||||
treated as "covered by the runtime load-smoke", not asserted.
|
||||
- INTENTIONAL_PINS are honored: a pinned grammar is expected to sit at
|
||||
an ABI the current runtime loads (that's *why* it's pinned), so it is
|
||||
asserted like any other rather than skipped.
|
||||
|
||||
Returns 0 when every introspectable grammar is in range, 1 otherwise.
|
||||
Prints a plain-text (non-Markdown) report so CI logs stay readable.
|
||||
"""
|
||||
current_runtime = read_current_runtime()
|
||||
abi_range = RUNTIME_ABI_RANGES.get(current_runtime)
|
||||
if abi_range is None:
|
||||
print(
|
||||
f"FAIL: RUNTIME_ABI_RANGES has no entry for current runtime "
|
||||
f"{current_runtime!r}; add it before asserting.",
|
||||
)
|
||||
return 1
|
||||
lo, hi = abi_range
|
||||
pinned_versions = read_pinned_grammar_versions()
|
||||
|
||||
print(
|
||||
f"Asserting all grammar ABIs load on tree-sitter@{current_runtime}.x "
|
||||
f"(ABI {lo}–{hi})."
|
||||
)
|
||||
|
||||
failures: list[str] = []
|
||||
checked = 0
|
||||
skipped: list[str] = []
|
||||
|
||||
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
|
||||
pinned_spec = pinned_versions.get(name, "—")
|
||||
pin_note = f" [intentional pin: {pinned_spec}]" if name in INTENTIONAL_PINS else ""
|
||||
|
||||
if is_vendored_pin(pinned_spec):
|
||||
v = vendored_drift_summary(name, upstream_repo, upstream_branch, parser_path)
|
||||
abi = v["vendored_abi"]
|
||||
if abi is None:
|
||||
# Prebuilt-only vendor (e.g. tree-sitter-swift): no parser.c to
|
||||
# introspect. The runtime load-smoke covers it instead.
|
||||
skipped.append(f"{name} (vendored, prebuilt — covered by load-smoke)")
|
||||
continue
|
||||
checked += 1
|
||||
if lo <= abi <= hi:
|
||||
print(f" OK {name}: vendored ABI {abi} in range{pin_note}")
|
||||
else:
|
||||
msg = (
|
||||
f"{name}: vendored ABI {abi} outside current runtime range "
|
||||
f"{lo}..{hi}{pin_note}"
|
||||
)
|
||||
print(f" FAIL {msg}")
|
||||
failures.append(msg)
|
||||
continue
|
||||
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path
|
||||
if not installed_parser.is_file():
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c"
|
||||
abi = extract_language_version(installed_parser)
|
||||
if abi is None:
|
||||
skipped.append(f"{name} (not installed / no parser.c — covered by load-smoke)")
|
||||
continue
|
||||
checked += 1
|
||||
if lo <= abi <= hi:
|
||||
print(f" OK {name}: installed ABI {abi} in range{pin_note}")
|
||||
else:
|
||||
msg = (
|
||||
f"{name}: installed ABI {abi} outside current runtime range "
|
||||
f"{lo}..{hi}{pin_note}"
|
||||
)
|
||||
print(f" FAIL {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
print("")
|
||||
if skipped:
|
||||
print("Not statically introspectable (asserted via runtime load-smoke):")
|
||||
for s in skipped:
|
||||
print(f" - {s}")
|
||||
print("")
|
||||
|
||||
if failures:
|
||||
print(f"RESULT: FAIL — {len(failures)} grammar(s) out of range, {checked} checked.")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
return 1
|
||||
|
||||
print(f"RESULT: OK — all {checked} introspectable grammar ABIs in range.")
|
||||
return 0
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _classify_grammar(
|
||||
*,
|
||||
name: str,
|
||||
pinned_spec: str | None,
|
||||
npm_version: str,
|
||||
peer_range: str | None,
|
||||
fetch_failed: bool,
|
||||
target_compat: bool,
|
||||
current_compat: bool,
|
||||
upstream_progress: str | None,
|
||||
) -> dict:
|
||||
"""Decide a single primary disposition + a separate bump-now hint.
|
||||
|
||||
Buckets are mutually exclusive and ordered by what a reviewer should
|
||||
look at first:
|
||||
- fetch_failed : npm registry fetch failed (treat as blocker, but
|
||||
surface separately so reviewers don't confuse it
|
||||
with an upstream block)
|
||||
- intentional : pinned in INTENTIONAL_PINS — explicit choice
|
||||
- ready : npm-latest peer dep already accepts the target
|
||||
runtime; nothing to do
|
||||
- waiting : main has a fix (ABI 15 or relaxed peer) but no
|
||||
published npm release yet
|
||||
- blocked : peer dep too tight on both npm and main
|
||||
|
||||
Independently of bucket, `bump_now` reports whether reviewers can
|
||||
move the pin forward today without touching the runtime — we only
|
||||
suggest it when npm-latest's peer dep also accepts our *current*
|
||||
runtime, otherwise the bump would break `npm install`.
|
||||
"""
|
||||
is_vendored = is_vendored_pin(pinned_spec)
|
||||
behind_latest = (
|
||||
not is_vendored
|
||||
and npm_version != "?"
|
||||
and not range_includes(pinned_spec, npm_version)
|
||||
)
|
||||
# Intentional pins must never appear as actionable bumps — by definition
|
||||
# we're holding them back on purpose. The pin can only be lifted by
|
||||
# editing INTENTIONAL_PINS and package.json together.
|
||||
bump_now = behind_latest and current_compat and name not in INTENTIONAL_PINS
|
||||
|
||||
if fetch_failed:
|
||||
bucket = "fetch_failed"
|
||||
elif name in INTENTIONAL_PINS:
|
||||
bucket = "intentional"
|
||||
elif target_compat:
|
||||
bucket = "ready"
|
||||
elif upstream_progress:
|
||||
bucket = "waiting"
|
||||
else:
|
||||
bucket = "blocked"
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"pinned_spec": pinned_spec or "—",
|
||||
"npm_version": npm_version,
|
||||
"peer_range": peer_range,
|
||||
"target_compat": target_compat,
|
||||
"current_compat": current_compat,
|
||||
"upstream_progress": upstream_progress,
|
||||
"behind_latest": behind_latest,
|
||||
"bump_now": bump_now,
|
||||
"bucket": bucket,
|
||||
"is_vendored": is_vendored,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
blockers: dict[str, str] = {}
|
||||
lines: list[str] = []
|
||||
lines.append(md_h("Tree-sitter 0.25 upgrade readiness", 1))
|
||||
lines.append("")
|
||||
|
||||
current_runtime = read_current_runtime()
|
||||
current_abi_range = RUNTIME_ABI_RANGES.get(current_runtime, (0, 0))
|
||||
target_abi_range = RUNTIME_ABI_RANGES.get(TARGET_RUNTIME_MAJOR_MINOR, (0, 0))
|
||||
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"(ABI {target_abi_range[0]}–{target_abi_range[1]})."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# First pass: gather raw data + classification per grammar. We render
|
||||
# the human-friendly buckets first, then the raw matrix in a <details>
|
||||
# block at the end. Status text in the matrix is preserved verbatim
|
||||
# so the workflow's row-diff change-detection keeps working.
|
||||
grammar_rows: list[dict] = []
|
||||
raw_matrix: list[str] = [
|
||||
"| Grammar | Pinned | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |",
|
||||
"|---|---|---|---|---|---|---|---|",
|
||||
]
|
||||
|
||||
vendored_grammars: list[dict] = []
|
||||
|
||||
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
|
||||
pinned_spec = pinned_versions.get(name, "—")
|
||||
|
||||
# Vendored grammars don't have an "npm latest" we install from —
|
||||
# we ship our own copy under gitnexus/vendor/<name>. Treat them
|
||||
# as a separate kind of artefact: their readiness for the runtime
|
||||
# upgrade depends on the vendored ABI being in the target range,
|
||||
# not on a peer-dep negotiation.
|
||||
if is_vendored_pin(pinned_spec):
|
||||
v = vendored_drift_summary(name, upstream_repo, upstream_branch, parser_path)
|
||||
v["pinned_spec"] = pinned_spec
|
||||
# Three-state classification: in-range, out-of-range, or
|
||||
# not-introspectable (e.g. tree-sitter-swift ships only
|
||||
# prebuilt .node binaries, no parser.c — assume compatible).
|
||||
if v["vendored_abi"] is None:
|
||||
v["target_compat"] = True
|
||||
v["abi_state"] = "prebuilt"
|
||||
status = "Vendored (prebuilt — ABI not introspectable)"
|
||||
elif target_abi_range[0] <= v["vendored_abi"] <= target_abi_range[1]:
|
||||
v["target_compat"] = True
|
||||
v["abi_state"] = "in_range"
|
||||
status = "Vendored (ABI in target range)"
|
||||
else:
|
||||
v["target_compat"] = False
|
||||
v["abi_state"] = "out_of_range"
|
||||
status = "Vendored (ABI out of range)"
|
||||
blockers[name] = (
|
||||
f"vendored `{name}`: ABI {v['vendored_abi']} outside target range "
|
||||
f"{target_abi_range[0]}..{target_abi_range[1]}"
|
||||
)
|
||||
# Keep vendored grammars in the raw matrix so the workflow's
|
||||
# row-diff change-detection picks up status transitions on
|
||||
# them too. npm-only columns get sentinels.
|
||||
raw_matrix.append(
|
||||
f"| `{name}` | {pinned_spec} | (vendored) | (vendored) | "
|
||||
f"{'Yes' if v['target_compat'] else '**No**'} | "
|
||||
f"{v['vendored_abi'] or '?'} | {v['upstream_abi'] or '?'} | {status} |"
|
||||
)
|
||||
vendored_grammars.append(v)
|
||||
continue
|
||||
|
||||
# Fetch latest npm metadata.
|
||||
info = npm_view_json(name)
|
||||
fetch_failed = info is None
|
||||
npm_version = "?"
|
||||
peer_range = None
|
||||
peer_optional = True
|
||||
if info:
|
||||
npm_version = info.get("version", "?")
|
||||
peers = info.get("peerDependencies") or {}
|
||||
peer_range = peers.get("tree-sitter")
|
||||
meta = info.get("peerDependenciesMeta") or {}
|
||||
ts_meta = meta.get("tree-sitter") or {}
|
||||
peer_optional = ts_meta.get("optional", False) if peer_range else True
|
||||
|
||||
if fetch_failed:
|
||||
peer_display = "? (fetch failed)"
|
||||
target_compat = False
|
||||
current_compat = False
|
||||
else:
|
||||
peer_display = peer_range or "none"
|
||||
if peer_range and not peer_optional:
|
||||
peer_display += " (required)"
|
||||
target_compat = satisfies_target(peer_range, TARGET_RUNTIME)
|
||||
current_compat = satisfies_target(peer_range, f"{current_runtime}.0")
|
||||
|
||||
# Check installed ABI using the same parser_path from GRAMMARS.
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path
|
||||
if not installed_parser.is_file():
|
||||
# Fallback to default location.
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c"
|
||||
installed_abi = extract_language_version(installed_parser)
|
||||
abi_display = str(installed_abi) if installed_abi else "?"
|
||||
|
||||
# Check upstream (main/master branch) ABI for unreleased work.
|
||||
upstream_url = (
|
||||
f"https://raw.githubusercontent.com/{upstream_repo}/"
|
||||
f"{upstream_branch}/{parser_path}"
|
||||
)
|
||||
upstream_text = fetch_text(upstream_url)
|
||||
upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None
|
||||
upstream_abi_display = str(upstream_abi) if upstream_abi else "?"
|
||||
|
||||
# Status text + upstream-progress detection. The Status column
|
||||
# values are preserved as-is to keep the workflow's row-diff
|
||||
# change-detection working on the raw matrix below.
|
||||
upstream_progress: str | None = None
|
||||
if fetch_failed:
|
||||
status = "Unknown (fetch failed)"
|
||||
blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep"
|
||||
elif name in INTENTIONAL_PINS:
|
||||
# An intentional pin is, by definition, a held-back grammar:
|
||||
# whatever npm-latest's peer dep says, our shipped version is
|
||||
# the one whose ABI/peer must accept the target runtime, and
|
||||
# the pin entry exists precisely because it does not. Treat
|
||||
# it as a blocker until the pin is lifted (entry removed from
|
||||
# INTENTIONAL_PINS), at which point this grammar falls back
|
||||
# to standard classification on the next run.
|
||||
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"
|
||||
)
|
||||
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"
|
||||
|
||||
# Also check upstream package.json for relaxed peer dep — beats
|
||||
# the ABI-15 hint when both are true.
|
||||
if not target_compat and not fetch_failed:
|
||||
upstream_pkg_url = (
|
||||
f"https://raw.githubusercontent.com/{upstream_repo}/"
|
||||
f"{upstream_branch}/package.json"
|
||||
)
|
||||
upstream_pkg_text = fetch_text(upstream_pkg_url)
|
||||
if upstream_pkg_text:
|
||||
try:
|
||||
upstream_pkg = json.loads(upstream_pkg_text)
|
||||
upstream_peer = (upstream_pkg.get("peerDependencies") or {}).get("tree-sitter")
|
||||
if upstream_peer and satisfies_target(upstream_peer, TARGET_RUNTIME):
|
||||
status = "Unreleased (peer relaxed on main)"
|
||||
upstream_progress = (
|
||||
f"peer relaxed to `{upstream_peer}` on "
|
||||
f"`{upstream_repo}@{upstream_branch}` not yet published"
|
||||
)
|
||||
blockers[name] = f"`{name}`: peer dep relaxed on `{upstream_repo}` main but not published to npm"
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
pinned_spec = pinned_versions.get(name, "—")
|
||||
compat_icon = "Yes" if target_compat else "**No**"
|
||||
raw_matrix.append(
|
||||
f"| `{name}` | {pinned_spec} | {npm_version} | {peer_display} | "
|
||||
f"{compat_icon} | {abi_display} | {upstream_abi_display} | {status} |"
|
||||
)
|
||||
|
||||
grammar_rows.append(_classify_grammar(
|
||||
name=name,
|
||||
pinned_spec=pinned_spec,
|
||||
npm_version=npm_version,
|
||||
peer_range=peer_range,
|
||||
fetch_failed=fetch_failed,
|
||||
target_compat=target_compat,
|
||||
current_compat=current_compat,
|
||||
upstream_progress=upstream_progress,
|
||||
))
|
||||
|
||||
# ── Bucketize ────────────────────────────────────────────────────
|
||||
by_bucket: dict[str, list[dict]] = {
|
||||
k: [] for k in ("ready", "intentional", "waiting", "blocked", "fetch_failed")
|
||||
}
|
||||
for row in grammar_rows:
|
||||
by_bucket[row["bucket"]].append(row)
|
||||
bump_now = [r for r in grammar_rows if r["bump_now"]]
|
||||
ready_count = len(by_bucket["ready"])
|
||||
|
||||
# ── TL;DR ────────────────────────────────────────────────────────
|
||||
npm_count = len(grammar_rows)
|
||||
vendored_count = len(vendored_grammars)
|
||||
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."
|
||||
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"Upstream movement: {moved}."
|
||||
)
|
||||
|
||||
lines.append(md_h("TL;DR", 2))
|
||||
lines.append(verdict)
|
||||
lines.append("")
|
||||
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"
|
||||
)
|
||||
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)")
|
||||
if by_bucket['fetch_failed']:
|
||||
lines.append(f"- {len(by_bucket['fetch_failed'])} could not be checked (npm registry unreachable)")
|
||||
if bump_now:
|
||||
lines.append(
|
||||
f"- **{len(bump_now)} bump candidate(s) you can take TODAY** (npm-latest "
|
||||
f"is newer than the pin AND its peer dep accepts our current runtime)"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── What you can do today ───────────────────────────────────────
|
||||
if bump_now:
|
||||
lines.append(md_h("What you can do today", 2))
|
||||
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."
|
||||
)
|
||||
lines.append("")
|
||||
for r in sorted(bump_now, key=lambda r: r["name"]):
|
||||
lines.append(
|
||||
f"- `{r['name']}`: `{r['pinned_spec']}` → `{r['npm_version']}` "
|
||||
f"(peer `{r['peer_range'] or 'none'}`)"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── Per-disposition sections ────────────────────────────────────
|
||||
def _emit_bucket(title: str, body_intro: str, rows: list[dict], render) -> None:
|
||||
if not rows:
|
||||
return
|
||||
lines.append(md_h(f"{title} ({len(rows)})", 3))
|
||||
lines.append(body_intro)
|
||||
lines.append("")
|
||||
for r in sorted(rows, key=lambda r: r["name"]):
|
||||
lines.append(render(r))
|
||||
lines.append("")
|
||||
|
||||
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.",
|
||||
by_bucket["ready"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}` — pinned `{r['pinned_spec']}`, npm latest `{r['npm_version']}`"
|
||||
+ (" _(also a bump candidate — see above)_" if r["bump_now"] else "")
|
||||
),
|
||||
)
|
||||
|
||||
if by_bucket["intentional"]:
|
||||
lines.append(md_h(f"Intentionally pinned ({len(by_bucket['intentional'])})", 3))
|
||||
lines.append(
|
||||
"Deliberately held below npm latest. These are **not** drift — each entry "
|
||||
"lists the issue motivating the pin and the condition for unpinning."
|
||||
)
|
||||
lines.append("")
|
||||
for r in sorted(by_bucket["intentional"], key=lambda r: r["name"]):
|
||||
reason = INTENTIONAL_PINS.get(r["name"], "(no rationale recorded)")
|
||||
lines.append(
|
||||
f"- `{r['name']}` pinned at `{r['pinned_spec']}` "
|
||||
f"(npm latest `{r['npm_version']}`)\n {reason}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
_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.",
|
||||
by_bucket["waiting"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`. "
|
||||
f"_{r['upstream_progress']}_"
|
||||
),
|
||||
)
|
||||
|
||||
_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.",
|
||||
by_bucket["blocked"],
|
||||
lambda r: (
|
||||
f"- `{r['name']}@{r['npm_version']}` — peer `{r['peer_range'] or 'none'}`"
|
||||
+ (" _(vendored)_" if r["is_vendored"] else "")
|
||||
),
|
||||
)
|
||||
|
||||
_emit_bucket(
|
||||
"Could not check",
|
||||
"npm registry fetch failed for these grammars. Re-run the workflow to retry.",
|
||||
by_bucket["fetch_failed"],
|
||||
lambda r: f"- `{r['name']}` (pinned `{r['pinned_spec']}`)",
|
||||
)
|
||||
|
||||
# ── Vendored parsers ────────────────────────────────────────────
|
||||
if vendored_grammars:
|
||||
lines.append(md_h(f"Vendored parsers ({len(vendored_grammars)})", 2))
|
||||
lines.append(
|
||||
"These grammars ship from `gitnexus/vendor/` rather than the npm "
|
||||
"registry. Their compatibility is governed by the **vendored "
|
||||
"ABI** (must lie in the target runtime's range), not by a peer-"
|
||||
"dep negotiation. The rationale for each vendored copy lives in "
|
||||
"its own `package.json` `_vendoredBy` field."
|
||||
)
|
||||
lines.append("")
|
||||
for v in sorted(vendored_grammars, key=lambda v: v["name"]):
|
||||
sync_label = (
|
||||
"in sync with upstream" if v["in_sync"] else "diverged from upstream"
|
||||
)
|
||||
if v["abi_state"] == "in_range":
|
||||
abi_label = f"ABI `{v['vendored_abi']}` (in target range)"
|
||||
elif v["abi_state"] == "prebuilt":
|
||||
abi_label = "ABI `prebuilt` (binary-only vendor, source not introspectable)"
|
||||
else:
|
||||
abi_label = (
|
||||
f"ABI `{v['vendored_abi']}` (**outside** target range "
|
||||
f"{target_abi_range[0]}..{target_abi_range[1]})"
|
||||
)
|
||||
upstream_abi_str = (
|
||||
f"ABI `{v['upstream_abi']}`" if v["upstream_abi"] else "ABI `?`"
|
||||
)
|
||||
lines.append(
|
||||
f"- **`{v['name']}`** `{v['vendored_version']}` — {abi_label}, "
|
||||
f"upstream `{v['upstream_repo']}@{v['upstream_sha']}` "
|
||||
f"{upstream_abi_str} · {sync_label}"
|
||||
)
|
||||
if v["vendored_by"]:
|
||||
# Show the first sentence — vendor package.json fields tend
|
||||
# to start with the rationale and tail off into install-
|
||||
# script breadcrumbs that aren't useful in this report.
|
||||
rationale = _first_sentence(v["vendored_by"])
|
||||
lines.append(f" - **Why vendored:** {rationale}")
|
||||
# Action computation: needs regen iff upstream ABI exceeds
|
||||
# vendored AND is still within target range. If upstream ABI
|
||||
# exceeds the target, that's a runtime-side blocker. For
|
||||
# prebuilt-only vendors we can't drive this from source ABI;
|
||||
# the action is a manual upstream-binary refresh, surfaced
|
||||
# via the in-sync flag instead.
|
||||
if v["abi_state"] == "prebuilt":
|
||||
if not v["in_sync"]:
|
||||
lines.append(
|
||||
" - **Action:** check whether upstream has shipped a new "
|
||||
"prebuilt release; this vendor ships binary-only artefacts."
|
||||
)
|
||||
elif v["upstream_abi"] and v["vendored_abi"] and v["upstream_abi"] > v["vendored_abi"]:
|
||||
if v["upstream_abi"] <= target_abi_range[1]:
|
||||
lines.append(
|
||||
f" - **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, "
|
||||
f"regenerate `parser.c` from upstream `{v['upstream_sha']}`."
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f" - **Action:** wait for a runtime supporting ABI "
|
||||
f"{v['upstream_abi']}; current target ({TARGET_RUNTIME}) only "
|
||||
f"goes up to ABI {target_abi_range[1]}."
|
||||
)
|
||||
blockers[f"vendored-{v['name']}-abi"] = (
|
||||
f"vendored {v['name']}: upstream ABI {v['upstream_abi']} outside target range"
|
||||
)
|
||||
elif not v["in_sync"]:
|
||||
lines.append(
|
||||
" - **Action:** review upstream changes; vendored copy may "
|
||||
"need a refresh (no ABI bump required)."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── Raw matrix (for completeness + workflow row-diff) ────────────
|
||||
lines.append(md_h("Full grammar matrix", 2))
|
||||
lines.append(
|
||||
"<details><summary>Click to expand the raw per-grammar table "
|
||||
"(used by the workflow's change-detection bot).</summary>\n"
|
||||
)
|
||||
lines.extend(raw_matrix)
|
||||
lines.append("\n</details>")
|
||||
lines.append("")
|
||||
|
||||
print("\n".join(lines))
|
||||
return 1 if blockers else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Force UTF-8 output: the report contains em-dashes and arrows that
|
||||
# Windows' default cp1252 codepage can't encode, while Linux runners
|
||||
# default to UTF-8 anyway.
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
# `--assert-current` is the offline CI gate (#1922): assert every grammar's
|
||||
# ABI loads on the CURRENT runtime. Bare invocation keeps the original
|
||||
# target-runtime readiness report behaviour.
|
||||
if "--assert-current" in sys.argv[1:]:
|
||||
sys.exit(assert_current())
|
||||
sys.exit(main())
|
||||
@@ -1,179 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce the GitHub Actions concurrency convention.
|
||||
|
||||
See CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention" for the rules.
|
||||
|
||||
Invoked from .github/workflows/ci-quality.yml. Runs locally too:
|
||||
python3 .github/scripts/check-workflow-concurrency.py .github/workflows
|
||||
|
||||
Rules:
|
||||
1. Every entry-point (non-reusable) workflow declares a top-level
|
||||
`concurrency:` block.
|
||||
2. Reusable workflows (on: workflow_call ONLY) do NOT declare one.
|
||||
3. The `concurrency.group` expression MUST reference either
|
||||
`${{ github.workflow }}` or one of the approved hardcoded literal prefixes
|
||||
for workflows that are simultaneously entry-points AND reusable (on: push/
|
||||
workflow_call). Two such exceptions are currently approved:
|
||||
- `CI-` for ci.yml (the original canonical form)
|
||||
- `docker-build-push-` for docker.yml
|
||||
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.
|
||||
|
||||
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
|
||||
flat (`on: workflow_call`) and mapping (`on:\n workflow_call:`) forms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-", "docker-build-push-")
|
||||
|
||||
|
||||
def is_reusable(lines: list[str]) -> bool:
|
||||
"""Return True iff the workflow's `on:` block names only `workflow_call`."""
|
||||
in_on = False
|
||||
on_indent: int | None = None
|
||||
keys: list[str] = []
|
||||
|
||||
for raw in lines:
|
||||
# Skip blank lines and comments
|
||||
stripped = raw.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
indent = len(raw) - len(raw.lstrip(" "))
|
||||
|
||||
if not in_on:
|
||||
if raw.startswith("on:"):
|
||||
remainder = raw[len("on:"):].strip()
|
||||
if not remainder:
|
||||
# `on:` followed by indented mapping on next lines
|
||||
in_on = True
|
||||
on_indent = indent
|
||||
continue
|
||||
if remainder.startswith("[") and remainder.endswith("]"):
|
||||
# Flow-style list: on: [workflow_call]
|
||||
items = [
|
||||
item.strip() for item in remainder.strip("[]").split(",")
|
||||
]
|
||||
return items == ["workflow_call"]
|
||||
# Scalar form: on: workflow_call (or a single other event)
|
||||
return remainder == "workflow_call"
|
||||
continue
|
||||
|
||||
# Inside the `on:` block; stop when indentation returns to <= on_indent
|
||||
if on_indent is not None and indent <= on_indent:
|
||||
break
|
||||
|
||||
# Only consider keys at on_indent + indentation step (anything deeper
|
||||
# is nested config like `types:`)
|
||||
if ":" not in stripped:
|
||||
continue
|
||||
# Heuristic: first-level event keys are those with indent == on_indent + 2
|
||||
# (the canonical step for a 2-space YAML doc). We collect all first-level
|
||||
# keys by tracking the smallest indent seen inside the block.
|
||||
keys.append((indent, stripped.split(":", 1)[0].strip()))
|
||||
|
||||
if not keys:
|
||||
return False
|
||||
|
||||
# Take only the outermost-indented keys as the event list
|
||||
min_indent = min(i for i, _ in keys)
|
||||
events = [name for i, name in keys if i == min_indent]
|
||||
return events == ["workflow_call"]
|
||||
|
||||
|
||||
CONCURRENCY_RE = re.compile(r"^concurrency:\s*$")
|
||||
GROUP_RE = re.compile(r"^\s+group:\s*(.+?)\s*$")
|
||||
|
||||
|
||||
def extract_group_key(lines: list[str]) -> str | None:
|
||||
"""Return the `group:` value of the top-level `concurrency:` block, or None."""
|
||||
for idx, raw in enumerate(lines):
|
||||
if CONCURRENCY_RE.match(raw):
|
||||
# Scan forward until we leave the concurrency block (next top-level key
|
||||
# is at column 0 and ends with `:`).
|
||||
for follow in lines[idx + 1:]:
|
||||
if follow and not follow.startswith(" ") and follow.rstrip().endswith(":"):
|
||||
break
|
||||
m = GROUP_RE.match(follow)
|
||||
if m:
|
||||
return m.group(1).strip().strip("'").strip('"')
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def has_top_level_concurrency(lines: list[str]) -> bool:
|
||||
return any(CONCURRENCY_RE.match(raw) for raw in lines)
|
||||
|
||||
|
||||
def check(workflows_dir: pathlib.Path) -> int:
|
||||
fail = 0
|
||||
files = sorted(
|
||||
list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml"))
|
||||
)
|
||||
for path in files:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
reusable = is_reusable(lines)
|
||||
has_conc = has_top_level_concurrency(lines)
|
||||
|
||||
if reusable:
|
||||
if has_conc:
|
||||
print(
|
||||
f"::error file={path}::Reusable workflow (on: workflow_call) "
|
||||
"must NOT declare its own concurrency block — it inherits "
|
||||
"from the caller. See CONTRIBUTING.md -> GitHub Actions — "
|
||||
"Concurrency Convention."
|
||||
)
|
||||
fail = 1
|
||||
continue
|
||||
|
||||
if not has_conc:
|
||||
print(
|
||||
f"::error file={path}::Missing top-level concurrency block. "
|
||||
"See CONTRIBUTING.md -> GitHub Actions — Concurrency Convention."
|
||||
)
|
||||
fail = 1
|
||||
continue
|
||||
|
||||
group = extract_group_key(lines)
|
||||
if group is None:
|
||||
print(
|
||||
f"::error file={path}::concurrency block is missing a "
|
||||
"`group:` key."
|
||||
)
|
||||
fail = 1
|
||||
continue
|
||||
|
||||
if 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 }}}} "
|
||||
"for normal entry-point workflows; use an approved literal prefix "
|
||||
"only for workflows that are both entry-points AND reusable — "
|
||||
"see CONTRIBUTING.md -> GitHub Actions — Concurrency Convention)."
|
||||
)
|
||||
fail = 1
|
||||
|
||||
return fail
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print(f"usage: {argv[0]} <workflows-dir>", file=sys.stderr)
|
||||
return 2
|
||||
workflows_dir = pathlib.Path(argv[1])
|
||||
if not workflows_dir.is_dir():
|
||||
print(f"not a directory: {workflows_dir}", file=sys.stderr)
|
||||
return 2
|
||||
return check(workflows_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -1,257 +0,0 @@
|
||||
"""Pure math utilities for triage sweep embedding analysis.
|
||||
|
||||
All functions are stateless and perform no I/O (except model loading by FastEmbed).
|
||||
Each function operates on numpy arrays and returns numpy arrays or plain Python types.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from fastembed import TextEmbedding
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.covariance import EllipticEnvelope
|
||||
from sklearn.metrics.pairwise import cosine_similarity
|
||||
|
||||
# FastEmbed model — BAAI/bge-small-en-v1.5 produces 384-dimensional embeddings.
|
||||
# ~46MB quantized ONNX, runs on CPU in ~0.5s per batch of 32.
|
||||
EMBEDDING_MODEL: str = "BAAI/bge-small-en-v1.5"
|
||||
|
||||
# Embedding dimensionality (determined by model choice).
|
||||
EMBEDDING_DIM: int = 384
|
||||
|
||||
# Batch size for FastEmbed. 32 balances memory and throughput on
|
||||
# a 2-vCPU GitHub Actions runner with ~7GB RAM.
|
||||
EMBEDDING_BATCH_SIZE: int = 32
|
||||
|
||||
|
||||
def embed_texts(texts: list[str]) -> NDArray[np.float32]:
|
||||
"""Embed a list of texts into dense vectors using FastEmbed.
|
||||
|
||||
Returns an array of shape (len(texts), 384) with dtype float32.
|
||||
Empty input returns a (0, 384) array.
|
||||
"""
|
||||
if not texts:
|
||||
return np.empty((0, EMBEDDING_DIM), dtype=np.float32)
|
||||
|
||||
model = TextEmbedding(model_name=EMBEDDING_MODEL)
|
||||
vectors = list(model.embed(texts, batch_size=EMBEDDING_BATCH_SIZE))
|
||||
return np.vstack(vectors).astype(np.float32)
|
||||
|
||||
|
||||
def normalize_rows(matrix: NDArray[np.float32]) -> NDArray[np.float32]:
|
||||
"""L2-normalize each row to unit length.
|
||||
|
||||
Zero-norm rows (e.g. from empty text) remain zero vectors.
|
||||
Uses eps=1e-10 in the denominator to avoid division by zero.
|
||||
"""
|
||||
if matrix.shape[0] == 0:
|
||||
return matrix
|
||||
|
||||
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
|
||||
return matrix / (norms + 1e-10)
|
||||
|
||||
|
||||
def reduce_dimensions(
|
||||
matrix: NDArray[np.float32],
|
||||
max_components: int,
|
||||
) -> NDArray[np.float32]:
|
||||
"""Reduce dimensionality via PCA.
|
||||
|
||||
Computes n_components = min(max_components, n-1, d). If n_components < 1,
|
||||
returns the matrix unchanged. Logs explained variance for observability.
|
||||
"""
|
||||
n, d = matrix.shape
|
||||
if n <= 1:
|
||||
return matrix
|
||||
|
||||
n_components = min(max_components, n - 1, d)
|
||||
if n_components < 1:
|
||||
return matrix
|
||||
|
||||
pca = PCA(n_components=n_components)
|
||||
reduced = pca.fit_transform(matrix)
|
||||
explained = pca.explained_variance_ratio_.sum()
|
||||
print(f"PCA: {d}d -> {n_components}d, explained variance: {explained:.3f}")
|
||||
return reduced.astype(np.float32)
|
||||
|
||||
|
||||
def detect_outliers(
|
||||
matrix: NDArray[np.float32],
|
||||
contamination: float = 0.1,
|
||||
iqr_multiplier: float = 3.0,
|
||||
max_outlier_pct: float = 0.05,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""Flag items whose Mahalanobis distance exceeds an IQR-based cutoff.
|
||||
|
||||
Uses EllipticEnvelope (robust covariance via MCD) to estimate the
|
||||
multivariate Gaussian, then computes sqrt(squared Mahalanobis distance)
|
||||
for each sample. The cutoff is Q75 + iqr_multiplier * IQR, which
|
||||
adapts to the actual distribution of distances.
|
||||
|
||||
A hard cap ensures no more than max_outlier_pct * n items are flagged;
|
||||
when the cap is hit, only the most extreme items (sorted by distance
|
||||
descending) are kept.
|
||||
|
||||
Returns (index, distance) tuples sorted by index ascending, along with
|
||||
the cutoff value stored as an attribute on the returned list.
|
||||
"""
|
||||
n = matrix.shape[0]
|
||||
if n < 2:
|
||||
return []
|
||||
|
||||
envelope = EllipticEnvelope(contamination=contamination, random_state=42)
|
||||
envelope.fit(matrix)
|
||||
|
||||
# .mahalanobis() returns squared Mahalanobis distances
|
||||
distances = np.sqrt(envelope.mahalanobis(matrix))
|
||||
|
||||
# IQR-based cutoff
|
||||
q25, q75 = np.percentile(distances, [25, 75])
|
||||
iqr = q75 - q25
|
||||
cutoff = q75 + iqr_multiplier * iqr
|
||||
|
||||
outlier_mask = distances > cutoff
|
||||
indices = np.where(outlier_mask)[0]
|
||||
|
||||
# Hard cap: keep at most max_outlier_pct * n items
|
||||
max_count = max(1, int(max_outlier_pct * n))
|
||||
if len(indices) > max_count:
|
||||
# Sort by distance descending, take the most extreme
|
||||
sorted_by_dist = sorted(indices, key=lambda i: distances[i], reverse=True)
|
||||
indices = np.array(sorted_by_dist[:max_count])
|
||||
|
||||
# Sort by index ascending for stable output
|
||||
indices = np.sort(indices)
|
||||
result = [(int(idx), float(distances[idx])) for idx in indices]
|
||||
|
||||
# Attach cutoff as metadata so the report can use it
|
||||
result = _OutlierResult(result) # type: ignore[assignment]
|
||||
result.cutoff = float(cutoff) # type: ignore[attr-defined]
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
|
||||
class _OutlierResult(list):
|
||||
"""A list subclass that carries metadata (cutoff) from outlier detection."""
|
||||
cutoff: float = 0.0
|
||||
|
||||
|
||||
def find_duplicate_pairs(
|
||||
matrix: NDArray[np.float32],
|
||||
threshold: float,
|
||||
) -> list[tuple[int, int, float]]:
|
||||
"""Find pairs of items with cosine similarity above threshold.
|
||||
|
||||
Returns (i, j, similarity) tuples where i < j. The input should be
|
||||
L2-normalized embeddings (full dimensionality, not PCA-reduced) so
|
||||
cosine similarity equals the dot product.
|
||||
"""
|
||||
n = matrix.shape[0]
|
||||
if n <= 1:
|
||||
return []
|
||||
|
||||
sim_matrix = cosine_similarity(matrix)
|
||||
# Upper triangle indices (i < j), excluding diagonal
|
||||
rows, cols = np.triu_indices(n, k=1)
|
||||
similarities = sim_matrix[rows, cols]
|
||||
|
||||
mask = similarities > threshold
|
||||
pairs: list[tuple[int, int, float]] = []
|
||||
for idx in np.where(mask)[0]:
|
||||
pairs.append((int(rows[idx]), int(cols[idx]), float(similarities[idx])))
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
# ── Label suggestion via z-score normalized embedding similarity ──────
|
||||
|
||||
# Z-score threshold: a label must be this many standard deviations above
|
||||
# the column mean to be considered a match.
|
||||
LABEL_Z_THRESHOLD: float = 1.5
|
||||
|
||||
# Margin gate: the top-1 label must beat the second-best by this many
|
||||
# z-score units to be accepted (subsequent labels don't need a margin).
|
||||
LABEL_Z_MARGIN: float = 0.5
|
||||
|
||||
# Floor for per-column standard deviation to avoid division by near-zero.
|
||||
LABEL_Z_STD_FLOOR: float = 0.01
|
||||
|
||||
# Minimum raw cosine similarity required even if z-score is high.
|
||||
# Prevents suggesting labels that are "relatively best" but still poor.
|
||||
MIN_RAW_SIMILARITY: float = 0.3
|
||||
|
||||
# Maximum number of labels to suggest per item.
|
||||
MAX_LABELS_PER_ITEM: int = 3
|
||||
|
||||
|
||||
def suggest_labels(
|
||||
item_embeddings: NDArray[np.float32],
|
||||
label_embeddings: NDArray[np.float32],
|
||||
label_names: list[str],
|
||||
z_threshold: float = LABEL_Z_THRESHOLD,
|
||||
z_margin: float = LABEL_Z_MARGIN,
|
||||
std_floor: float = LABEL_Z_STD_FLOOR,
|
||||
min_raw_sim: float = MIN_RAW_SIMILARITY,
|
||||
max_per_item: int = MAX_LABELS_PER_ITEM,
|
||||
) -> list[list[tuple[str, float]]]:
|
||||
"""Suggest labels for each item using z-score normalized similarity.
|
||||
|
||||
1. Compute raw cosine similarity matrix (n items x m labels).
|
||||
2. Column-wise z-score: for each label j, normalize across all items.
|
||||
3. For each item, rank labels by z-score descending.
|
||||
4. Accept a label only if z >= z_threshold AND raw_sim >= min_raw_sim.
|
||||
5. Margin gate: the top-1 label must beat #2 by z_margin; subsequent
|
||||
labels don't need a margin.
|
||||
6. Cap at max_per_item.
|
||||
|
||||
Returns a list of length n, where each element is a list of
|
||||
(label_name, raw_similarity) tuples. Empty list if nothing qualifies.
|
||||
"""
|
||||
n = item_embeddings.shape[0]
|
||||
m = label_embeddings.shape[0]
|
||||
if n == 0 or m == 0:
|
||||
return [[] for _ in range(n)]
|
||||
|
||||
# (n, m) raw similarity matrix
|
||||
sim_matrix = cosine_similarity(item_embeddings, label_embeddings)
|
||||
|
||||
# Column-wise z-score normalization
|
||||
col_means = sim_matrix.mean(axis=0) # shape (m,)
|
||||
col_stds = sim_matrix.std(axis=0) # shape (m,)
|
||||
col_stds = np.maximum(col_stds, std_floor)
|
||||
z_matrix = (sim_matrix - col_means) / col_stds
|
||||
|
||||
suggestions: list[list[tuple[str, float]]] = []
|
||||
for i in range(n):
|
||||
z_row = z_matrix[i]
|
||||
raw_row = sim_matrix[i]
|
||||
|
||||
# Rank labels by z-score descending
|
||||
ranked = np.argsort(z_row)[::-1]
|
||||
|
||||
item_labels: list[tuple[str, float]] = []
|
||||
|
||||
# Margin gate: top-1 z-score must beat #2 by z_margin.
|
||||
# If not, the assignment is ambiguous — skip this item entirely.
|
||||
if len(ranked) > 1:
|
||||
top1_z = float(z_row[ranked[0]])
|
||||
top2_z = float(z_row[ranked[1]])
|
||||
if top1_z - top2_z < z_margin:
|
||||
suggestions.append(item_labels)
|
||||
continue
|
||||
|
||||
for rank_pos, idx in enumerate(ranked):
|
||||
if len(item_labels) >= max_per_item:
|
||||
break
|
||||
|
||||
z_val = float(z_row[idx])
|
||||
raw_val = float(raw_row[idx])
|
||||
|
||||
# Must pass both z-threshold and raw similarity floor
|
||||
if z_val < z_threshold or raw_val < min_raw_sim:
|
||||
continue
|
||||
|
||||
item_labels.append((label_names[idx], raw_val))
|
||||
|
||||
suggestions.append(item_labels)
|
||||
|
||||
return suggestions
|
||||
@@ -1,4 +0,0 @@
|
||||
fastembed>=0.5.0
|
||||
numpy>=1.26.0
|
||||
scikit-learn>=1.4.0
|
||||
scipy>=1.10.0
|
||||
@@ -1,600 +0,0 @@
|
||||
"""Triage sweep: fetch open issues/PRs, detect outliers and duplicates, generate a report.
|
||||
|
||||
Entrypoint script for the triage-sweep workflow. Fetches all open items via
|
||||
the GitHub REST API, delegates embedding and analysis to embedding_utils,
|
||||
generates a markdown report, and optionally creates a report issue.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import TypedDict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from embedding_utils import (
|
||||
embed_texts,
|
||||
normalize_rows,
|
||||
reduce_dimensions,
|
||||
detect_outliers,
|
||||
find_duplicate_pairs,
|
||||
suggest_labels,
|
||||
LABEL_Z_THRESHOLD,
|
||||
LABEL_Z_MARGIN,
|
||||
LABEL_Z_STD_FLOOR,
|
||||
MIN_RAW_SIMILARITY,
|
||||
MAX_LABELS_PER_ITEM,
|
||||
)
|
||||
|
||||
# ── Thresholds (overridable via workflow_dispatch inputs) ──────────────
|
||||
|
||||
# IQR multiplier for outlier cutoff: cutoff = Q75 + IQR_MULTIPLIER * IQR.
|
||||
IQR_MULTIPLIER: float = float(os.environ.get("INPUT_IQR_MULTIPLIER", "3.0"))
|
||||
|
||||
# Hard cap: at most this fraction of items can be flagged as outliers.
|
||||
MAX_OUTLIER_PCT: float = float(os.environ.get("INPUT_MAX_OUTLIER_PCT", "0.05"))
|
||||
|
||||
# EllipticEnvelope contamination: expected fraction of outliers in the data.
|
||||
# Governs how aggressively the robust covariance downweights extreme points.
|
||||
CONTAMINATION: float = float(os.environ.get("INPUT_CONTAMINATION", "0.1"))
|
||||
|
||||
# Cosine similarity above which two items are flagged as duplicates.
|
||||
# 0.92 catches near-identical issues while tolerating paraphrasing.
|
||||
COSINE_THRESHOLD: float = float(os.environ.get("INPUT_COSINE_THRESHOLD", "0.92"))
|
||||
|
||||
# Hard cap on items to process. Prevents runaway costs on very large repos.
|
||||
MAX_ITEMS: int = int(os.environ.get("INPUT_MAX_ITEMS", "500"))
|
||||
|
||||
# When true, print report to stdout/file but do not create a GitHub issue.
|
||||
DRY_RUN: bool = os.environ.get("INPUT_DRY_RUN", "false").lower() == "true"
|
||||
|
||||
# ── Fixed constants (not user-configurable) ───────────────────────────
|
||||
|
||||
# Minimum number of samples required for EllipticEnvelope to fit
|
||||
# a Gaussian reliably. Must be >= 3 * PCA_MAX_COMPONENTS so the
|
||||
# covariance matrix is estimated from enough data points.
|
||||
PCA_MAX_COMPONENTS: int = 20
|
||||
MIN_SAMPLES_FOR_OUTLIER_DETECTION: int = 100
|
||||
|
||||
# Max character length for embedding input text. bge-small-en-v1.5 has a
|
||||
# 512-token context window (~4 chars/token). We keep title + body under
|
||||
# this limit so the model sees the full text instead of silently truncating.
|
||||
MAX_EMBED_CHARS: int = 2000
|
||||
|
||||
# GitHub REST API page size (max allowed is 100).
|
||||
API_PAGE_SIZE: int = 100
|
||||
|
||||
# Report issue label.
|
||||
REPORT_LABEL: str = "triage-report"
|
||||
|
||||
# Report file path (written for the summary step to pick up).
|
||||
REPORT_FILE: str = "/tmp/triage-report.md"
|
||||
|
||||
|
||||
class TriageItem(TypedDict):
|
||||
"""One open issue or PR, with only the fields we need."""
|
||||
number: int
|
||||
title: str
|
||||
html_url: str
|
||||
is_pr: bool
|
||||
labels: list[str]
|
||||
created_at: str
|
||||
# title + body concatenated, used as embedding input
|
||||
text: str
|
||||
|
||||
|
||||
def github_api_get(path: str) -> list[dict]:
|
||||
"""Make a single authenticated GET request to the GitHub REST API.
|
||||
|
||||
Reads GITHUB_TOKEN and GITHUB_REPOSITORY from env. Raises SystemExit
|
||||
with the HTTP status and response body on any non-2xx response.
|
||||
"""
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repo = os.environ["GITHUB_REPOSITORY"]
|
||||
url = f"https://api.github.com/repos/{repo}{path}"
|
||||
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Accept", "application/vnd.github+json")
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("X-GitHub-Api-Version", "2022-11-28")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
print(f"::error::GitHub API {e.code}: {body}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def fetch_all_open_items() -> list[TriageItem]:
|
||||
"""Paginate through all open issues and PRs.
|
||||
|
||||
Returns up to MAX_ITEMS TriageItem dicts. Items with a pull_request
|
||||
key are marked is_pr=True. The text field is title + body concatenated.
|
||||
"""
|
||||
items: list[TriageItem] = []
|
||||
page = 1
|
||||
|
||||
while len(items) < MAX_ITEMS:
|
||||
path = (
|
||||
f"/issues?state=open&per_page={API_PAGE_SIZE}"
|
||||
f"&sort=created&direction=desc&page={page}"
|
||||
)
|
||||
data = github_api_get(path)
|
||||
|
||||
if not data:
|
||||
break
|
||||
|
||||
for raw in data:
|
||||
if len(items) >= MAX_ITEMS:
|
||||
break
|
||||
|
||||
body = raw.get("body", "") or ""
|
||||
full_text = f"{raw['title']}\n\n{body}"
|
||||
# Truncate to fit the embedding model's token window.
|
||||
# Title is always preserved; body gets clipped if needed.
|
||||
if len(full_text) > MAX_EMBED_CHARS:
|
||||
full_text = full_text[:MAX_EMBED_CHARS]
|
||||
items.append(TriageItem(
|
||||
number=raw["number"],
|
||||
title=raw["title"],
|
||||
html_url=raw["html_url"],
|
||||
is_pr="pull_request" in raw,
|
||||
labels=[lbl["name"] for lbl in raw.get("labels", [])],
|
||||
created_at=raw["created_at"],
|
||||
text=full_text,
|
||||
))
|
||||
|
||||
if len(data) < API_PAGE_SIZE:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
return items
|
||||
|
||||
|
||||
class RepoLabel(TypedDict):
|
||||
"""A label from the repo with its embedding text."""
|
||||
name: str
|
||||
description: str
|
||||
# "name: description" concatenated for embedding
|
||||
text: str
|
||||
|
||||
|
||||
def fetch_repo_labels() -> list[RepoLabel]:
|
||||
"""Fetch all labels from the repository, paginating if needed.
|
||||
|
||||
Returns labels with name, description, and a text field suitable
|
||||
for embedding ("name: description"). Labels with no description
|
||||
use just the name.
|
||||
"""
|
||||
labels: list[RepoLabel] = []
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
data = github_api_get(f"/labels?per_page={API_PAGE_SIZE}&page={page}")
|
||||
for raw in data:
|
||||
name = raw["name"]
|
||||
desc = raw.get("description", "") or ""
|
||||
text = f"{name}: {desc}" if desc else name
|
||||
labels.append(RepoLabel(name=name, description=desc, text=text))
|
||||
|
||||
if len(data) < API_PAGE_SIZE:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def apply_labels_to_item(item_number: int, labels: list[str]) -> None:
|
||||
"""Add labels to a single issue/PR via the GitHub API.
|
||||
|
||||
Skips silently if labels list is empty. Uses POST which adds labels
|
||||
without removing existing ones.
|
||||
"""
|
||||
if not labels:
|
||||
return
|
||||
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repo = os.environ["GITHUB_REPOSITORY"]
|
||||
url = f"https://api.github.com/repos/{repo}/issues/{item_number}/labels"
|
||||
|
||||
payload = json.dumps({"labels": labels}).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=payload, method="POST")
|
||||
req.add_header("Accept", "application/vnd.github+json")
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
# Non-fatal: log warning but don't abort the sweep
|
||||
print(f"::warning::Failed to label #{item_number}: {e.code} {body}")
|
||||
|
||||
|
||||
def _item_age(created_at: str) -> str:
|
||||
"""Compute a human-readable age string from an ISO 8601 created_at timestamp."""
|
||||
try:
|
||||
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||
delta = datetime.now(timezone.utc) - created
|
||||
days = delta.days
|
||||
if days < 1:
|
||||
return "<1d"
|
||||
if days < 30:
|
||||
return f"{days}d"
|
||||
if days < 365:
|
||||
return f"{days // 30}mo"
|
||||
return f"{days // 365}y"
|
||||
except (ValueError, TypeError):
|
||||
return "?"
|
||||
|
||||
|
||||
def _suggested_action(a: TriageItem, b: TriageItem) -> str:
|
||||
"""Determine a suggested action for a duplicate pair based on types and age."""
|
||||
if a["is_pr"] and b["is_pr"]:
|
||||
return "Review for overlap"
|
||||
if not a["is_pr"] and not b["is_pr"]:
|
||||
# Both issues — close the newer one
|
||||
try:
|
||||
a_dt = datetime.fromisoformat(a["created_at"].replace("Z", "+00:00"))
|
||||
b_dt = datetime.fromisoformat(b["created_at"].replace("Z", "+00:00"))
|
||||
newer = b if b_dt > a_dt else a
|
||||
except (ValueError, TypeError):
|
||||
newer = b
|
||||
return f"Close #{newer['number']} as duplicate"
|
||||
# One issue, one PR
|
||||
return "Link PR to issue"
|
||||
|
||||
|
||||
def generate_report(
|
||||
items: list[TriageItem],
|
||||
outlier_results: list[tuple[int, float]],
|
||||
duplicate_pairs: list[tuple[int, int, float]],
|
||||
label_suggestions: list[list[tuple[str, float]]] | None = None,
|
||||
) -> str:
|
||||
"""Generate a structured markdown triage report."""
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "unknown/repo")
|
||||
|
||||
# Compute label suggestion counts early for the health table
|
||||
outlier_set = {idx for idx, _ in outlier_results}
|
||||
suggested_count = 0
|
||||
if label_suggestions is not None:
|
||||
suggested_count = sum(
|
||||
1 for i, s in enumerate(label_suggestions)
|
||||
if s and not items[i]["labels"] and i not in outlier_set
|
||||
)
|
||||
|
||||
# ── Health summary table at the top ──────────────────────────────
|
||||
lines: list[str] = [
|
||||
"## Triage Sweep Report",
|
||||
"",
|
||||
f"**Run:** {now} UTC",
|
||||
f"**Items analyzed:** {len(items)}",
|
||||
f"**Thresholds:** IQR multiplier {IQR_MULTIPLIER}, Cosine > {COSINE_THRESHOLD}",
|
||||
"",
|
||||
"### Health Summary",
|
||||
"",
|
||||
"| Metric | Value |",
|
||||
"|--------|-------|",
|
||||
f"| Items analyzed | {len(items)} |",
|
||||
f"| Outliers flagged | {len(outlier_results)} |",
|
||||
f"| Duplicate pairs | {len(duplicate_pairs)} |",
|
||||
f"| Label suggestions | {suggested_count} |",
|
||||
"",
|
||||
]
|
||||
|
||||
# ── Outlier section ──────────────────────────────────────────────
|
||||
# Determine cutoff for high-confidence split
|
||||
cutoff = getattr(outlier_results, "cutoff", 0.0)
|
||||
high_conf_cutoff = 2 * cutoff if cutoff > 0 else float("inf")
|
||||
|
||||
high_conf = [(idx, d) for idx, d in outlier_results if d > high_conf_cutoff]
|
||||
borderline = [(idx, d) for idx, d in outlier_results if d <= high_conf_cutoff]
|
||||
|
||||
lines.extend([
|
||||
f"### Potential Outliers / Spam ({len(outlier_results)})",
|
||||
"",
|
||||
"Items with unusually high Mahalanobis distance from the distribution center.",
|
||||
"These may be spam, off-topic, or poorly described.",
|
||||
"",
|
||||
])
|
||||
|
||||
if high_conf:
|
||||
lines.append(f"**High Confidence** ({len(high_conf)} items, distance > 2x cutoff)")
|
||||
lines.append("")
|
||||
lines.append("| # | Type | Title | Distance | Age |")
|
||||
lines.append("|---|------|-------|----------|-----|")
|
||||
for idx, distance in high_conf:
|
||||
item = items[idx]
|
||||
kind = "PR" if item["is_pr"] else "Issue"
|
||||
age = _item_age(item["created_at"])
|
||||
title = item["title"][:80] + ("..." if len(item["title"]) > 80 else "")
|
||||
lines.append(
|
||||
f"| [#{item['number']}]({item['html_url']}) "
|
||||
f"| {kind} | {title} | {distance:.2f} | {age} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if borderline:
|
||||
lines.append("<details>")
|
||||
lines.append(f"<summary>Borderline ({len(borderline)} items)</summary>")
|
||||
lines.append("")
|
||||
lines.append("| # | Type | Title | Distance | Age |")
|
||||
lines.append("|---|------|-------|----------|-----|")
|
||||
for idx, distance in borderline:
|
||||
item = items[idx]
|
||||
kind = "PR" if item["is_pr"] else "Issue"
|
||||
age = _item_age(item["created_at"])
|
||||
title = item["title"][:80] + ("..." if len(item["title"]) > 80 else "")
|
||||
lines.append(
|
||||
f"| [#{item['number']}]({item['html_url']}) "
|
||||
f"| {kind} | {title} | {distance:.2f} | {age} |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("</details>")
|
||||
lines.append("")
|
||||
|
||||
if not outlier_results:
|
||||
lines.append("None found.")
|
||||
|
||||
# ── Duplicate pairs section ──────────────────────────────────────
|
||||
lines.extend([
|
||||
"",
|
||||
f"### Potential Duplicates ({len(duplicate_pairs)} pairs)",
|
||||
"",
|
||||
"Pairs of items with cosine similarity above the threshold.",
|
||||
"",
|
||||
])
|
||||
|
||||
if duplicate_pairs:
|
||||
lines.append("| Item A | Item B | Similarity | Suggested Action |")
|
||||
lines.append("|--------|--------|------------|------------------|")
|
||||
for i, j, sim in duplicate_pairs:
|
||||
a = items[i]
|
||||
b = items[j]
|
||||
kind_a = "PR" if a["is_pr"] else "Issue"
|
||||
kind_b = "PR" if b["is_pr"] else "Issue"
|
||||
action = _suggested_action(a, b)
|
||||
lines.append(
|
||||
f"| [#{a['number']}]({a['html_url']}) {kind_a}: {a['title']} "
|
||||
f"| [#{b['number']}]({b['html_url']}) {kind_b}: {b['title']} "
|
||||
f"| {sim:.3f} | {action} |"
|
||||
)
|
||||
else:
|
||||
lines.append("None found.")
|
||||
|
||||
# ── Label suggestions section ────────────────────────────────────
|
||||
if label_suggestions is not None:
|
||||
# High confidence: top-1 label with raw_sim >= 0.5
|
||||
# Low confidence: top-1 label with raw_sim < 0.5
|
||||
high_conf_labels: list[tuple[int, list[tuple[str, float]]]] = []
|
||||
low_conf_labels: list[tuple[int, list[tuple[str, float]]]] = []
|
||||
for i, sugs in enumerate(label_suggestions):
|
||||
if sugs and not items[i]["labels"] and i not in outlier_set:
|
||||
top1 = sugs[:1]
|
||||
if top1[0][1] >= 0.5:
|
||||
high_conf_labels.append((i, top1))
|
||||
else:
|
||||
low_conf_labels.append((i, top1))
|
||||
|
||||
total_suggestions = len(high_conf_labels) + len(low_conf_labels)
|
||||
lines.extend([
|
||||
"",
|
||||
f"### Suggested Labels ({total_suggestions} unlabeled items)",
|
||||
"",
|
||||
"Labels suggested by z-score normalized embedding similarity against repo label descriptions.",
|
||||
"Only shown for unlabeled items that were not flagged as outliers.",
|
||||
"",
|
||||
])
|
||||
|
||||
# Label concentration warning
|
||||
if total_suggestions > 0:
|
||||
label_counts: dict[str, int] = {}
|
||||
for _, sugs in high_conf_labels + low_conf_labels:
|
||||
for name, _ in sugs:
|
||||
label_counts[name] = label_counts.get(name, 0) + 1
|
||||
for name, count in label_counts.items():
|
||||
if count > total_suggestions * 0.5:
|
||||
lines.append(
|
||||
f"> **Warning:** Label `{name}` accounts for "
|
||||
f"{count}/{total_suggestions} suggestions "
|
||||
f"({count * 100 // total_suggestions}%). "
|
||||
f"Consider reviewing label descriptions for specificity."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if high_conf_labels:
|
||||
lines.append("| # | Type | Title | Suggested Label |")
|
||||
lines.append("|---|------|-------|--------------------|")
|
||||
for idx, sugs in high_conf_labels:
|
||||
item = items[idx]
|
||||
kind = "PR" if item["is_pr"] else "Issue"
|
||||
label_strs = [f"`{name}` ({score:.2f})" for name, score in sugs]
|
||||
lines.append(
|
||||
f"| [#{item['number']}]({item['html_url']}) "
|
||||
f"| {kind} | {item['title']} | {', '.join(label_strs)} |"
|
||||
)
|
||||
|
||||
if low_conf_labels:
|
||||
lines.append("")
|
||||
lines.append("<details>")
|
||||
lines.append(f"<summary>Low-confidence suggestions ({len(low_conf_labels)} items)</summary>")
|
||||
lines.append("")
|
||||
lines.append("| # | Type | Title | Suggested Label |")
|
||||
lines.append("|---|------|-------|--------------------|")
|
||||
for idx, sugs in low_conf_labels:
|
||||
item = items[idx]
|
||||
kind = "PR" if item["is_pr"] else "Issue"
|
||||
label_strs = [f"`{name}` ({score:.2f})" for name, score in sugs]
|
||||
lines.append(
|
||||
f"| [#{item['number']}]({item['html_url']}) "
|
||||
f"| {kind} | {item['title']} | {', '.join(label_strs)} |"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("</details>")
|
||||
|
||||
if not high_conf_labels and not low_conf_labels:
|
||||
lines.append("No unlabeled items need suggestions.")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"### Summary",
|
||||
"",
|
||||
f"- {len(outlier_results)} outliers flagged for review",
|
||||
f"- {len(duplicate_pairs)} duplicate pairs found",
|
||||
f"- {len(items)} items analyzed in total",
|
||||
])
|
||||
|
||||
if label_suggestions is not None:
|
||||
lines.append(f"- {suggested_count} items suggested for labeling")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"---",
|
||||
f"*Generated by [triage-sweep](https://github.com/{repo}/actions) — no LLM was used.*",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_report_issue(report_body: str) -> None:
|
||||
"""Create a GitHub issue with the triage report.
|
||||
|
||||
Posts to the issues API with the triage-report label.
|
||||
Raises SystemExit on non-201 response.
|
||||
"""
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repo = os.environ["GITHUB_REPOSITORY"]
|
||||
url = f"https://api.github.com/repos/{repo}/issues"
|
||||
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
payload = json.dumps({
|
||||
"title": f"Triage Sweep Report — {today}",
|
||||
"body": report_body,
|
||||
"labels": [REPORT_LABEL],
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=payload, method="POST")
|
||||
req.add_header("Accept", "application/vnd.github+json")
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
resp_body = resp.read().decode("utf-8")
|
||||
if resp.status != 201:
|
||||
print(f"::error::Failed to create issue: {resp.status} {resp_body}")
|
||||
sys.exit(1)
|
||||
result = json.loads(resp_body)
|
||||
print(f"Created issue: {result.get('html_url', 'unknown')}")
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
print(f"::error::Failed to create issue: {e.code} {body}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def write_report(report: str) -> None:
|
||||
"""Write the report to the file system for the summary step."""
|
||||
with open(REPORT_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(report)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Orchestrate the full triage sweep."""
|
||||
# 1. Validate environment
|
||||
for var in ("GITHUB_TOKEN", "GITHUB_REPOSITORY"):
|
||||
if not os.environ.get(var):
|
||||
print(f"::error::Missing required environment variable: {var}")
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Fetch all open issues + PRs
|
||||
items = fetch_all_open_items()
|
||||
print(f"Fetched {len(items)} open items")
|
||||
|
||||
if len(items) == 0:
|
||||
report = "## Triage Sweep Report\n\nNo open issues or PRs found."
|
||||
write_report(report)
|
||||
print("No items to analyze.")
|
||||
return
|
||||
|
||||
# 3. Extract texts for embedding
|
||||
texts: list[str] = [item["text"] for item in items]
|
||||
|
||||
# 4. Embed all texts (returns numpy float32 array of shape [n, 384])
|
||||
embeddings = embed_texts(texts)
|
||||
|
||||
# 5. L2-normalize
|
||||
embeddings = normalize_rows(embeddings)
|
||||
|
||||
# 6. Outlier detection (Mahalanobis via EllipticEnvelope)
|
||||
outlier_results: list[tuple[int, float]] = []
|
||||
if len(items) >= MIN_SAMPLES_FOR_OUTLIER_DETECTION:
|
||||
reduced = reduce_dimensions(embeddings, PCA_MAX_COMPONENTS)
|
||||
outlier_results = detect_outliers(
|
||||
reduced,
|
||||
contamination=CONTAMINATION,
|
||||
iqr_multiplier=IQR_MULTIPLIER,
|
||||
max_outlier_pct=MAX_OUTLIER_PCT,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Skipping outlier detection: {len(items)} items < "
|
||||
f"{MIN_SAMPLES_FOR_OUTLIER_DETECTION} minimum"
|
||||
)
|
||||
|
||||
# 7. Duplicate detection (pairwise cosine similarity)
|
||||
duplicate_pairs = find_duplicate_pairs(embeddings, COSINE_THRESHOLD)
|
||||
|
||||
# 8. Label suggestion via embedding similarity
|
||||
label_suggestions: list[list[tuple[str, float]]] | None = None
|
||||
repo_labels = fetch_repo_labels()
|
||||
if repo_labels:
|
||||
label_texts = [lbl["text"] for lbl in repo_labels]
|
||||
label_names = [lbl["name"] for lbl in repo_labels]
|
||||
label_embeddings = embed_texts(label_texts)
|
||||
label_embeddings = normalize_rows(label_embeddings)
|
||||
label_suggestions = suggest_labels(embeddings, label_embeddings, label_names)
|
||||
print(f"Computed label suggestions against {len(repo_labels)} repo labels")
|
||||
|
||||
# NOTE: Auto-labeling is disabled. The report shows suggestions for
|
||||
# human review. To re-enable, uncomment the block below.
|
||||
#
|
||||
# # Apply top label to unlabeled items (unless dry run)
|
||||
# # Skip outliers — flagged items shouldn't get categorized
|
||||
# outlier_set = {idx for idx, _ in outlier_results}
|
||||
# if not DRY_RUN:
|
||||
# applied_count = 0
|
||||
# for i, sugs in enumerate(label_suggestions):
|
||||
# if sugs and not items[i]["labels"] and i not in outlier_set:
|
||||
# # Apply only the top-1 label (highest confidence)
|
||||
# apply_labels_to_item(items[i]["number"], [sugs[0][0]])
|
||||
# applied_count += 1
|
||||
# print(f"Applied labels to {applied_count} unlabeled items")
|
||||
else:
|
||||
print("No repo labels found — skipping label suggestions")
|
||||
|
||||
# 9. Generate report
|
||||
report = generate_report(items, outlier_results, duplicate_pairs, label_suggestions)
|
||||
|
||||
# 10. Write report to file (for summary step)
|
||||
write_report(report)
|
||||
|
||||
# 11. Create report issue (unless dry run)
|
||||
if DRY_RUN:
|
||||
print("Dry run — skipping issue creation and label application.")
|
||||
print(report)
|
||||
else:
|
||||
create_report_issue(report)
|
||||
print("Report issue created.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,468 +0,0 @@
|
||||
"""Tests for embedding_utils.py — all embedding model calls are mocked."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Mock fastembed before importing the module under test (persistent)
|
||||
if "fastembed" not in sys.modules:
|
||||
sys.modules["fastembed"] = MagicMock()
|
||||
|
||||
from embedding_utils import (
|
||||
embed_texts,
|
||||
normalize_rows,
|
||||
reduce_dimensions,
|
||||
detect_outliers,
|
||||
find_duplicate_pairs,
|
||||
suggest_labels,
|
||||
EMBEDDING_DIM,
|
||||
EMBEDDING_MODEL,
|
||||
EMBEDDING_BATCH_SIZE,
|
||||
LABEL_Z_THRESHOLD,
|
||||
LABEL_Z_MARGIN,
|
||||
LABEL_Z_STD_FLOOR,
|
||||
MIN_RAW_SIMILARITY,
|
||||
MAX_LABELS_PER_ITEM,
|
||||
)
|
||||
|
||||
|
||||
class TestEmbedTexts:
|
||||
"""Tests for the embed_texts function."""
|
||||
|
||||
def test_empty_list_returns_empty_array(self):
|
||||
result = embed_texts([])
|
||||
assert result.shape == (0, EMBEDDING_DIM)
|
||||
assert result.dtype == np.float32
|
||||
|
||||
@patch("embedding_utils.TextEmbedding")
|
||||
def test_single_text(self, mock_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_cls.return_value = mock_model
|
||||
vec = np.random.randn(EMBEDDING_DIM).astype(np.float32)
|
||||
mock_model.embed.return_value = iter([vec])
|
||||
|
||||
result = embed_texts(["hello world"])
|
||||
|
||||
mock_cls.assert_called_once_with(model_name=EMBEDDING_MODEL)
|
||||
mock_model.embed.assert_called_once_with(
|
||||
["hello world"], batch_size=EMBEDDING_BATCH_SIZE
|
||||
)
|
||||
assert result.shape == (1, EMBEDDING_DIM)
|
||||
assert result.dtype == np.float32
|
||||
np.testing.assert_array_almost_equal(result[0], vec)
|
||||
|
||||
@patch("embedding_utils.TextEmbedding")
|
||||
def test_multiple_texts(self, mock_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_cls.return_value = mock_model
|
||||
vecs = [
|
||||
np.random.randn(EMBEDDING_DIM).astype(np.float32)
|
||||
for _ in range(5)
|
||||
]
|
||||
mock_model.embed.return_value = iter(vecs)
|
||||
|
||||
result = embed_texts(["a", "b", "c", "d", "e"])
|
||||
assert result.shape == (5, EMBEDDING_DIM)
|
||||
assert result.dtype == np.float32
|
||||
|
||||
|
||||
class TestNormalizeRows:
|
||||
"""Tests for L2 row normalization."""
|
||||
|
||||
def test_empty_matrix(self):
|
||||
m = np.empty((0, 10), dtype=np.float32)
|
||||
result = normalize_rows(m)
|
||||
assert result.shape == (0, 10)
|
||||
|
||||
def test_single_row(self):
|
||||
m = np.array([[3.0, 4.0]], dtype=np.float32)
|
||||
result = normalize_rows(m)
|
||||
# Norm should be ~1.0
|
||||
norm = np.linalg.norm(result[0])
|
||||
assert abs(norm - 1.0) < 1e-5
|
||||
|
||||
def test_multiple_rows(self):
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((10, 50)).astype(np.float32)
|
||||
result = normalize_rows(m)
|
||||
norms = np.linalg.norm(result, axis=1)
|
||||
np.testing.assert_allclose(norms, 1.0, atol=1e-5)
|
||||
|
||||
def test_zero_row_stays_near_zero(self):
|
||||
m = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], dtype=np.float32)
|
||||
result = normalize_rows(m)
|
||||
# Zero row divided by eps -> very small values
|
||||
assert np.linalg.norm(result[0]) < 1e-3
|
||||
# Non-zero row should be unit norm
|
||||
assert abs(np.linalg.norm(result[1]) - 1.0) < 1e-5
|
||||
|
||||
def test_preserves_direction(self):
|
||||
m = np.array([[2.0, 0.0], [0.0, 3.0]], dtype=np.float32)
|
||||
result = normalize_rows(m)
|
||||
np.testing.assert_allclose(result[0], [1.0, 0.0], atol=1e-5)
|
||||
np.testing.assert_allclose(result[1], [0.0, 1.0], atol=1e-5)
|
||||
|
||||
|
||||
class TestReduceDimensions:
|
||||
"""Tests for PCA dimensionality reduction."""
|
||||
|
||||
def test_single_sample_returns_unchanged(self):
|
||||
m = np.random.randn(1, 50).astype(np.float32)
|
||||
result = reduce_dimensions(m, 10)
|
||||
np.testing.assert_array_equal(result, m)
|
||||
|
||||
def test_reduces_dimensions(self):
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((100, 50)).astype(np.float32)
|
||||
result = reduce_dimensions(m, 10)
|
||||
assert result.shape == (100, 10)
|
||||
assert result.dtype == np.float32
|
||||
|
||||
def test_caps_at_n_minus_1(self):
|
||||
rng = np.random.default_rng(42)
|
||||
# 5 samples, 20 features -> max components = 4 (n-1)
|
||||
m = rng.standard_normal((5, 20)).astype(np.float32)
|
||||
result = reduce_dimensions(m, 50)
|
||||
assert result.shape == (5, 4)
|
||||
|
||||
def test_caps_at_d(self):
|
||||
rng = np.random.default_rng(42)
|
||||
# 100 samples, 3 features -> max components = 3
|
||||
m = rng.standard_normal((100, 3)).astype(np.float32)
|
||||
result = reduce_dimensions(m, 50)
|
||||
assert result.shape == (100, 3)
|
||||
|
||||
def test_max_components_respected(self):
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((50, 30)).astype(np.float32)
|
||||
result = reduce_dimensions(m, 5)
|
||||
assert result.shape[1] == 5
|
||||
|
||||
|
||||
class TestDetectOutliers:
|
||||
"""Tests for IQR-based outlier detection."""
|
||||
|
||||
def test_single_sample_returns_empty(self):
|
||||
m = np.random.randn(1, 5).astype(np.float32)
|
||||
result = detect_outliers(m)
|
||||
assert result == []
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
# n < 2 case
|
||||
m = np.empty((0, 5), dtype=np.float32)
|
||||
result = detect_outliers(m)
|
||||
assert result == []
|
||||
|
||||
def test_finds_outliers_in_synthetic_data(self):
|
||||
rng = np.random.default_rng(42)
|
||||
# Create a tight cluster with one obvious outlier
|
||||
cluster = rng.standard_normal((50, 3)).astype(np.float32) * 0.1
|
||||
outlier = np.array([[100.0, 100.0, 100.0]], dtype=np.float32)
|
||||
m = np.vstack([cluster, outlier])
|
||||
result = detect_outliers(m)
|
||||
# The outlier (index 50) should be detected
|
||||
outlier_indices = [idx for idx, _ in result]
|
||||
assert 50 in outlier_indices
|
||||
|
||||
def test_returns_list_of_index_distance_tuples(self):
|
||||
rng = np.random.default_rng(42)
|
||||
# Tight cluster + outlier to guarantee at least one result
|
||||
cluster = rng.standard_normal((20, 3)).astype(np.float32) * 0.1
|
||||
far_point = np.array([[50.0, 50.0, 50.0]], dtype=np.float32)
|
||||
m = np.vstack([cluster, far_point])
|
||||
result = detect_outliers(m)
|
||||
assert isinstance(result, list)
|
||||
for item in result:
|
||||
assert isinstance(item, tuple)
|
||||
assert len(item) == 2
|
||||
idx, dist = item
|
||||
assert isinstance(idx, int)
|
||||
assert isinstance(dist, float)
|
||||
assert dist > 0
|
||||
|
||||
def test_iqr_cutoff_behavior(self):
|
||||
"""Lower IQR multiplier should flag more items than higher multiplier."""
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((100, 3)).astype(np.float32)
|
||||
low = detect_outliers(m, iqr_multiplier=1.0, max_outlier_pct=0.5)
|
||||
high = detect_outliers(m, iqr_multiplier=5.0, max_outlier_pct=0.5)
|
||||
assert len(low) >= len(high)
|
||||
|
||||
def test_dimension_aware_no_mass_flagging(self):
|
||||
"""High-dimensional clean Gaussian data should not flag everything."""
|
||||
rng = np.random.default_rng(42)
|
||||
# 500 samples, 10 dims — well-conditioned for robust covariance
|
||||
m = rng.standard_normal((500, 10)).astype(np.float32)
|
||||
result = detect_outliers(m)
|
||||
# With IQR-based cutoff on clean Gaussian data,
|
||||
# only a small fraction should be flagged (well under 50%)
|
||||
assert len(result) < 250
|
||||
|
||||
def test_contamination_parameter(self):
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((50, 3)).astype(np.float32)
|
||||
# Should not raise with different contamination values
|
||||
result = detect_outliers(m, contamination=0.05)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_max_outlier_pct_hard_cap(self):
|
||||
"""The hard cap should limit outlier count to max_outlier_pct * n."""
|
||||
rng = np.random.default_rng(42)
|
||||
# Create data with many potential outliers (bimodal)
|
||||
cluster = rng.standard_normal((80, 3)).astype(np.float32) * 0.1
|
||||
outliers = rng.standard_normal((20, 3)).astype(np.float32) * 50.0
|
||||
m = np.vstack([cluster, outliers])
|
||||
# Very low IQR multiplier to flag a lot, but cap at 5%
|
||||
result = detect_outliers(m, iqr_multiplier=0.5, max_outlier_pct=0.05)
|
||||
max_allowed = max(1, int(0.05 * 100)) # 5
|
||||
assert len(result) <= max_allowed
|
||||
|
||||
def test_hard_cap_keeps_most_extreme(self):
|
||||
"""When capped, the most extreme items (highest distance) should be kept."""
|
||||
rng = np.random.default_rng(42)
|
||||
cluster = rng.standard_normal((90, 3)).astype(np.float32) * 0.1
|
||||
# Create outliers with increasing extremity
|
||||
outliers = np.array([
|
||||
[10.0, 10.0, 10.0],
|
||||
[20.0, 20.0, 20.0],
|
||||
[50.0, 50.0, 50.0],
|
||||
], dtype=np.float32)
|
||||
m = np.vstack([cluster, outliers])
|
||||
# Cap at ~1 item (0.01 * 93 = 0, but min is 1)
|
||||
result = detect_outliers(m, iqr_multiplier=0.5, max_outlier_pct=0.02)
|
||||
if len(result) > 0:
|
||||
# The most extreme (index 92, distance for [50,50,50]) should be kept
|
||||
indices = [idx for idx, _ in result]
|
||||
assert 92 in indices
|
||||
|
||||
def test_cutoff_attribute(self):
|
||||
"""Returned result should carry a cutoff attribute."""
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((50, 3)).astype(np.float32)
|
||||
result = detect_outliers(m)
|
||||
assert hasattr(result, "cutoff")
|
||||
assert isinstance(result.cutoff, float)
|
||||
assert result.cutoff > 0
|
||||
|
||||
|
||||
class TestFindDuplicatePairs:
|
||||
"""Tests for cosine similarity duplicate detection."""
|
||||
|
||||
def test_single_item_returns_empty(self):
|
||||
m = np.random.randn(1, 10).astype(np.float32)
|
||||
result = find_duplicate_pairs(m, 0.9)
|
||||
assert result == []
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
m = np.empty((0, 10), dtype=np.float32)
|
||||
result = find_duplicate_pairs(m, 0.9)
|
||||
assert result == []
|
||||
|
||||
def test_identical_vectors_detected(self):
|
||||
vec = np.random.randn(10).astype(np.float32)
|
||||
vec = vec / np.linalg.norm(vec)
|
||||
m = np.vstack([vec, vec, np.random.randn(10).astype(np.float32)])
|
||||
result = find_duplicate_pairs(m, 0.99)
|
||||
# Items 0 and 1 are identical, should be found
|
||||
assert any(i == 0 and j == 1 for i, j, _ in result)
|
||||
|
||||
def test_orthogonal_vectors_not_detected(self):
|
||||
m = np.eye(5, dtype=np.float32)
|
||||
result = find_duplicate_pairs(m, 0.5)
|
||||
assert result == []
|
||||
|
||||
def test_returns_correct_format(self):
|
||||
vec = np.random.randn(10).astype(np.float32)
|
||||
vec = vec / np.linalg.norm(vec)
|
||||
m = np.vstack([vec, vec])
|
||||
result = find_duplicate_pairs(m, 0.5)
|
||||
assert len(result) >= 1
|
||||
for item in result:
|
||||
assert len(item) == 3
|
||||
i, j, sim = item
|
||||
assert isinstance(i, int)
|
||||
assert isinstance(j, int)
|
||||
assert isinstance(sim, float)
|
||||
assert i < j
|
||||
|
||||
def test_i_less_than_j(self):
|
||||
rng = np.random.default_rng(42)
|
||||
# Create some similar vectors
|
||||
base = rng.standard_normal(10).astype(np.float32)
|
||||
m = np.vstack([base + rng.standard_normal(10) * 0.01 for _ in range(5)])
|
||||
result = find_duplicate_pairs(m, 0.5)
|
||||
for i, j, _ in result:
|
||||
assert i < j
|
||||
|
||||
def test_high_threshold_fewer_pairs(self):
|
||||
rng = np.random.default_rng(42)
|
||||
m = rng.standard_normal((10, 20)).astype(np.float32)
|
||||
# Normalize for meaningful cosine similarities
|
||||
norms = np.linalg.norm(m, axis=1, keepdims=True)
|
||||
m = m / norms
|
||||
low = find_duplicate_pairs(m, 0.3)
|
||||
high = find_duplicate_pairs(m, 0.9)
|
||||
assert len(low) >= len(high)
|
||||
|
||||
|
||||
class TestSuggestLabels:
|
||||
"""Tests for z-score normalized label suggestion."""
|
||||
|
||||
def test_empty_items_returns_empty_lists(self):
|
||||
items = np.empty((0, 10), dtype=np.float32)
|
||||
labels = np.random.randn(3, 10).astype(np.float32)
|
||||
result = suggest_labels(items, labels, ["a", "b", "c"])
|
||||
assert result == []
|
||||
|
||||
def test_empty_labels_returns_empty_per_item(self):
|
||||
items = np.random.randn(5, 10).astype(np.float32)
|
||||
labels = np.empty((0, 10), dtype=np.float32)
|
||||
result = suggest_labels(items, labels, [])
|
||||
assert len(result) == 5
|
||||
assert all(s == [] for s in result)
|
||||
|
||||
def test_identical_embedding_gets_that_label(self):
|
||||
"""If an item embedding strongly matches one label, z-score should highlight it."""
|
||||
# Create multiple items so z-score normalization is meaningful
|
||||
rng = np.random.default_rng(42)
|
||||
# 10 random items + 1 item that matches label "bug" exactly
|
||||
random_items = rng.standard_normal((10, 3)).astype(np.float32)
|
||||
bug_vec = np.array([[1.0, 0.0, 0.0]], dtype=np.float32)
|
||||
items = np.vstack([random_items, bug_vec])
|
||||
labels = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, ["bug", "feature", "docs"],
|
||||
z_threshold=0.5, z_margin=0.0, min_raw_sim=0.1,
|
||||
)
|
||||
# The last item (matching bug_vec) should get "bug" as top suggestion
|
||||
last_item_sugs = result[-1]
|
||||
if last_item_sugs:
|
||||
assert last_item_sugs[0][0] == "bug"
|
||||
|
||||
def test_z_score_suppresses_dominant_label(self):
|
||||
"""When all items are similar to one label, z-scores should be low
|
||||
(none stands out) and that label should not be blindly suggested."""
|
||||
# All items identical — z-score for every item on every label is 0
|
||||
items = np.ones((10, 3), dtype=np.float32)
|
||||
labels = np.array([[1.0, 1.0, 1.0], [0.0, 1.0, 0.0]], dtype=np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, ["catch-all", "specific"],
|
||||
z_threshold=1.5, min_raw_sim=0.3,
|
||||
)
|
||||
# With identical items, std=0 -> z-scores are all 0 -> nothing passes z_threshold
|
||||
for sugs in result:
|
||||
assert sugs == []
|
||||
|
||||
def test_margin_gate_blocks_top1(self):
|
||||
"""Top-1 label must beat #2 by z_margin to be accepted as position 0."""
|
||||
rng = np.random.default_rng(99)
|
||||
# 20 items, each slightly different, 2 labels
|
||||
items = rng.standard_normal((20, 5)).astype(np.float32)
|
||||
# Two labels that are nearly identical -> margin gate should block top-1
|
||||
labels = np.array([[1.0, 0.5, 0.0, 0.0, 0.0],
|
||||
[1.0, 0.5, 0.01, 0.0, 0.0]], dtype=np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, ["label-a", "label-b"],
|
||||
z_threshold=0.0, z_margin=10.0, min_raw_sim=0.0, max_per_item=1,
|
||||
)
|
||||
# With a huge margin requirement and max_per_item=1, nothing should pass
|
||||
# because the only candidate (top-1) is blocked by margin gate,
|
||||
# and max_per_item=1 prevents falling through to position 2
|
||||
for sugs in result:
|
||||
assert sugs == []
|
||||
|
||||
def test_margin_gate_passes_when_clear_winner(self):
|
||||
"""When top-1 clearly beats #2, it should pass the margin gate."""
|
||||
# Create items where one strongly matches label 0 vs label 1
|
||||
items = np.array([
|
||||
[1.0, 0.0, 0.0, 0.0, 0.0], # strongly matches label-a
|
||||
[0.0, 0.0, 0.0, 0.0, 1.0], # matches neither well
|
||||
] * 5, dtype=np.float32) # 10 items for stable z-scores
|
||||
labels = np.array([
|
||||
[1.0, 0.0, 0.0, 0.0, 0.0], # label-a
|
||||
[0.0, 1.0, 0.0, 0.0, 0.0], # label-b (orthogonal)
|
||||
], dtype=np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, ["label-a", "label-b"],
|
||||
z_threshold=0.5, z_margin=0.3, min_raw_sim=0.1,
|
||||
)
|
||||
# Items matching label-a should get it suggested (clear z-score advantage)
|
||||
got_label_a = sum(1 for sugs in result if sugs and sugs[0][0] == "label-a")
|
||||
assert got_label_a > 0
|
||||
|
||||
def test_min_raw_similarity_filter(self):
|
||||
"""Even with high z-score, low raw similarity should be filtered out."""
|
||||
# Items are orthogonal to all labels -> raw similarity near 0
|
||||
items = np.array([[1.0, 0.0, 0.0]], dtype=np.float32)
|
||||
labels = np.array([[0.0, 0.0, 1.0]], dtype=np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, ["irrelevant"],
|
||||
z_threshold=0.0, z_margin=0.0, min_raw_sim=0.9,
|
||||
)
|
||||
# Raw similarity is ~0, which is below min_raw_sim=0.9
|
||||
assert result[0] == []
|
||||
|
||||
def test_max_per_item_respected(self):
|
||||
"""Even if many labels qualify, max_per_item caps the results."""
|
||||
rng = np.random.default_rng(42)
|
||||
# Create items with some variance so z-scores differentiate
|
||||
items = rng.standard_normal((20, 10)).astype(np.float32)
|
||||
base = items[0]
|
||||
# All labels very similar to item 0
|
||||
labels = np.array([base + rng.standard_normal(10) * 0.01 for _ in range(10)])
|
||||
names = [f"label-{i}" for i in range(10)]
|
||||
result = suggest_labels(
|
||||
items, labels, names,
|
||||
z_threshold=0.0, z_margin=0.0, min_raw_sim=0.0, max_per_item=2,
|
||||
)
|
||||
for sugs in result:
|
||||
assert len(sugs) <= 2
|
||||
|
||||
def test_returns_raw_similarity_not_z_score(self):
|
||||
"""Returned scores should be raw cosine similarity, not z-scores."""
|
||||
rng = np.random.default_rng(42)
|
||||
items = rng.standard_normal((15, 5)).astype(np.float32)
|
||||
labels = rng.standard_normal((3, 5)).astype(np.float32)
|
||||
names = ["bug", "feature", "docs"]
|
||||
result = suggest_labels(
|
||||
items, labels, names,
|
||||
z_threshold=0.0, z_margin=0.0, min_raw_sim=-1.0,
|
||||
)
|
||||
# Raw cosine similarity should be in [-1, 1] range
|
||||
for sugs in result:
|
||||
for name, score in sugs:
|
||||
assert -1.0 <= score <= 1.0 + 1e-5
|
||||
assert isinstance(name, str)
|
||||
assert isinstance(score, float)
|
||||
|
||||
def test_returns_correct_format(self):
|
||||
rng = np.random.default_rng(42)
|
||||
items = rng.standard_normal((3, 10)).astype(np.float32)
|
||||
labels = rng.standard_normal((5, 10)).astype(np.float32)
|
||||
names = ["bug", "feature", "docs", "ci", "test"]
|
||||
result = suggest_labels(
|
||||
items, labels, names,
|
||||
z_threshold=0.0, z_margin=0.0, min_raw_sim=-1.0,
|
||||
)
|
||||
assert len(result) == 3
|
||||
for sugs in result:
|
||||
for name, score in sugs:
|
||||
assert isinstance(name, str)
|
||||
assert isinstance(score, float)
|
||||
assert name in names
|
||||
|
||||
def test_text_truncation_in_labels(self):
|
||||
"""Label names should be returned as-is even when very long."""
|
||||
rng = np.random.default_rng(42)
|
||||
items = rng.standard_normal((10, 5)).astype(np.float32)
|
||||
long_name = "a" * 200
|
||||
labels = rng.standard_normal((1, 5)).astype(np.float32)
|
||||
result = suggest_labels(
|
||||
items, labels, [long_name],
|
||||
z_threshold=0.0, z_margin=0.0, min_raw_sim=-1.0,
|
||||
)
|
||||
for sugs in result:
|
||||
if sugs:
|
||||
assert sugs[0][0] == long_name
|
||||
@@ -1,873 +0,0 @@
|
||||
"""Tests for sweep.py — all external calls (API, embedding) are mocked."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch, MagicMock, mock_open
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Mock fastembed before importing sweep (which imports embedding_utils)
|
||||
sys.modules["fastembed"] = MagicMock()
|
||||
|
||||
# Set required env vars before importing sweep (module-level constants read env)
|
||||
os.environ.setdefault("GITHUB_TOKEN", "test-token")
|
||||
os.environ.setdefault("GITHUB_REPOSITORY", "owner/repo")
|
||||
|
||||
from sweep import (
|
||||
github_api_get,
|
||||
fetch_all_open_items,
|
||||
fetch_repo_labels,
|
||||
apply_labels_to_item,
|
||||
generate_report,
|
||||
create_report_issue,
|
||||
write_report,
|
||||
main,
|
||||
TriageItem,
|
||||
RepoLabel,
|
||||
REPORT_FILE,
|
||||
REPORT_LABEL,
|
||||
API_PAGE_SIZE,
|
||||
MIN_SAMPLES_FOR_OUTLIER_DETECTION,
|
||||
PCA_MAX_COMPONENTS,
|
||||
MAX_EMBED_CHARS,
|
||||
IQR_MULTIPLIER,
|
||||
MAX_OUTLIER_PCT,
|
||||
_item_age,
|
||||
_suggested_action,
|
||||
)
|
||||
|
||||
|
||||
def _make_api_issue(number: int, title: str = "Test issue", is_pr: bool = False,
|
||||
body: str = "Issue body", labels: list[str] | None = None,
|
||||
created_at: str = "2026-03-21T00:00:00Z") -> dict:
|
||||
"""Helper to build a mock GitHub API issue response object."""
|
||||
result: dict = {
|
||||
"number": number,
|
||||
"title": title,
|
||||
"html_url": f"https://github.com/owner/repo/issues/{number}",
|
||||
"body": body,
|
||||
"created_at": created_at,
|
||||
"labels": [{"name": lbl} for lbl in (labels or [])],
|
||||
}
|
||||
if is_pr:
|
||||
result["pull_request"] = {"url": "..."}
|
||||
return result
|
||||
|
||||
|
||||
class TestGithubApiGet:
|
||||
"""Tests for the github_api_get function."""
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_successful_request(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps([{"id": 1}]).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
result = github_api_get("/issues?state=open")
|
||||
assert result == [{"id": 1}]
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_http_error_exits(self, mock_urlopen):
|
||||
error = HTTPError(
|
||||
url="https://api.github.com/repos/owner/repo/issues",
|
||||
code=403,
|
||||
msg="Forbidden",
|
||||
hdrs=None, # type: ignore[arg-type]
|
||||
fp=BytesIO(b'{"message": "rate limited"}'),
|
||||
)
|
||||
mock_urlopen.side_effect = error
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
github_api_get("/issues")
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for module-level constants."""
|
||||
|
||||
def test_min_samples_is_at_least_3x_pca_max(self):
|
||||
"""MIN_SAMPLES must be >= 3 * PCA_MAX_COMPONENTS for reliable covariance."""
|
||||
assert MIN_SAMPLES_FOR_OUTLIER_DETECTION >= 3 * PCA_MAX_COMPONENTS
|
||||
|
||||
def test_min_samples_is_100(self):
|
||||
assert MIN_SAMPLES_FOR_OUTLIER_DETECTION == 100
|
||||
|
||||
def test_pca_max_components_is_20(self):
|
||||
assert PCA_MAX_COMPONENTS == 20
|
||||
|
||||
def test_iqr_multiplier_default(self):
|
||||
assert IQR_MULTIPLIER == 3.0
|
||||
|
||||
def test_max_outlier_pct_default(self):
|
||||
assert MAX_OUTLIER_PCT == 0.05
|
||||
|
||||
|
||||
class TestFetchAllOpenItems:
|
||||
"""Tests for fetch_all_open_items."""
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_empty_repo(self, mock_get):
|
||||
mock_get.return_value = []
|
||||
items = fetch_all_open_items()
|
||||
assert items == []
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_single_page(self, mock_get):
|
||||
mock_get.return_value = [
|
||||
_make_api_issue(1, "Bug report"),
|
||||
_make_api_issue(2, "Feature request", is_pr=True),
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert len(items) == 2
|
||||
assert items[0]["number"] == 1
|
||||
assert items[0]["is_pr"] is False
|
||||
assert items[1]["is_pr"] is True
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_text_field_constructed(self, mock_get):
|
||||
mock_get.return_value = [
|
||||
_make_api_issue(1, "My Title", body="My Body"),
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert items[0]["text"] == "My Title\n\nMy Body"
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_long_body_truncated(self, mock_get):
|
||||
"""Bodies exceeding MAX_EMBED_CHARS are truncated to fit the token window."""
|
||||
long_body = "x" * (MAX_EMBED_CHARS + 500)
|
||||
mock_get.return_value = [
|
||||
_make_api_issue(1, "Title", body=long_body),
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert len(items[0]["text"]) == MAX_EMBED_CHARS
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_short_body_not_truncated(self, mock_get):
|
||||
"""Bodies under the limit are left intact."""
|
||||
mock_get.return_value = [
|
||||
_make_api_issue(1, "Title", body="Short body"),
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert items[0]["text"] == "Title\n\nShort body"
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_null_body_handled(self, mock_get):
|
||||
issue = _make_api_issue(1, "No body")
|
||||
issue["body"] = None
|
||||
mock_get.return_value = [issue]
|
||||
items = fetch_all_open_items()
|
||||
assert items[0]["text"] == "No body\n\n"
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_labels_extracted(self, mock_get):
|
||||
mock_get.return_value = [
|
||||
_make_api_issue(1, "Labeled", labels=["bug", "high-priority"]),
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert items[0]["labels"] == ["bug", "high-priority"]
|
||||
|
||||
@patch("sweep.MAX_ITEMS", 3)
|
||||
@patch("sweep.github_api_get")
|
||||
def test_max_items_cap(self, mock_get):
|
||||
mock_get.return_value = [_make_api_issue(i) for i in range(100)]
|
||||
items = fetch_all_open_items()
|
||||
assert len(items) == 3
|
||||
|
||||
@patch("sweep.API_PAGE_SIZE", 2)
|
||||
@patch("sweep.github_api_get")
|
||||
def test_pagination(self, mock_get):
|
||||
# First page: 2 items (full page), second page: 1 item (partial -> stop)
|
||||
mock_get.side_effect = [
|
||||
[_make_api_issue(1), _make_api_issue(2)],
|
||||
[_make_api_issue(3)],
|
||||
]
|
||||
items = fetch_all_open_items()
|
||||
assert len(items) == 3
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
|
||||
class TestItemAge:
|
||||
"""Tests for _item_age helper."""
|
||||
|
||||
def test_recent_item(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
recent = (datetime.now(timezone.utc) - timedelta(hours=12)).isoformat()
|
||||
assert _item_age(recent) == "<1d"
|
||||
|
||||
def test_days_old(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
old = (datetime.now(timezone.utc) - timedelta(days=15)).isoformat()
|
||||
assert _item_age(old) == "15d"
|
||||
|
||||
def test_months_old(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
old = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
|
||||
assert _item_age(old) == "3mo"
|
||||
|
||||
def test_years_old(self):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
old = (datetime.now(timezone.utc) - timedelta(days=400)).isoformat()
|
||||
assert _item_age(old) == "1y"
|
||||
|
||||
def test_invalid_date(self):
|
||||
assert _item_age("not-a-date") == "?"
|
||||
|
||||
|
||||
class TestSuggestedAction:
|
||||
"""Tests for _suggested_action helper."""
|
||||
|
||||
def test_both_issues_close_newer(self):
|
||||
a = TriageItem(
|
||||
number=1, title="A", html_url="u", is_pr=False, labels=[],
|
||||
created_at="2026-01-01T00:00:00Z", text="t",
|
||||
)
|
||||
b = TriageItem(
|
||||
number=2, title="B", html_url="u", is_pr=False, labels=[],
|
||||
created_at="2026-02-01T00:00:00Z", text="t",
|
||||
)
|
||||
result = _suggested_action(a, b)
|
||||
assert "Close #2 as duplicate" in result
|
||||
|
||||
def test_both_prs_review(self):
|
||||
a = TriageItem(
|
||||
number=1, title="A", html_url="u", is_pr=True, labels=[],
|
||||
created_at="2026-01-01T00:00:00Z", text="t",
|
||||
)
|
||||
b = TriageItem(
|
||||
number=2, title="B", html_url="u", is_pr=True, labels=[],
|
||||
created_at="2026-01-01T00:00:00Z", text="t",
|
||||
)
|
||||
assert _suggested_action(a, b) == "Review for overlap"
|
||||
|
||||
def test_issue_pr_link(self):
|
||||
a = TriageItem(
|
||||
number=1, title="A", html_url="u", is_pr=False, labels=[],
|
||||
created_at="2026-01-01T00:00:00Z", text="t",
|
||||
)
|
||||
b = TriageItem(
|
||||
number=2, title="B", html_url="u", is_pr=True, labels=[],
|
||||
created_at="2026-01-01T00:00:00Z", text="t",
|
||||
)
|
||||
assert _suggested_action(a, b) == "Link PR to issue"
|
||||
|
||||
|
||||
class TestGenerateReport:
|
||||
"""Tests for the markdown report generator."""
|
||||
|
||||
def test_no_findings(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Test", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="Test",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [])
|
||||
assert "## Triage Sweep Report" in report
|
||||
assert "Items analyzed:** 1" in report
|
||||
assert "None found." in report
|
||||
assert "0 outliers flagged" in report
|
||||
assert "0 duplicate pairs found" in report
|
||||
|
||||
def test_health_summary_table(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Test", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="Test",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [])
|
||||
assert "### Health Summary" in report
|
||||
assert "| Metric | Value |" in report
|
||||
assert "| Items analyzed | 1 |" in report
|
||||
|
||||
def test_iqr_multiplier_in_thresholds(self):
|
||||
"""Report should show IQR multiplier, not percentile."""
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Test", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="Test",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [])
|
||||
assert "IQR multiplier" in report
|
||||
assert "percentile" not in report.lower().split("thresholds")[0] # not in thresholds line
|
||||
|
||||
def test_with_outliers_shows_distance_and_age(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=10, title="Spam Issue", html_url="https://example.com/10",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="spam",
|
||||
),
|
||||
TriageItem(
|
||||
number=20, title="Good Issue", html_url="https://example.com/20",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="good",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [(0, 12.34)], [])
|
||||
assert "#10" in report
|
||||
assert "Spam Issue" in report
|
||||
assert "12.34" in report
|
||||
assert "1 outliers flagged" in report
|
||||
# Age column should be present
|
||||
assert "| Age |" in report
|
||||
|
||||
def test_outlier_borderline_in_details(self):
|
||||
"""Borderline outliers should be in a <details> section."""
|
||||
from embedding_utils import _OutlierResult
|
||||
items = [
|
||||
TriageItem(
|
||||
number=10, title="Borderline", html_url="https://example.com/10",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="spam",
|
||||
),
|
||||
]
|
||||
# Create outlier results with cutoff=10.0, distance=12.0 (< 2*cutoff=20)
|
||||
outlier_results = _OutlierResult([(0, 12.0)])
|
||||
outlier_results.cutoff = 10.0
|
||||
report = generate_report(items, outlier_results, [])
|
||||
assert "<details>" in report
|
||||
assert "Borderline" in report
|
||||
|
||||
def test_outlier_high_confidence(self):
|
||||
"""Items with distance > 2x cutoff should be in high confidence section."""
|
||||
from embedding_utils import _OutlierResult
|
||||
items = [
|
||||
TriageItem(
|
||||
number=10, title="Definite Spam", html_url="https://example.com/10",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="spam",
|
||||
),
|
||||
]
|
||||
outlier_results = _OutlierResult([(0, 25.0)])
|
||||
outlier_results.cutoff = 10.0
|
||||
report = generate_report(items, outlier_results, [])
|
||||
assert "High Confidence" in report
|
||||
|
||||
def test_with_duplicates_suggested_action(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="First", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="a",
|
||||
),
|
||||
TriageItem(
|
||||
number=2, title="Second", html_url="https://example.com/2",
|
||||
is_pr=True, labels=[], created_at="2026-02-01T00:00:00Z", text="b",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [(0, 1, 0.954)])
|
||||
assert "#1" in report
|
||||
assert "#2" in report
|
||||
assert "0.954" in report
|
||||
assert "1 duplicate pairs found" in report
|
||||
assert "Suggested Action" in report
|
||||
assert "Link PR to issue" in report
|
||||
|
||||
def test_duplicate_both_issues_close_newer(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="First", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="a",
|
||||
),
|
||||
TriageItem(
|
||||
number=2, title="Second", html_url="https://example.com/2",
|
||||
is_pr=False, labels=[], created_at="2026-02-01T00:00:00Z", text="b",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [(0, 1, 0.95)])
|
||||
assert "Close #2 as duplicate" in report
|
||||
|
||||
def test_duplicate_both_prs_review(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="PR A", html_url="https://example.com/1",
|
||||
is_pr=True, labels=[], created_at="2026-01-01T00:00:00Z", text="a",
|
||||
),
|
||||
TriageItem(
|
||||
number=2, title="PR B", html_url="https://example.com/2",
|
||||
is_pr=True, labels=[], created_at="2026-01-01T00:00:00Z", text="b",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [(0, 1, 0.95)])
|
||||
assert "Review for overlap" in report
|
||||
|
||||
def test_pr_type_label(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=5, title="PR Title", html_url="https://example.com/5",
|
||||
is_pr=True, labels=[], created_at="2026-01-01T00:00:00Z", text="pr",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [(0, 8.5)], [])
|
||||
assert "| PR |" in report
|
||||
|
||||
def test_footer_present(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="T", html_url="u",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="t",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [])
|
||||
assert "no LLM was used" in report
|
||||
|
||||
|
||||
class TestCreateReportIssue:
|
||||
"""Tests for creating the report GitHub issue."""
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_successful_creation(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 201
|
||||
mock_resp.read.return_value = json.dumps({
|
||||
"html_url": "https://github.com/owner/repo/issues/99",
|
||||
}).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
# Should not raise
|
||||
create_report_issue("# Test Report")
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_http_error_exits(self, mock_urlopen):
|
||||
error = HTTPError(
|
||||
url="https://api.github.com/repos/owner/repo/issues",
|
||||
code=422,
|
||||
msg="Unprocessable",
|
||||
hdrs=None, # type: ignore[arg-type]
|
||||
fp=BytesIO(b'{"message": "validation failed"}'),
|
||||
)
|
||||
mock_urlopen.side_effect = error
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
create_report_issue("# Test Report")
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestWriteReport:
|
||||
"""Tests for the write_report helper."""
|
||||
|
||||
@patch("builtins.open", mock_open())
|
||||
def test_writes_to_file(self):
|
||||
write_report("# Report Content")
|
||||
from builtins import open as builtin_open # noqa
|
||||
# Verify open was called with the right path
|
||||
from unittest.mock import call
|
||||
open_mock = open # The patched version
|
||||
open_mock.assert_called_once_with(REPORT_FILE, "w", encoding="utf-8") # type: ignore[attr-defined]
|
||||
open_mock().write.assert_called_once_with("# Report Content") # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestFetchRepoLabels:
|
||||
"""Tests for fetch_repo_labels."""
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_fetches_and_constructs_labels(self, mock_get):
|
||||
mock_get.return_value = [
|
||||
{"name": "bug", "description": "Something isn't working"},
|
||||
{"name": "enhancement", "description": "New feature or request"},
|
||||
{"name": "docs", "description": ""},
|
||||
]
|
||||
labels = fetch_repo_labels()
|
||||
assert len(labels) == 3
|
||||
assert labels[0]["name"] == "bug"
|
||||
assert labels[0]["text"] == "bug: Something isn't working"
|
||||
assert labels[2]["text"] == "docs" # no description, just name
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_empty_repo_labels(self, mock_get):
|
||||
mock_get.return_value = []
|
||||
labels = fetch_repo_labels()
|
||||
assert labels == []
|
||||
|
||||
@patch("sweep.github_api_get")
|
||||
def test_null_description_handled(self, mock_get):
|
||||
mock_get.return_value = [
|
||||
{"name": "wontfix", "description": None},
|
||||
]
|
||||
labels = fetch_repo_labels()
|
||||
assert labels[0]["text"] == "wontfix"
|
||||
|
||||
@patch("sweep.API_PAGE_SIZE", 2)
|
||||
@patch("sweep.github_api_get")
|
||||
def test_label_pagination(self, mock_get):
|
||||
"""Repos with more labels than one page should fetch all pages."""
|
||||
mock_get.side_effect = [
|
||||
# First page: full (2 items = API_PAGE_SIZE)
|
||||
[
|
||||
{"name": "bug", "description": "Broken"},
|
||||
{"name": "feature", "description": "New"},
|
||||
],
|
||||
# Second page: partial (1 item < API_PAGE_SIZE) -> stop
|
||||
[
|
||||
{"name": "docs", "description": "Documentation"},
|
||||
],
|
||||
]
|
||||
labels = fetch_repo_labels()
|
||||
assert len(labels) == 3
|
||||
assert mock_get.call_count == 2
|
||||
assert labels[0]["name"] == "bug"
|
||||
assert labels[2]["name"] == "docs"
|
||||
|
||||
|
||||
class TestApplyLabelsToItem:
|
||||
"""Tests for apply_labels_to_item."""
|
||||
|
||||
def test_empty_labels_skips(self):
|
||||
# Should not make any API call
|
||||
apply_labels_to_item(1, [])
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_successful_label_application(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'[{"name": "bug"}]'
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
# Should not raise
|
||||
apply_labels_to_item(42, ["bug", "enhancement"])
|
||||
|
||||
@patch("sweep.urllib.request.urlopen")
|
||||
def test_http_error_is_non_fatal(self, mock_urlopen):
|
||||
error = HTTPError(
|
||||
url="https://api.github.com/repos/owner/repo/issues/1/labels",
|
||||
code=404,
|
||||
msg="Not Found",
|
||||
hdrs=None, # type: ignore[arg-type]
|
||||
fp=BytesIO(b'{"message": "not found"}'),
|
||||
)
|
||||
mock_urlopen.side_effect = error
|
||||
|
||||
# Should NOT raise — labeling failures are warnings, not fatal
|
||||
apply_labels_to_item(1, ["bug"])
|
||||
|
||||
|
||||
class TestGenerateReportWithLabels:
|
||||
"""Tests for label suggestions in the report."""
|
||||
|
||||
def test_report_includes_label_section_high_confidence(self):
|
||||
"""High-confidence label (raw_sim >= 0.5) should appear in main table."""
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Fix crash", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="crash",
|
||||
),
|
||||
]
|
||||
suggestions = [[("bug", 0.85)]]
|
||||
report = generate_report(items, [], [], label_suggestions=suggestions)
|
||||
assert "Suggested Labels" in report
|
||||
assert "`bug` (0.85)" in report
|
||||
assert "1 items suggested for labeling" in report
|
||||
|
||||
def test_report_low_confidence_in_details(self):
|
||||
"""Low-confidence label (raw_sim < 0.5) should be in <details> section."""
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Something", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="something",
|
||||
),
|
||||
]
|
||||
suggestions = [[("maybe-bug", 0.35)]]
|
||||
report = generate_report(items, [], [], label_suggestions=suggestions)
|
||||
assert "Low-confidence suggestions" in report
|
||||
assert "<details>" in report
|
||||
assert "`maybe-bug` (0.35)" in report
|
||||
|
||||
def test_report_skips_already_labeled_items(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Already labeled", html_url="https://example.com/1",
|
||||
is_pr=False, labels=["bug"], created_at="2026-01-01T00:00:00Z", text="bug",
|
||||
),
|
||||
]
|
||||
suggestions = [[("bug", 0.95)]]
|
||||
report = generate_report(items, [], [], label_suggestions=suggestions)
|
||||
assert "0 items suggested for labeling" in report
|
||||
assert "No unlabeled items" in report
|
||||
|
||||
def test_report_excludes_outliers_from_suggestions(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Spam garbage", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="spam",
|
||||
),
|
||||
TriageItem(
|
||||
number=2, title="Real bug", html_url="https://example.com/2",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="bug",
|
||||
),
|
||||
]
|
||||
suggestions = [[("bug", 0.85)], [("bug", 0.90)]]
|
||||
# Item 0 is an outlier (with distance) — should be excluded from label suggestions
|
||||
report = generate_report(items, [(0, 15.2)], [], label_suggestions=suggestions)
|
||||
assert "1 unlabeled items" in report # only item 2
|
||||
assert "#2" in report
|
||||
# Item 0 (outlier) should NOT be in the suggestions table
|
||||
assert "Spam garbage" not in report.split("Suggested Labels")[1]
|
||||
|
||||
def test_report_without_label_suggestions(self):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="T", html_url="u",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="t",
|
||||
),
|
||||
]
|
||||
report = generate_report(items, [], [], label_suggestions=None)
|
||||
assert "Suggested Labels" not in report
|
||||
|
||||
def test_label_concentration_warning(self):
|
||||
"""When >50% of suggestions point to the same label, a warning should appear."""
|
||||
items = [
|
||||
TriageItem(
|
||||
number=i, title=f"Item {i}", html_url=f"https://example.com/{i}",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text=f"text {i}",
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
# 3 out of 4 items get "bug" label -> 75% concentration
|
||||
suggestions = [
|
||||
[("bug", 0.85)],
|
||||
[("bug", 0.80)],
|
||||
[("bug", 0.75)],
|
||||
[("enhancement", 0.90)],
|
||||
]
|
||||
report = generate_report(items, [], [], label_suggestions=suggestions)
|
||||
assert "Warning" in report
|
||||
assert "`bug`" in report
|
||||
assert "3/4" in report
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for the main orchestration function."""
|
||||
|
||||
@patch.dict(os.environ, {"GITHUB_TOKEN": "", "GITHUB_REPOSITORY": "owner/repo"})
|
||||
def test_missing_token_exits(self):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@patch.dict(os.environ, {"GITHUB_TOKEN": "tok", "GITHUB_REPOSITORY": ""})
|
||||
def test_missing_repo_exits(self):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.fetch_all_open_items", return_value=[])
|
||||
def test_no_items(self, mock_fetch, mock_write):
|
||||
main()
|
||||
mock_write.assert_called_once()
|
||||
report = mock_write.call_args[0][0]
|
||||
assert "No open issues or PRs found" in report
|
||||
|
||||
@patch("sweep.create_report_issue")
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.suggest_labels", return_value=[])
|
||||
@patch("sweep.find_duplicate_pairs", return_value=[])
|
||||
@patch("sweep.detect_outliers", return_value=[])
|
||||
@patch("sweep.reduce_dimensions")
|
||||
@patch("sweep.normalize_rows")
|
||||
@patch("sweep.embed_texts")
|
||||
@patch("sweep.fetch_repo_labels")
|
||||
@patch("sweep.fetch_all_open_items")
|
||||
def test_full_flow_with_enough_items(
|
||||
self, mock_fetch, mock_labels, mock_embed, mock_norm, mock_reduce,
|
||||
mock_outliers, mock_dupes, mock_suggest, mock_write, mock_create,
|
||||
):
|
||||
"""Test the full flow with >= MIN_SAMPLES items (outlier detection runs)."""
|
||||
n = MIN_SAMPLES_FOR_OUTLIER_DETECTION
|
||||
items = [
|
||||
TriageItem(
|
||||
number=i, title=f"Item {i}", html_url=f"https://example.com/{i}",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text=f"text {i}",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
mock_fetch.return_value = items
|
||||
mock_labels.return_value = [
|
||||
RepoLabel(name="bug", description="Something broken", text="bug: Something broken"),
|
||||
]
|
||||
|
||||
embeddings = np.random.randn(n, 384).astype(np.float32)
|
||||
mock_embed.return_value = embeddings
|
||||
mock_norm.return_value = embeddings
|
||||
mock_reduce.return_value = np.random.randn(n, 10).astype(np.float32)
|
||||
|
||||
main()
|
||||
|
||||
mock_fetch.assert_called_once()
|
||||
mock_labels.assert_called_once()
|
||||
# embed_texts called twice: once for items, once for labels
|
||||
assert mock_embed.call_count == 2
|
||||
mock_norm.assert_called()
|
||||
mock_reduce.assert_called_once()
|
||||
mock_outliers.assert_called_once()
|
||||
mock_dupes.assert_called_once()
|
||||
mock_suggest.assert_called_once()
|
||||
mock_write.assert_called_once()
|
||||
mock_create.assert_called_once()
|
||||
|
||||
@patch("sweep.create_report_issue")
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.suggest_labels", return_value=[])
|
||||
@patch("sweep.find_duplicate_pairs", return_value=[])
|
||||
@patch("sweep.detect_outliers")
|
||||
@patch("sweep.reduce_dimensions")
|
||||
@patch("sweep.normalize_rows")
|
||||
@patch("sweep.embed_texts")
|
||||
@patch("sweep.fetch_repo_labels", return_value=[])
|
||||
@patch("sweep.fetch_all_open_items")
|
||||
def test_skips_outlier_detection_for_few_items(
|
||||
self, mock_fetch, mock_labels, mock_embed, mock_norm, mock_reduce,
|
||||
mock_outliers, mock_dupes, mock_suggest, mock_write, mock_create,
|
||||
):
|
||||
"""With < MIN_SAMPLES items, outlier detection should be skipped."""
|
||||
n = MIN_SAMPLES_FOR_OUTLIER_DETECTION - 1
|
||||
items = [
|
||||
TriageItem(
|
||||
number=i, title=f"Item {i}", html_url=f"https://example.com/{i}",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text=f"text {i}",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
mock_fetch.return_value = items
|
||||
|
||||
embeddings = np.random.randn(n, 384).astype(np.float32)
|
||||
mock_embed.return_value = embeddings
|
||||
mock_norm.return_value = embeddings
|
||||
|
||||
main()
|
||||
|
||||
# Outlier detection should not have been called
|
||||
mock_reduce.assert_not_called()
|
||||
mock_outliers.assert_not_called()
|
||||
# But duplicates should still be checked
|
||||
mock_dupes.assert_called_once()
|
||||
|
||||
@patch.dict(os.environ, {"INPUT_DRY_RUN": "true"})
|
||||
@patch("sweep.DRY_RUN", True)
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.create_report_issue")
|
||||
@patch("sweep.apply_labels_to_item")
|
||||
@patch("sweep.suggest_labels", return_value=[[("bug", 0.85)]])
|
||||
@patch("sweep.find_duplicate_pairs", return_value=[])
|
||||
@patch("sweep.normalize_rows")
|
||||
@patch("sweep.embed_texts")
|
||||
@patch("sweep.fetch_repo_labels")
|
||||
@patch("sweep.fetch_all_open_items")
|
||||
def test_dry_run_skips_issue_creation_and_labeling(
|
||||
self, mock_fetch, mock_labels, mock_embed, mock_norm,
|
||||
mock_dupes, mock_suggest, mock_apply, mock_create, mock_write,
|
||||
):
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Item", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="text",
|
||||
)
|
||||
]
|
||||
mock_fetch.return_value = items
|
||||
mock_labels.return_value = [
|
||||
RepoLabel(name="bug", description="Broken", text="bug: Broken"),
|
||||
]
|
||||
embeddings = np.random.randn(1, 384).astype(np.float32)
|
||||
mock_embed.return_value = embeddings
|
||||
mock_norm.return_value = embeddings
|
||||
|
||||
main()
|
||||
|
||||
mock_create.assert_not_called()
|
||||
mock_apply.assert_not_called()
|
||||
mock_write.assert_called_once()
|
||||
|
||||
@patch("sweep.create_report_issue")
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.apply_labels_to_item")
|
||||
@patch("sweep.suggest_labels")
|
||||
@patch("sweep.find_duplicate_pairs", return_value=[])
|
||||
@patch("sweep.normalize_rows")
|
||||
@patch("sweep.embed_texts")
|
||||
@patch("sweep.fetch_repo_labels")
|
||||
@patch("sweep.fetch_all_open_items")
|
||||
def test_labels_not_auto_applied(
|
||||
self, mock_fetch, mock_labels, mock_embed, mock_norm,
|
||||
mock_dupes, mock_suggest, mock_apply, mock_write, mock_create,
|
||||
):
|
||||
"""Auto-labeling is disabled; labels should appear in report only."""
|
||||
items = [
|
||||
TriageItem(
|
||||
number=1, title="Crash bug", html_url="https://example.com/1",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text="crash",
|
||||
),
|
||||
TriageItem(
|
||||
number=2, title="Already labeled", html_url="https://example.com/2",
|
||||
is_pr=False, labels=["enhancement"], created_at="2026-01-01T00:00:00Z", text="feat",
|
||||
),
|
||||
]
|
||||
mock_fetch.return_value = items
|
||||
mock_labels.return_value = [
|
||||
RepoLabel(name="bug", description="Broken", text="bug: Broken"),
|
||||
]
|
||||
mock_suggest.return_value = [
|
||||
[("bug", 0.90)],
|
||||
[("bug", 0.45)],
|
||||
]
|
||||
|
||||
embeddings = np.random.randn(2, 384).astype(np.float32)
|
||||
mock_embed.return_value = embeddings
|
||||
mock_norm.return_value = embeddings
|
||||
|
||||
main()
|
||||
|
||||
# Auto-labeling is disabled — apply_labels_to_item should never be called
|
||||
mock_apply.assert_not_called()
|
||||
|
||||
@patch("sweep.create_report_issue")
|
||||
@patch("sweep.write_report")
|
||||
@patch("sweep.apply_labels_to_item")
|
||||
@patch("sweep.suggest_labels")
|
||||
@patch("sweep.find_duplicate_pairs", return_value=[])
|
||||
@patch("sweep.detect_outliers")
|
||||
@patch("sweep.reduce_dimensions")
|
||||
@patch("sweep.normalize_rows")
|
||||
@patch("sweep.embed_texts")
|
||||
@patch("sweep.fetch_repo_labels")
|
||||
@patch("sweep.fetch_all_open_items")
|
||||
def test_outliers_excluded_from_report_suggestions(
|
||||
self, mock_fetch, mock_labels, mock_embed, mock_norm, mock_reduce,
|
||||
mock_outliers, mock_dupes, mock_suggest, mock_apply, mock_write, mock_create,
|
||||
):
|
||||
"""Items flagged as outliers should not appear in report label suggestions."""
|
||||
n = MIN_SAMPLES_FOR_OUTLIER_DETECTION
|
||||
items = [
|
||||
TriageItem(
|
||||
number=i, title=f"Item {i}", html_url=f"https://example.com/{i}",
|
||||
is_pr=False, labels=[], created_at="2026-01-01T00:00:00Z", text=f"text {i}",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
mock_fetch.return_value = items
|
||||
mock_labels.return_value = [
|
||||
RepoLabel(name="bug", description="Broken", text="bug: Broken"),
|
||||
]
|
||||
mock_outliers.return_value = [(0, 12.5), (5, 15.3)]
|
||||
mock_suggest.return_value = [[("bug", 0.85)] for _ in range(n)]
|
||||
|
||||
embeddings = np.random.randn(n, 384).astype(np.float32)
|
||||
mock_embed.return_value = embeddings
|
||||
mock_norm.return_value = embeddings
|
||||
mock_reduce.return_value = np.random.randn(n, 10).astype(np.float32)
|
||||
|
||||
main()
|
||||
|
||||
# Auto-labeling is disabled
|
||||
mock_apply.assert_not_called()
|
||||
# Report should still be generated (outliers excluded from suggestions in report)
|
||||
mock_write.assert_called_once()
|
||||
report = mock_write.call_args[0][0]
|
||||
# Outlier items 0 and 5 should not appear in the label suggestions section
|
||||
assert "Item 0" not in report.split("Suggested Labels")[1] if "Suggested Labels" in report else True
|
||||
@@ -1,127 +0,0 @@
|
||||
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
|
||||
# devcontainer.json, so the version pin there stays the single source of truth.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.devcontainer/**'
|
||||
- '.github/workflows/ci-devcontainer.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.devcontainer/**'
|
||||
- '.github/workflows/ci-devcontainer.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Grouped per branch or tag. Cancel a PR run when a newer one replaces it.
|
||||
# Never cancel a push-to-main run.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
config-transforms:
|
||||
name: Config-transform unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# persist-credentials: false — this job only reads (tests and syntax
|
||||
# checks) and never pushes. The setting keeps GITHUB_TOKEN out of
|
||||
# .git/config, which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Unit-test the host->container config transforms
|
||||
run: node --test .devcontainer/translate-plugin-registries.test.cjs
|
||||
- name: Syntax-check the lifecycle shell scripts
|
||||
run: |
|
||||
bash -n .devcontainer/install-deps.sh
|
||||
bash -n .devcontainer/post-create.sh
|
||||
|
||||
build:
|
||||
name: Build devcontainer image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# persist-credentials: false — this is a read-only build smoke that
|
||||
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
|
||||
# which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
# 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
|
||||
# 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`).
|
||||
#
|
||||
# The @devcontainers/cli version is pinned on purpose. A bare
|
||||
# `npx --yes @devcontainers/cli` would resolve @latest at run time. A
|
||||
# breaking or malicious publish could then change CI behavior, or change
|
||||
# how devcontainer.json is read, with no diff to show for it. Bump this pin
|
||||
# deliberately, alongside the Dockerfile and devcontainer.json pins.
|
||||
#
|
||||
# @devcontainers/cli wraps the Dockerfile with `# syntax=docker/dockerfile:1`,
|
||||
# which BuildKit resolves from Docker Hub. Hub blips surface as
|
||||
# `DeadlineExceeded` / `i/o timeout` on the syntax frontend (see run
|
||||
# 26797815133). Build retry (2 attempts, 45s backoff) matches
|
||||
# `.github/actions/docker-build-push-retry` (docker/build-push-action#1422).
|
||||
# Pre-pull of docker/dockerfile:1 is extra hardening; best-effort so the
|
||||
# build retry still runs if Hub is flaky only during pull.
|
||||
- name: Pre-pull BuildKit Dockerfile frontend (retry)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
img="docker/dockerfile:1"
|
||||
for attempt in 1 2 3; do
|
||||
if docker pull "$img"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::docker pull ${img} attempt ${attempt} failed"
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
sleep $((attempt * 15))
|
||||
fi
|
||||
done
|
||||
echo "::warning::failed to pre-pull ${img} after 3 attempts; continuing — build step may still succeed"
|
||||
exit 1
|
||||
- name: Build devcontainer via @devcontainers/cli
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2; do
|
||||
if npx --yes @devcontainers/cli@0.87.0 build --workspace-folder .; then
|
||||
if [ "$attempt" -eq 2 ]; then
|
||||
echo "::notice::devcontainer build retry succeeded (attempt 2); investigate if this recurs across runs."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq 2 ]; then
|
||||
echo "::error::devcontainer build failed after 2 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::devcontainer build attempt ${attempt} failed; retrying in 45s…"
|
||||
sleep 45
|
||||
done
|
||||
@@ -1,94 +0,0 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
name: Check web module changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
web_changed: ${{ steps.filter.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
web:
|
||||
- 'gitnexus-web/**'
|
||||
|
||||
e2e:
|
||||
name: e2e (chromium)
|
||||
needs: check-changes
|
||||
if: needs.check-changes.result == 'success' && needs.check-changes.outputs.web_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Configure e2e GitNexus home
|
||||
run: echo "GITNEXUS_HOME=${RUNNER_TEMP}/gitnexus-home" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: npm ci
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Build backend
|
||||
run: npm run build
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Analyze repository (index for backend)
|
||||
run: |
|
||||
E2E_REPO="${RUNNER_TEMP}/gitnexus-e2e-repo"
|
||||
rm -rf "${E2E_REPO}"
|
||||
mkdir -p "${E2E_REPO}"
|
||||
cp -R gitnexus/test/fixtures/mini-repo/src "${E2E_REPO}/src"
|
||||
printf '%s\n' '{"name":"e2e-mini-repo","version":"0.0.0","private":true}' > "${E2E_REPO}/package.json"
|
||||
node gitnexus/dist/cli/index.js analyze "${E2E_REPO}" --skip-git --skip-agents-md --name e2e-mini-repo
|
||||
if [ ! -d "${E2E_REPO}/.gitnexus" ]; then
|
||||
echo "::error::No fixture .gitnexus index created"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Start backend server
|
||||
run: node dist/cli/index.js serve &
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Wait for backend readiness
|
||||
run: npx wait-on http://localhost:4747/api/repos --timeout 30000
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Start Vite dev server
|
||||
run: npm run dev &
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Wait for Vite dev server
|
||||
run: npx wait-on http://localhost:5173 --timeout 30000
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npx playwright test
|
||||
working-directory: gitnexus-web
|
||||
env:
|
||||
E2E: '1'
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-results
|
||||
path: |
|
||||
gitnexus-web/test-results/
|
||||
gitnexus-web/playwright-report/
|
||||
retention-days: 5
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Integration Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
# ── Integration test matrix ─────────────────────────────────────────
|
||||
# Each test-group runs on a SEPARATE runner per OS, giving full process
|
||||
# isolation for the KuzuDB native C++ addon.
|
||||
# 3 OS x 4 groups = 12 parallel jobs.
|
||||
#
|
||||
# Groups:
|
||||
# kuzu-db — 7 files using withTestKuzuDB / kuzu-adapter (native addon)
|
||||
# Each file runs as its own `vitest run` invocation for full
|
||||
# process isolation. KuzuDB's native N-API addon registers
|
||||
# persistent handles that prevent fork workers from exiting
|
||||
# on Linux, and its C++ destructors segfault during
|
||||
# process.exit(). Running each file in its own process lets
|
||||
# the OS reclaim all resources cleanly.
|
||||
# pipeline — 3 files: ingestion pipeline + csv, each creates own temp DB
|
||||
# e2e — 2 files: child-process only (spawnSync), no in-process kuzu
|
||||
# standalone — 4 files: pure logic, no kuzu, no child processes
|
||||
test-matrix:
|
||||
name: integration (${{ matrix.os }} / ${{ matrix.test-group }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
test-group: [kuzu-db, pipeline, e2e, standalone]
|
||||
include:
|
||||
- test-group: kuzu-db
|
||||
# Marker — actual files are listed in the run step below
|
||||
test-glob: ''
|
||||
- test-group: pipeline
|
||||
test-glob: >-
|
||||
test/integration/pipeline.test.ts
|
||||
test/integration/csv-pipeline.test.ts
|
||||
test/integration/parsing.test.ts
|
||||
- test-group: e2e
|
||||
test-glob: >-
|
||||
test/integration/cli-e2e.test.ts
|
||||
test/integration/hooks-e2e.test.ts
|
||||
- test-group: standalone
|
||||
test-glob: >-
|
||||
test/integration/filesystem-walker.test.ts
|
||||
test/integration/enrichment.test.ts
|
||||
test/integration/tree-sitter-languages.test.ts
|
||||
test/integration/worker-pool.test.ts
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
# kuzu-db: run each file in its own vitest process for full isolation.
|
||||
# KuzuDB's native addon hangs fork workers on Linux — process isolation
|
||||
# is the only reliable fix boundary.
|
||||
- name: Run integration tests — kuzu-db (process-isolated)
|
||||
if: matrix.test-group == 'kuzu-db'
|
||||
working-directory: gitnexus
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
files=(
|
||||
test/integration/kuzu-core-adapter.test.ts
|
||||
test/integration/kuzu-pool.test.ts
|
||||
test/integration/local-backend.test.ts
|
||||
test/integration/local-backend-calltool.test.ts
|
||||
test/integration/search-core.test.ts
|
||||
test/integration/search-pool.test.ts
|
||||
test/integration/augmentation.test.ts
|
||||
)
|
||||
for f in "${files[@]}"; do
|
||||
echo "::group::$f"
|
||||
npx vitest run --reporter=verbose --pool=forks "$f"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
# Non-kuzu groups: run all files in a single vitest invocation
|
||||
- name: Run integration tests — ${{ matrix.test-group }}
|
||||
if: matrix.test-group != 'kuzu-db'
|
||||
run: npx vitest run --reporter=verbose ${{ matrix.test-glob }}
|
||||
working-directory: gitnexus
|
||||
|
||||
# ── Unified status gate ──────────────────────────────────────────────
|
||||
# Branch protection should require THIS job, not the matrix jobs directly.
|
||||
status:
|
||||
name: integration (all groups)
|
||||
needs: test-matrix
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check all matrix jobs passed
|
||||
run: |
|
||||
result="${{ needs.test-matrix.result }}"
|
||||
if [[ "$result" != "success" ]]; then
|
||||
echo "::error::Integration matrix failed or cancelled: $result"
|
||||
exit 1
|
||||
fi
|
||||
@@ -3,73 +3,11 @@ name: Quality Checks
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
- run: npx prettier --check .
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
- run: npx eslint .
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
|
||||
typecheck-web:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
- run: npx tsc -b --noEmit
|
||||
working-directory: gitnexus-web
|
||||
|
||||
# Enforces the convention documented in CONTRIBUTING.md → "GitHub Actions —
|
||||
# Concurrency Convention":
|
||||
# 1. Every entry-point (non-reusable) workflow declares a top-level
|
||||
# `concurrency:` block.
|
||||
# 2. Reusable workflows (`on: workflow_call` only) do NOT declare one —
|
||||
# they inherit concurrency from the caller.
|
||||
# 3. The concurrency group key starts with `${{ github.workflow }}` or
|
||||
# the literal `CI-` prefix (the documented ci.yml exception for
|
||||
# reusable-workflow-safe grouping).
|
||||
# Reusability is detected by parsing each workflow's `on:` block, not an
|
||||
# allowlist, so new reusable workflows never produce false positives.
|
||||
workflow-convention:
|
||||
name: Workflow concurrency convention
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Validate workflow concurrency convention
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .github/scripts/check-workflow-concurrency.py .github/workflows
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
name: CI Report
|
||||
|
||||
# Triggered after the CI workflow completes. Because workflow_run
|
||||
# always runs code from the *default branch*, it receives a read/write
|
||||
# GITHUB_TOKEN — even when the triggering PR comes from a fork.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI']
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read # needed to list/download workflow run artifacts
|
||||
contents: read # needed for sparse checkout of vitest.config.ts
|
||||
pull-requests: write # needed to post sticky PR comment
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Serialize sticky-comment writes per PR so two rapid CI completions don't race.
|
||||
# Internal PRs surface in `pull_requests[0].number`. Fork PRs leave that array empty,
|
||||
# so we fall back to `<head-repo-full-name>/<head-branch>`, which is stable across
|
||||
# reruns and subsequent pushes for the same fork PR (unlike `workflow_run.id` which
|
||||
# is unique per run and therefore does not serialize anything).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
pr-report:
|
||||
name: PR Report
|
||||
# Only run for pull-request CI runs
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# ── Download artifacts from the CI run ────────────────────────
|
||||
- name: Download artifacts
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const runId = context.payload.workflow_run.id;
|
||||
|
||||
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId,
|
||||
});
|
||||
|
||||
async function downloadArtifact(name, dest) {
|
||||
const match = allArtifacts.data.artifacts.find(a => a.name === name);
|
||||
if (!match) {
|
||||
core.warning(`Artifact "${name}" not found`);
|
||||
return false;
|
||||
}
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: match.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, `${name}.zip`), Buffer.from(zip.data));
|
||||
return true;
|
||||
}
|
||||
|
||||
const temp = process.env.RUNNER_TEMP;
|
||||
await downloadArtifact('pr-meta', path.join(temp, 'dl'));
|
||||
await downloadArtifact('test-reports', path.join(temp, 'dl'));
|
||||
|
||||
- name: Extract artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/dl"
|
||||
# Extract each artifact into its own directory to avoid filename collisions
|
||||
for z in *.zip; do
|
||||
[ -f "$z" ] || continue
|
||||
name="${z%.zip}"
|
||||
mkdir -p "$RUNNER_TEMP/artifacts/$name"
|
||||
unzip -o "$z" -d "$RUNNER_TEMP/artifacts/$name"
|
||||
done
|
||||
|
||||
- name: Read PR metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
DIR="$RUNNER_TEMP/artifacts/pr-meta"
|
||||
if [ ! -f "$DIR/pr_number" ]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::pr_number artifact missing — skipping report"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate PR number is a positive integer (artifact comes from
|
||||
# untrusted fork code, so treat contents defensively).
|
||||
PR_NUM=$(tr -d '[:space:]' < "$DIR/pr_number")
|
||||
if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::error::Invalid PR number in artifact: '$PR_NUM'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate job-result strings against known GitHub Actions values.
|
||||
# Artifact contents come from the PR workflow (potentially untrusted
|
||||
# fork code), so we whitelist to prevent newline injection into
|
||||
# GITHUB_OUTPUT.
|
||||
validate_result() {
|
||||
local val
|
||||
val=$(tr -d '[:space:]' < "$1")
|
||||
case "$val" in
|
||||
success|failure|cancelled|skipped) echo "$val" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
{
|
||||
echo "skip=false"
|
||||
echo "pr_number=$PR_NUM"
|
||||
echo "quality=$(validate_result "$DIR/quality_result")"
|
||||
echo "tests=$(validate_result "$DIR/tests_result")"
|
||||
echo "e2e=$(validate_result "$DIR/e2e_result")"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: gitnexus/vitest.config.ts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
# ── Fetch base branch coverage for delta reporting ───────────
|
||||
- name: Fetch base branch coverage
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
id: base-coverage
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Find recent successful CI runs on main (check several in case
|
||||
// the most recent artifact has expired).
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci.yml',
|
||||
branch: 'main',
|
||||
status: 'success',
|
||||
per_page: 5,
|
||||
});
|
||||
|
||||
if (runs.data.workflow_runs.length === 0) {
|
||||
core.setOutput('found', 'false');
|
||||
core.info('No successful main branch CI runs found');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
});
|
||||
|
||||
const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
if (!testReports) {
|
||||
core.info(`Run ${run.id}: no test-reports artifact, trying next`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: testReports.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data));
|
||||
core.setOutput('found', 'true');
|
||||
core.setOutput('dir', dest);
|
||||
return;
|
||||
} catch (err) {
|
||||
// 410 Gone means the artifact expired; try the next run
|
||||
if (err.status === 410 || err.response?.status === 410) {
|
||||
core.info(`Run ${run.id}: artifact expired, trying next`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts exhausted — no usable base coverage
|
||||
core.setOutput('found', 'false');
|
||||
core.info('No downloadable test-reports artifact found on main (all expired or missing)');
|
||||
|
||||
- name: Extract base coverage
|
||||
if: steps.meta.outputs.skip != 'true' && steps.base-coverage.outputs.found == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
cd "${{ steps.base-coverage.outputs.dir }}"
|
||||
mkdir -p base
|
||||
unzip -o base.zip -d base
|
||||
|
||||
- name: Build report
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
id: report
|
||||
shell: bash
|
||||
env:
|
||||
QUALITY: ${{ steps.meta.outputs.quality }}
|
||||
TESTS: ${{ steps.meta.outputs.tests }}
|
||||
E2E: ${{ steps.meta.outputs.e2e }}
|
||||
BASE_FOUND: ${{ steps.base-coverage.outputs.found }}
|
||||
BASE_DIR: ${{ steps.base-coverage.outputs.dir }}
|
||||
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
run: |
|
||||
DIR="$RUNNER_TEMP/artifacts"
|
||||
|
||||
# ── Helper: read coverage summary into prefixed vars ──
|
||||
read_cov() {
|
||||
local prefix=$1 file=$2
|
||||
if [ -n "$file" ] && [ -f "$file" ]; then
|
||||
local val
|
||||
val=$(jq -r '.total.statements.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_STMTS" '%s' "$val"
|
||||
val=$(jq -r '.total.branches.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_BRANCH" '%s' "$val"
|
||||
val=$(jq -r '.total.functions.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_FUNCS" '%s' "$val"
|
||||
val=$(jq -r '.total.lines.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_LINES" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.statements.covered)/\(.total.statements.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_STMTS_COV" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.branches.covered)/\(.total.branches.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_BRANCH_COV" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.functions.covered)/\(.total.functions.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_FUNCS_COV" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.lines.covered)/\(.total.lines.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_LINES_COV" '%s' "$val"
|
||||
return 0
|
||||
else
|
||||
printf -v "${prefix}_STMTS" '%s' "N/A"
|
||||
printf -v "${prefix}_BRANCH" '%s' "N/A"
|
||||
printf -v "${prefix}_FUNCS" '%s' "N/A"
|
||||
printf -v "${prefix}_LINES" '%s' "N/A"
|
||||
printf -v "${prefix}_STMTS_COV" '%s' ""
|
||||
printf -v "${prefix}_BRANCH_COV" '%s' ""
|
||||
printf -v "${prefix}_FUNCS_COV" '%s' ""
|
||||
printf -v "${prefix}_LINES_COV" '%s' ""
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Read coverage reports ──
|
||||
UNIT_SUMMARY=$(find "$DIR/test-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
|
||||
|
||||
read_cov "U" "$UNIT_SUMMARY"
|
||||
|
||||
# ── Read base branch coverage (main) ──
|
||||
BASE_SUMMARY=""
|
||||
if [ "$BASE_FOUND" = "true" ] && [ -n "$BASE_DIR" ]; then
|
||||
BASE_SUMMARY=$(find "$BASE_DIR/base" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
|
||||
fi
|
||||
read_cov "B" "$BASE_SUMMARY"
|
||||
|
||||
# ── Locate test results ──
|
||||
RESULTS_FILE=$(find "$DIR/test-reports" -name "test-results.json" -type f 2>/dev/null | head -1)
|
||||
WEB_RESULTS_FILE=$(find "$DIR/test-reports" -name "web-test-results.json" -type f 2>/dev/null | head -1)
|
||||
|
||||
sum_results() {
|
||||
local file=$1
|
||||
if [ -n "$file" ] && [ -f "$file" ]; then
|
||||
jq -r '"\(.numTotalTests) \(.numPassedTests) \(.numFailedTests) \(.numPendingTests) \(.numTotalTestSuites) \(((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor)"' "$file" 2>/dev/null || echo "0 0 0 0 0 0"
|
||||
else
|
||||
echo "0 0 0 0 0 0"
|
||||
fi
|
||||
}
|
||||
|
||||
# `_` placeholder for the suite-count column — positional
|
||||
# readability for sum_results' 6-field output, but the value
|
||||
# isn't surfaced in the report (suites are tracked per-test
|
||||
# framework, not as a top-line metric).
|
||||
read -r CLI_T CLI_P CLI_F CLI_S _ CLI_D <<< "$(sum_results "$RESULTS_FILE")"
|
||||
read -r WEB_T WEB_P WEB_F WEB_S _ WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")"
|
||||
|
||||
TOTAL=$((CLI_T + WEB_T))
|
||||
PASSED=$((CLI_P + WEB_P))
|
||||
FAILED=$((CLI_F + WEB_F))
|
||||
SKIPPED=$((CLI_S + WEB_S))
|
||||
DURATION=$((CLI_D > WEB_D ? CLI_D : WEB_D))
|
||||
|
||||
# ── Status helpers ──
|
||||
status_icon() {
|
||||
case "$1" in
|
||||
success) echo "✅" ;;
|
||||
failure) echo "❌" ;;
|
||||
cancelled) echo "⏭️" ;;
|
||||
*) echo "❓" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Validate a value looks like a number (integer or decimal, optional
|
||||
# leading minus). Returns 1 for anything else — guards against awk
|
||||
# injection when artifact values come from untrusted fork code.
|
||||
is_numeric() { [[ "$1" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; }
|
||||
|
||||
cov_delta() {
|
||||
local pct=$1 base=$2
|
||||
if [ "$pct" = "N/A" ] || [ "$base" = "N/A" ]; then echo "—"; return; fi
|
||||
if ! is_numeric "$pct" || ! is_numeric "$base"; then echo "—"; return; fi
|
||||
local diff
|
||||
diff=$(awk -v p="$pct" -v b="$base" 'BEGIN { printf "%.1f", p - b }')
|
||||
if [ "$(awk -v p="$pct" -v b="$base" 'BEGIN { print (p > b) ? 1 : 0 }')" = "1" ]; then
|
||||
echo "📈 +${diff}"
|
||||
elif [ "$(awk -v p="$pct" -v b="$base" 'BEGIN { print (p < b) ? 1 : 0 }')" = "1" ]; then
|
||||
echo "📉 ${diff}"
|
||||
else
|
||||
echo "= ${diff}"
|
||||
fi
|
||||
}
|
||||
|
||||
cov_bar() {
|
||||
local pct=$1 base=$2
|
||||
if [ "$pct" = "N/A" ] || ! is_numeric "$pct"; then echo "—"; return; fi
|
||||
local filled
|
||||
filled=$(awk -v p="$pct" 'BEGIN { printf "%d", p / 5 }')
|
||||
(( filled < 0 )) && filled=0
|
||||
(( filled > 20 )) && filled=20
|
||||
local empty=$((20 - filled))
|
||||
local bar=""
|
||||
for ((i=0; i<filled; i++)); do bar+="█"; done
|
||||
for ((i=0; i<empty; i++)); do bar+="░"; done
|
||||
# Green if >= base (or base unavailable), red if dropped
|
||||
if [ "$base" = "N/A" ] || ! is_numeric "$base" || [ "$(awk -v p="$pct" -v b="$base" 'BEGIN { print (p >= b) ? 1 : 0 }')" = "1" ]; then
|
||||
echo "🟢 ${bar}"
|
||||
else
|
||||
echo "🔴 ${bar}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Overall status ──
|
||||
if [[ "$QUALITY" == "success" && "$TESTS" == "success" && ("$E2E" == "success" || "$E2E" == "skipped") ]]; then
|
||||
OVERALL="✅ **All checks passed**"
|
||||
else
|
||||
OVERALL="❌ **Some checks failed**"
|
||||
fi
|
||||
|
||||
# ── Build markdown ──
|
||||
{
|
||||
echo "body<<GITNEXUS_CI_REPORT_EOF_7f3a"
|
||||
echo "## CI Report"
|
||||
echo ""
|
||||
echo "${OVERALL}"
|
||||
echo ""
|
||||
echo "### Pipeline Status"
|
||||
echo ""
|
||||
echo "| Stage | Status | Details |"
|
||||
echo "|-------|--------|---------|"
|
||||
echo "| $(status_icon "$QUALITY") Typecheck | \`${QUALITY}\` | tsc --noEmit |"
|
||||
echo "| $(status_icon "$TESTS") Tests | \`${TESTS}\` | unit tests, 3 platforms |"
|
||||
echo "| $(status_icon "$E2E") E2E | \`${E2E}\` | gitnexus-web changes only |"
|
||||
echo ""
|
||||
|
||||
if [ "$TOTAL" -gt 0 ] 2>/dev/null; then
|
||||
echo "### Test Results"
|
||||
echo ""
|
||||
echo "| Tests | Passed | Failed | Skipped | Duration |"
|
||||
echo "|-------|--------|--------|---------|----------|"
|
||||
echo "| ${TOTAL} | ${PASSED} | ${FAILED} | ${SKIPPED} | ${DURATION}s |"
|
||||
echo ""
|
||||
|
||||
if [ "$FAILED" = "0" ]; then
|
||||
echo "✅ All **${PASSED}** tests passed"
|
||||
else
|
||||
echo "❌ **${FAILED}** failed / **${PASSED}** passed"
|
||||
fi
|
||||
if [ "$SKIPPED" != "0" ]; then
|
||||
echo ""
|
||||
echo "<details>"
|
||||
echo "<summary>${SKIPPED} test(s) skipped — expand for details</summary>"
|
||||
echo ""
|
||||
for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE"; do
|
||||
if [ -n "$rf" ] && [ -f "$rf" ]; then
|
||||
jq -r '
|
||||
.testResults[]
|
||||
| .assertionResults[]?
|
||||
| select(.status == "pending" or .status == "skipped")
|
||||
| "- \(.ancestorTitles | join(" > ")) > \(.title)"
|
||||
' "$rf" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo "</details>"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Coverage table helper ──
|
||||
cov_table() {
|
||||
local label=$1 s=$2 b=$3 f=$4 l=$5 sc=$6 bc=$7 fc=$8 lc=$9
|
||||
shift 9
|
||||
local bs=$1 bb=$2 bf=$3 bl=$4
|
||||
echo "#### ${label}"
|
||||
echo ""
|
||||
echo "| Metric | Coverage | Covered | Base | Delta | Status |"
|
||||
echo "|--------|----------|---------|------|-------|--------|"
|
||||
echo "| Statements | **${s}%** | ${sc} | ${bs}% | $(cov_delta "$s" "$bs") | $(cov_bar "$s" "$bs") |"
|
||||
echo "| Branches | **${b}%** | ${bc} | ${bb}% | $(cov_delta "$b" "$bb") | $(cov_bar "$b" "$bb") |"
|
||||
echo "| Functions | **${f}%** | ${fc} | ${bf}% | $(cov_delta "$f" "$bf") | $(cov_bar "$f" "$bf") |"
|
||||
echo "| Lines | **${l}%** | ${lc} | ${bl}% | $(cov_delta "$l" "$bl") | $(cov_bar "$l" "$bl") |"
|
||||
echo ""
|
||||
}
|
||||
|
||||
if [ "$U_STMTS" != "N/A" ]; then
|
||||
echo "### Code Coverage"
|
||||
echo ""
|
||||
cov_table "Tests" \
|
||||
"$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \
|
||||
"$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \
|
||||
"$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES"
|
||||
else
|
||||
echo "### Code Coverage"
|
||||
echo ""
|
||||
echo "⚠️ Coverage data unavailable - check the [unit test job](${RUN_URL}) for details."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "<sub>📋 [View full run](${RUN_URL}) · Generated by CI</sub>"
|
||||
echo "GITNEXUS_CI_REPORT_EOF_7f3a"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v2
|
||||
with:
|
||||
header: ci-report
|
||||
number: ${{ steps.meta.outputs.pr_number }}
|
||||
message: ${{ steps.report.outputs.body }}
|
||||
@@ -1,278 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: ubuntu / coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
# persist-credentials: false — this job runs tests and uploads a
|
||||
# test-reports artifact (if: always()). The default-persisted token in
|
||||
# .git/config must not be capturable through that upload (zizmor
|
||||
# credential-persistence / artipacked audit). The job never pushes.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Run all tests with coverage
|
||||
run: >-
|
||||
npx vitest run
|
||||
--reporter=default
|
||||
--reporter=json
|
||||
--outputFile=test-results.json
|
||||
--coverage
|
||||
--coverage.reporter=json-summary
|
||||
--coverage.reporter=json
|
||||
--coverage.reporter=text
|
||||
--coverage.thresholdAutoUpdate=false
|
||||
--coverage.reportOnFailure=true
|
||||
working-directory: gitnexus
|
||||
|
||||
# gitnexus-shared already built by setup-gitnexus action above
|
||||
- name: Install gitnexus-web dependencies
|
||||
run: npm ci
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Run gitnexus-web unit tests
|
||||
run: >-
|
||||
npx vitest run
|
||||
--reporter=default
|
||||
--reporter=json
|
||||
--outputFile=web-test-results.json
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Run docker-server integration tests
|
||||
run: node --test docker-server.test.mjs
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-reports
|
||||
path: |
|
||||
gitnexus/coverage/coverage-summary.json
|
||||
gitnexus/coverage/coverage-final.json
|
||||
gitnexus/test-results.json
|
||||
gitnexus-web/web-test-results.json
|
||||
retention-days: 5
|
||||
|
||||
# Platform-sensitive subset only — the full suite runs on Ubuntu above.
|
||||
# See gitnexus/scripts/cross-platform-tests.ts for the file list and
|
||||
# rationale for each included test.
|
||||
cross-platform:
|
||||
name: ${{ matrix.os }} (platform-sensitive)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Ubuntu already covered by the coverage job above
|
||||
os: [windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# persist-credentials: false — runs tests only, never pushes (zizmor
|
||||
# credential-persistence / artipacked audit).
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
- name: Run platform-sensitive tests
|
||||
run: npx tsx scripts/run-cross-platform.ts
|
||||
working-directory: gitnexus
|
||||
|
||||
# Tree-sitter ABI gate (#1922). Two halves, both blocking:
|
||||
# 1. Static, offline: assert every grammar's compiled ABI loads on the
|
||||
# pinned runtime (check-tree-sitter-upgrade-readiness.py --assert-current).
|
||||
# 2. Dynamic: run the parser-loader ABI load-smoke on the OS matrix so an
|
||||
# ABI-incompatible prebuilt (esp. the binary-only Swift vendor, which the
|
||||
# static check can't introspect) fails on the platform it ships to.
|
||||
abi-assert:
|
||||
name: tree-sitter ABI (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Assert installed + vendored grammar ABIs (static)
|
||||
shell: bash
|
||||
run: python3 .github/scripts/check-tree-sitter-upgrade-readiness.py --assert-current
|
||||
|
||||
- name: Run parser-loader ABI load-smoke (dynamic)
|
||||
run: npx vitest run test/unit/parser-loader-abi.test.ts
|
||||
working-directory: gitnexus
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
packaged-install-smoke:
|
||||
name: packaged install smoke (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
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
|
||||
# be at risk of leaking through any future artifact-upload step
|
||||
# (zizmor artipacked audit). Disable upfront.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Pack gitnexus tarball
|
||||
shell: bash
|
||||
run: npm pack
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Install gitnexus tarball into isolated prefix
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PREFIX="$RUNNER_TEMP/gitnexus-smoke"
|
||||
mkdir -p "$PREFIX"
|
||||
TARBALL=$(find . -maxdepth 1 -name 'gitnexus-*.tgz' -print -quit)
|
||||
if [ -z "$TARBALL" ]; then
|
||||
echo "ERROR: no gitnexus-*.tgz tarball found in $(pwd)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing $TARBALL into $PREFIX"
|
||||
npm install -g --prefix "$PREFIX" "./$TARBALL" --no-audit --no-fund
|
||||
echo "PREFIX=$PREFIX" >> "$GITHUB_ENV"
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Assert no junctions or vendor build artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Locate the installed gitnexus package across npm prefix layouts
|
||||
# (lib/node_modules on POSIX, node_modules on Windows).
|
||||
for candidate in "$PREFIX/lib/node_modules/gitnexus" "$PREFIX/node_modules/gitnexus"; do
|
||||
if [ -d "$candidate" ]; then
|
||||
INSTALLED="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "${INSTALLED:-}" ]; then
|
||||
echo "ERROR: installed gitnexus package not found under $PREFIX" >&2
|
||||
ls -la "$PREFIX" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Installed package at: $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
|
||||
echo "ERROR: vendor tree contains forbidden build artifacts (#836):" >&2
|
||||
echo "$BAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# #1728 invariant: materialized grammar dirs are real directories,
|
||||
# not junctions/symlinks (which is what the EPERM regression created).
|
||||
for name in tree-sitter-dart tree-sitter-proto tree-sitter-swift; do
|
||||
entry="$INSTALLED/node_modules/$name"
|
||||
if [ ! -e "$entry" ]; then
|
||||
echo "WARN: $name not materialized (toolchain/prebuild may be unavailable on $RUNNER_OS)"
|
||||
continue
|
||||
fi
|
||||
if [ -L "$entry" ]; then
|
||||
echo "ERROR: $entry is a symlink/junction — #1728 regression" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$entry" ]; then
|
||||
echo "ERROR: $entry is not a directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Assert gitnexus --version works
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
"$PREFIX/gitnexus.cmd" --version
|
||||
else
|
||||
"$PREFIX/bin/gitnexus" --version
|
||||
fi
|
||||
|
||||
# ── 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
|
||||
# coverage job above SKIPS them — their O(n^2) scaling guards never ran in CI.
|
||||
# Run them here with GITNEXUS_BENCH=1, alongside the Python scope-capture and
|
||||
# import-resolution fingerprint + scaling guards (PR #1918 P2a).
|
||||
#
|
||||
# `--no-file-parallelism` is REQUIRED: these suites measure wall-clock and peak
|
||||
# heap, so parallel forks both skew the timings and OOM the worker pool — they
|
||||
# must run one file at a time.
|
||||
#
|
||||
# go-pipeline-benchmark.test.ts is deliberately NOT included: its
|
||||
# worker-pool (#1848) suite spins a real worker pool that exits unexpectedly
|
||||
# under vitest's fork pool (reproduced in validation), which would make this
|
||||
# gate flaky. Go is already guarded by its non-gated O(n^2) tripwire (runs in
|
||||
# the main coverage job) plus its golden capture-parity test.
|
||||
benchmarks:
|
||||
name: benchmarks (GITNEXUS_BENCH)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
# persist-credentials: false — this job only runs npm + vitest benchmarks
|
||||
# and never pushes; the default-persisted token in .git/config would be at
|
||||
# risk of leaking through an artifact upload (zizmor credential-persistence
|
||||
# / artipacked audit). Mirrors the packaged-install-smoke job below.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Python scope-capture + import-resolution fingerprint / scaling guards
|
||||
run: |
|
||||
node --import tsx bench/python-scope/measure.mjs --check
|
||||
node --import tsx bench/python-scope/import-target-fingerprint.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language scope-capture fingerprint + scaling guards
|
||||
# Build-free: asserts emit<Lang>ScopeCaptures output is unchanged
|
||||
# (fingerprint) and stays linear (scaling < 1.5) for go/csharp/rust/php/
|
||||
# ruby/cobol. Catches an O(n^2) re-regression without the worker pool.
|
||||
run: node --import tsx bench/scope-capture/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
|
||||
env:
|
||||
GITNEXUS_BENCH: '1'
|
||||
run: >-
|
||||
npx vitest run --no-file-parallelism
|
||||
test/integration/cobol-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-pipeline-benchmark.test.ts
|
||||
test/integration/rust-pipeline-benchmark.test.ts
|
||||
test/integration/php-pipeline-benchmark.test.ts
|
||||
test/integration/ruby-pipeline-benchmark.test.ts
|
||||
working-directory: gitnexus
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: unit (ubuntu / coverage)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: >-
|
||||
npx vitest run test/unit
|
||||
--reporter=default
|
||||
--reporter=json
|
||||
--outputFile=test-results.json
|
||||
--coverage
|
||||
--coverage.reporter=json-summary
|
||||
--coverage.reporter=json
|
||||
--coverage.reporter=text
|
||||
--coverage.thresholdAutoUpdate=false
|
||||
--coverage.reportOnFailure=true
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Coverage report
|
||||
if: always()
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-reports
|
||||
path: |
|
||||
gitnexus/coverage/coverage-summary.json
|
||||
gitnexus/test-results.json
|
||||
retention-days: 5
|
||||
|
||||
cross-platform:
|
||||
name: unit (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- run: npx vitest run test/unit
|
||||
working-directory: gitnexus
|
||||
+174
-97
@@ -1,132 +1,209 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is
|
||||
# invoked as a reusable workflow from publish.yml. In called-workflow context
|
||||
# `github.workflow` evaluation is ambiguous across GitHub Actions versions, and a
|
||||
# prefix that could resolve to the caller's name would share a concurrency group
|
||||
# with the caller → deadlock. A literal prefix is immune. Direct `pull_request`
|
||||
# invocations use `CI-<ref>`; invocations from a reusable-workflow caller fall
|
||||
# into a per-run-unique group that never serializes with the caller. `push` to
|
||||
# main is handled by publish.yml (RC mode), which calls this workflow once
|
||||
# before publishing.
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# ── Reusable workflow orchestration ─────────────────────────────────
|
||||
# Each concern lives in its own workflow file for maintainability:
|
||||
# ci-quality.yml — typecheck (tsc --noEmit)
|
||||
# ci-tests.yml — unit + integration tests with coverage + cross-platform
|
||||
# (includes the scope-resolution resolver tests)
|
||||
# ci-e2e.yml — E2E tests (only when gitnexus-web/ changes)
|
||||
# ci-unit-tests.yml — unit tests with coverage + cross-platform
|
||||
# ci-integration.yml — integration test matrix (3 OS x 4 groups)
|
||||
#
|
||||
# Shared setup is DRY via .github/actions/setup-gitnexus composite action.
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.github/workflows/ci-quality.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
tests:
|
||||
uses: ./.github/workflows/ci-tests.yml
|
||||
permissions:
|
||||
contents: read
|
||||
unit-tests:
|
||||
uses: ./.github/workflows/ci-unit-tests.yml
|
||||
|
||||
e2e:
|
||||
uses: ./.github/workflows/ci-e2e.yml
|
||||
permissions:
|
||||
contents: read
|
||||
integration:
|
||||
uses: ./.github/workflows/ci-integration.yml
|
||||
|
||||
# ── 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
|
||||
# artifact because workflow_run context doesn't reliably carry PR
|
||||
# info for fork PRs.
|
||||
save-pr-meta:
|
||||
name: Save PR Metadata
|
||||
# ── PR test & coverage report ────────────────────────────────────
|
||||
# Downloads coverage artifacts from unit tests and posts a summary
|
||||
# comment on the PR with test results and coverage metrics.
|
||||
pr-report:
|
||||
name: PR Report
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs: [quality, tests, e2e]
|
||||
needs: [quality, unit-tests, integration]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Write metadata
|
||||
shell: bash
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
QUALITY: ${{ needs.quality.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
run: |
|
||||
mkdir -p pr-meta
|
||||
echo "$PR_NUMBER" > pr-meta/pr_number
|
||||
echo "$QUALITY" > pr-meta/quality_result
|
||||
echo "$TESTS" > pr-meta/tests_result
|
||||
echo "$E2E" > pr-meta/e2e_result
|
||||
# TODO(post-merge): remove backward-compat copies once ci-report.yml
|
||||
# on main reads underscore names.
|
||||
# Backward-compat: ci-report.yml on main still reads hyphenated
|
||||
# names. workflow_run always executes from the default branch, so
|
||||
# the main-branch reader won't find the underscore variants until
|
||||
# this PR is merged. Write both until then.
|
||||
cp pr-meta/pr_number pr-meta/pr-number
|
||||
cp pr-meta/quality_result pr-meta/quality-result
|
||||
cp pr-meta/tests_result pr-meta/tests-result
|
||||
cp pr-meta/e2e_result pr-meta/e2e-result
|
||||
|
||||
- name: Upload PR metadata
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- name: Download test reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-meta
|
||||
path: pr-meta/
|
||||
retention-days: 1
|
||||
name: test-reports
|
||||
path: reports
|
||||
continue-on-error: true
|
||||
|
||||
- name: Debug artifact contents
|
||||
run: find reports -type f 2>/dev/null || echo "No reports directory"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Build report
|
||||
id: report
|
||||
run: |
|
||||
# ── Locate coverage file (artifact path may vary) ──
|
||||
COV_FILE=$(find reports -name "coverage-summary.json" -type f 2>/dev/null | head -1)
|
||||
if [ -n "$COV_FILE" ]; then
|
||||
STMTS=$(jq -r '.total.statements.pct' "$COV_FILE")
|
||||
BRANCH=$(jq -r '.total.branches.pct' "$COV_FILE")
|
||||
FUNCS=$(jq -r '.total.functions.pct' "$COV_FILE")
|
||||
LINES=$(jq -r '.total.lines.pct' "$COV_FILE")
|
||||
STMTS_COV=$(jq -r '"\(.total.statements.covered)/\(.total.statements.total)"' "$COV_FILE")
|
||||
BRANCH_COV=$(jq -r '"\(.total.branches.covered)/\(.total.branches.total)"' "$COV_FILE")
|
||||
FUNCS_COV=$(jq -r '"\(.total.functions.covered)/\(.total.functions.total)"' "$COV_FILE")
|
||||
LINES_COV=$(jq -r '"\(.total.lines.covered)/\(.total.lines.total)"' "$COV_FILE")
|
||||
else
|
||||
STMTS="N/A"; BRANCH="N/A"; FUNCS="N/A"; LINES="N/A"
|
||||
STMTS_COV=""; BRANCH_COV=""; FUNCS_COV=""; LINES_COV=""
|
||||
fi
|
||||
|
||||
# ── Locate test results ──
|
||||
RESULTS_FILE=$(find reports -name "test-results.json" -type f 2>/dev/null | head -1)
|
||||
if [ -n "$RESULTS_FILE" ]; then
|
||||
TOTAL=$(jq -r '.numTotalTests' "$RESULTS_FILE")
|
||||
PASSED=$(jq -r '.numPassedTests' "$RESULTS_FILE")
|
||||
FAILED=$(jq -r '.numFailedTests' "$RESULTS_FILE")
|
||||
SKIPPED=$(jq -r '.numPendingTests' "$RESULTS_FILE")
|
||||
SUITES=$(jq -r '.numTotalTestSuites' "$RESULTS_FILE")
|
||||
DURATION=$(jq -r '((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor' "$RESULTS_FILE" 2>/dev/null || echo "N/A")
|
||||
else
|
||||
TOTAL="N/A"; PASSED="N/A"; FAILED="N/A"; SKIPPED="N/A"
|
||||
SUITES="N/A"; DURATION="N/A"
|
||||
fi
|
||||
|
||||
# ── Coverage thresholds (from vitest.config.ts P0 settings) ──
|
||||
THRESH_STMTS=26; THRESH_BRANCH=23; THRESH_FUNCS=28; THRESH_LINES=27
|
||||
|
||||
# ── Status helpers ──
|
||||
status_icon() {
|
||||
case "$1" in
|
||||
success) echo "✅" ;;
|
||||
failure) echo "❌" ;;
|
||||
cancelled) echo "⏭️" ;;
|
||||
*) echo "❓" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cov_bar() {
|
||||
local pct=$1 thresh=$2
|
||||
if [ "$pct" = "N/A" ]; then echo "—"; return; fi
|
||||
local filled=$(echo "$pct / 5" | bc 2>/dev/null || echo 0)
|
||||
local empty=$((20 - filled))
|
||||
local bar=""
|
||||
for ((i=0; i<filled; i++)); do bar+="█"; done
|
||||
for ((i=0; i<empty; i++)); do bar+="░"; done
|
||||
if [ "$(echo "$pct >= $thresh" | bc 2>/dev/null)" = "1" ]; then
|
||||
echo "🟢 ${bar}"
|
||||
else
|
||||
echo "🔴 ${bar}"
|
||||
fi
|
||||
}
|
||||
|
||||
QUALITY="${{ needs.quality.result }}"
|
||||
UNIT="${{ needs.unit-tests.result }}"
|
||||
INTEG="${{ needs.integration.result }}"
|
||||
|
||||
# ── Overall status ──
|
||||
if [[ "$QUALITY" == "success" && "$UNIT" == "success" && "$INTEG" == "success" ]]; then
|
||||
OVERALL="✅ **All checks passed**"
|
||||
else
|
||||
OVERALL="❌ **Some checks failed**"
|
||||
fi
|
||||
|
||||
# ── Build markdown ──
|
||||
{
|
||||
echo "body<<REPORT_EOF"
|
||||
echo "## CI Report"
|
||||
echo ""
|
||||
echo "${OVERALL}"
|
||||
echo ""
|
||||
echo "### Pipeline Status"
|
||||
echo ""
|
||||
echo "| Stage | Status | Details |"
|
||||
echo "|-------|--------|---------|"
|
||||
echo "| $(status_icon "$QUALITY") Typecheck | \`${QUALITY}\` | tsc --noEmit |"
|
||||
echo "| $(status_icon "$UNIT") Unit Tests | \`${UNIT}\` | 3 platforms |"
|
||||
echo "| $(status_icon "$INTEG") Integration | \`${INTEG}\` | 3 OS × 4 groups = 12 jobs |"
|
||||
echo ""
|
||||
|
||||
if [ "$TOTAL" != "N/A" ]; then
|
||||
echo "### Test Results"
|
||||
echo ""
|
||||
if [ "$FAILED" = "0" ]; then
|
||||
echo "✅ **${PASSED}** passed"
|
||||
else
|
||||
echo "❌ **${FAILED}** failed / **${PASSED}** passed"
|
||||
fi
|
||||
if [ "$SKIPPED" != "0" ]; then
|
||||
echo " · ${SKIPPED} skipped"
|
||||
fi
|
||||
echo " · ${SUITES} suites · ${TOTAL} total"
|
||||
if [ "$DURATION" != "N/A" ]; then
|
||||
echo " · ⏱️ ${DURATION}s"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "$STMTS" != "N/A" ]; then
|
||||
echo "### Code Coverage"
|
||||
echo ""
|
||||
echo "| Metric | Coverage | Covered | Threshold | Status |"
|
||||
echo "|--------|----------|---------|-----------|--------|"
|
||||
echo "| Statements | **${STMTS}%** | ${STMTS_COV} | ${THRESH_STMTS}% | $(cov_bar "$STMTS" "$THRESH_STMTS") |"
|
||||
echo "| Branches | **${BRANCH}%** | ${BRANCH_COV} | ${THRESH_BRANCH}% | $(cov_bar "$BRANCH" "$THRESH_BRANCH") |"
|
||||
echo "| Functions | **${FUNCS}%** | ${FUNCS_COV} | ${THRESH_FUNCS}% | $(cov_bar "$FUNCS" "$THRESH_FUNCS") |"
|
||||
echo "| Lines | **${LINES}%** | ${LINES_COV} | ${THRESH_LINES}% | $(cov_bar "$LINES" "$THRESH_LINES") |"
|
||||
echo ""
|
||||
echo "<details>"
|
||||
echo "<summary>Coverage thresholds are auto-ratcheted — they only go up</summary>"
|
||||
echo ""
|
||||
echo "Vitest \`thresholds.autoUpdate\` bumps the floor whenever local coverage exceeds it."
|
||||
echo "CI enforces the current thresholds; developers commit the ratcheted values."
|
||||
echo "</details>"
|
||||
echo ""
|
||||
else
|
||||
echo "### Code Coverage"
|
||||
echo ""
|
||||
echo "⚠️ Coverage data unavailable — check the [unit test job](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "<sub>📋 [View full run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · Generated by CI</sub>"
|
||||
echo "REPORT_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on PR
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: ci-report
|
||||
message: ${{ steps.report.outputs.body }}
|
||||
|
||||
# ── Unified CI gate ──────────────────────────────────────────────
|
||||
# Single required check for branch protection.
|
||||
ci-status:
|
||||
name: CI Gate
|
||||
needs: [quality, tests, e2e]
|
||||
needs: [quality, unit-tests, integration]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check all jobs passed
|
||||
shell: bash
|
||||
env:
|
||||
QUALITY: ${{ needs.quality.result }}
|
||||
# The tree-sitter ABI gate (#1922) runs as the `abi-assert` job
|
||||
# inside the `tests` reusable workflow. A failed job fails the
|
||||
# reusable workflow, so `needs.tests.result` below blocks the merge
|
||||
# on an ABI mismatch. (`jobs.<id>.result` cannot be exposed as a
|
||||
# workflow_call output, so the gate is enforced transitively here.)
|
||||
# The scope-resolution resolver tests also run inside the `tests`
|
||||
# workflow (RING4-1 #942 removed the separate scope-parity gate),
|
||||
# so a resolver regression makes TESTS != success and blocks here.
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
run: |
|
||||
echo "Quality: $QUALITY"
|
||||
echo "Tests: $TESTS"
|
||||
echo "E2E: $E2E"
|
||||
# A failed `abi-assert` job (#1922) inside the tests reusable
|
||||
# workflow makes TESTS != success, so this clause also blocks the
|
||||
# merge on a tree-sitter ABI mismatch.
|
||||
if [[ "$QUALITY" != "success" ]] ||
|
||||
[[ "$TESTS" != "success" ]]; then
|
||||
echo "::error::Quality or test jobs failed (includes the tree-sitter ABI gate, #1922)"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$E2E" != "success" && "$E2E" != "skipped" ]]; then
|
||||
echo "::error::E2E job failed"
|
||||
echo "Quality: ${{ needs.quality.result }}"
|
||||
echo "Unit Tests: ${{ needs.unit-tests.result }}"
|
||||
echo "Integration: ${{ needs.integration.result }}"
|
||||
if [[ "${{ needs.quality.result }}" != "success" ]] ||
|
||||
[[ "${{ needs.unit-tests.result }}" != "success" ]] ||
|
||||
[[ "${{ needs.integration.result }}" != "success" ]]; then
|
||||
echo "::error::One or more CI jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
+17
-132
@@ -1,17 +1,8 @@
|
||||
name: Claude Code
|
||||
|
||||
# Label-triggered code-review requests use pull_request_target so the workflow
|
||||
# runs as defined on the default branch, which allows access to secrets for
|
||||
# posting review comments on fork PRs. SECURITY: PR checkouts pin the fork's
|
||||
# HEAD SHA (not the branch name) to prevent TOCTOU races.
|
||||
# The claude-code-action sandboxes execution; it does not run arbitrary code
|
||||
# from the checked-out source.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
@@ -19,147 +10,41 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Serialize per-PR/issue to avoid racing comments.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
contains(github.event.comment.body, '@claude') ||
|
||||
(github.event.issue.pull_request && contains(github.event.comment.body, '/review'))
|
||||
) &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@claude') &&
|
||||
(github.event.review.author_association == 'OWNER' ||
|
||||
github.event.review.author_association == 'MEMBER' ||
|
||||
github.event.review.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issues' &&
|
||||
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
|
||||
(github.event.issue.author_association == 'OWNER' ||
|
||||
github.event.issue.author_association == 'MEMBER' ||
|
||||
github.event.issue.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.label.name == 'claude-review' &&
|
||||
github.event.pull_request.draft == false &&
|
||||
(github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR')
|
||||
)
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # required for Claude to read CI results on PRs
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
# For PR-related triggers, resolve the fork repo so we can checkout correctly.
|
||||
- name: Resolve PR context
|
||||
id: pr
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
// Determine if this event is PR-related
|
||||
let pr = null;
|
||||
if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
|
||||
const resp = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.issue.number,
|
||||
});
|
||||
pr = resp.data;
|
||||
} else if (context.eventName === 'pull_request_review_comment') {
|
||||
pr = context.payload.pull_request;
|
||||
} else if (context.eventName === 'pull_request_review') {
|
||||
pr = context.payload.pull_request;
|
||||
} else if (context.eventName === 'pull_request_target') {
|
||||
pr = context.payload.pull_request;
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.setOutput('is_pr', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('is_pr', 'true');
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
core.setOutput('repo', pr.head.repo.full_name);
|
||||
core.setOutput('branch', pr.head.ref);
|
||||
|
||||
- name: Resolve Claude mode
|
||||
id: mode
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
with:
|
||||
script: |
|
||||
const body = (context.payload.comment?.body ?? '').toLowerCase();
|
||||
const isCodeReview =
|
||||
(context.eventName === 'pull_request_target' &&
|
||||
context.payload.label?.name === 'claude-review') ||
|
||||
(context.eventName === 'issue_comment' &&
|
||||
Boolean(context.payload.issue?.pull_request) &&
|
||||
body.includes('/review'));
|
||||
|
||||
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
|
||||
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
if: steps.mode.outputs.code_review != 'true'
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: steps.mode.outputs.code_review == 'true'
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
# Review posts use Bash (`gh`, etc.); default mode asks for approval — impossible in CI.
|
||||
claude_args: '--dangerously-skip-permissions'
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review https://github.com/${{ github.repository }}/pull/${{ steps.pr.outputs.number }} --comment'
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
name: CodeQL
|
||||
|
||||
# Static analysis (SAST) for TypeScript/JavaScript and Python sources.
|
||||
# Findings upload to the GitHub Security tab as SARIF.
|
||||
#
|
||||
# Advisory only on first introduction — see docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md.
|
||||
# Promote to a required check after baseline triage (operator decision).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly Monday 06:00 UTC — catches advisories newly published against
|
||||
# already-merged code without waiting for the next PR.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
# security-events:write is what enables SARIF upload to the Security tab.
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript-typescript, python]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
# Exclude generated/vendored code; tune after first-run signal.
|
||||
# gitnexus/vendor/ holds tree-sitter-proto sources (regenerated, not authored).
|
||||
# CodeQL path filters use .gitignore-style globs and do NOT support
|
||||
# brace expansion — list each generated parser file separately.
|
||||
config: |
|
||||
paths-ignore:
|
||||
- '**/dist/**'
|
||||
- '**/node_modules/**'
|
||||
- 'gitnexus/vendor/**'
|
||||
- 'gitnexus/src/core/parsing/**/parser.c'
|
||||
- 'gitnexus/src/core/parsing/**/parser.js'
|
||||
# Test fixtures are intentionally synthetic inputs (broken/unused
|
||||
# code, malformed samples) used to exercise the analyzer. CodeQL
|
||||
# findings here are noise, not real bugs.
|
||||
- '**/test/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Dependency Review
|
||||
|
||||
# Blocks PRs that introduce dependencies with high/critical known vulnerabilities.
|
||||
# Reads the dependency graph diff between PR head and base.
|
||||
#
|
||||
# This is a required-check candidate after one week of clean runs
|
||||
# (operator decision — see docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
# pull-requests:write enables the inline summary comment on failure.
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
fail-on-severity: high
|
||||
comment-summary-in-pr: on-failure
|
||||
@@ -1,271 +0,0 @@
|
||||
name: Docker Build & Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
# workflow_dispatch is allowed for dry-run testing only. Publishing is still
|
||||
# exclusively tag-driven so that every signed image corresponds 1:1 to a
|
||||
# published `gitnexus@X.Y.Z` on npm. dry_run:true (the default) skips all
|
||||
# push, sign, and attestation steps — the build runs but nothing is published.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Build only — skip push, signing, and attestations'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: >-
|
||||
The full v-prefixed tag to build (e.g. v1.2.3-rc.1).
|
||||
The tag must already exist in the repo and its tree must contain
|
||||
a gitnexus/package.json whose version matches the tag.
|
||||
required: true
|
||||
type: string
|
||||
# Explicit secret contract — callers pass these by name. Replaces the
|
||||
# blanket `secrets: inherit` pattern (zizmor `secrets-inherit` audit).
|
||||
# GHCR auth uses the implicit GITHUB_TOKEN; only Docker Hub credentials
|
||||
# need to be passed through.
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME:
|
||||
required: true
|
||||
DOCKERHUB_TOKEN:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Tag refs are unique per release, so distinct tags run in parallel.
|
||||
# Re-pushes of the same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight.
|
||||
# Hardcoded `docker-build-push-` prefix (not `${{ github.workflow }}`) when invoked as a reusable
|
||||
# workflow: in called-workflow context `github.workflow` is ambiguous and could resolve to the
|
||||
# caller's name, sharing a concurrency group with the caller → deadlock.
|
||||
# Direct tag-push invocations use `docker-build-push-<ref>`; workflow_call invocations get a
|
||||
# per-run-unique group (they are already serialized by the caller's own concurrency group).
|
||||
concurrency:
|
||||
group: ${{ (github.event_name == 'push') && format('docker-build-push-{0}', github.ref) || format('docker-build-push-nested-{0}', github.run_id) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
name: Build & Push ${{ matrix.image.name }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# Required for Cosign keyless signing via the OIDC token exchange,
|
||||
# and for build provenance / SBOM attestations.
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
image:
|
||||
# Static UI bundle. Small, fast image. Drop-in replacement for the
|
||||
# legacy single-image setup at the same `gitnexus` repository slug
|
||||
# is intentionally avoided — the UI now lives at `gitnexus-web` and
|
||||
# the CLI/server takes the canonical `gitnexus` slug below.
|
||||
- name: gitnexus-web
|
||||
dockerfile: Dockerfile.web
|
||||
slug: gitnexus-web
|
||||
# CLI / `gitnexus serve` backend. Heavy native deps (tree-sitter,
|
||||
# onnxruntime-node) live only in this image.
|
||||
- name: gitnexus
|
||||
dockerfile: Dockerfile.cli
|
||||
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.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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"
|
||||
|
||||
# Required for multi-platform (linux/arm64) emulation.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Docker Hub is a mirror of GHCR: same tags, same digests, same Cosign
|
||||
# signatures. GHCR remains authoritative (it is the registry the
|
||||
# ClusterImagePolicy globs against by default), but Docker Hub is the
|
||||
# registry most users reach for first, so we publish there too.
|
||||
# Requires repo secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (a scoped
|
||||
# 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 }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Computes image tags and labels from the verified semver tag:
|
||||
# v1.2.3 → :1.2.3, :1.2, :1, :latest (auto, only for non-prerelease)
|
||||
# v1.2.3-rc.1 → :1.2.3-rc.1 only (prereleases never become :latest)
|
||||
# `:latest` is only emitted for tag pushes thanks to `flavor: latest=auto`,
|
||||
# ensuring it always points at a real npm-published version.
|
||||
#
|
||||
# For workflow_call invocations github.ref is the caller's branch ref, so
|
||||
# the type=semver patterns would not match. In that case we add an explicit
|
||||
# type=raw tag using the version already verified above, so the same
|
||||
# image-naming rules apply regardless of how the workflow was triggered.
|
||||
# NOTE: We check `inputs.tag` rather than `github.event_name` because in a
|
||||
# reusable workflow the github context is inherited from the caller —
|
||||
# `github.event_name` would still be "push", not "workflow_call".
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
# Dual-registry publish. metadata-action expands the same tag set
|
||||
# against every image ref listed here, and build-push-action pushes
|
||||
# one build to all of them, so the GHCR and Docker Hub images share
|
||||
# a digest and are byte-identical. The Docker Hub namespace
|
||||
# (`akonlabs`) is hardcoded because it differs from the GitHub org
|
||||
# (`abhigyanpatwari`) — `github.repository_owner` would produce the
|
||||
# wrong ref.
|
||||
images: |
|
||||
ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
|
||||
docker.io/akonlabs/${{ matrix.image.slug }}
|
||||
flavor: latest=auto
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=${{ steps.version.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`
|
||||
# (see docker/build-push-action#1422 — retry policy stays out of the
|
||||
# upstream action). `ignore-error=true` on cache-to avoids cache export
|
||||
# flakes failing an otherwise successful push.
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: ./.github/actions/docker-build-push-retry
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.image.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ matrix.image.slug }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image.slug }},ignore-error=true
|
||||
|
||||
# Cosign keyless signing. Each pushed tag is signed by the workflow's
|
||||
# OIDC identity, so consumers can verify the image with the strict,
|
||||
# fully-anchored identity regex (kept in sync with README.md and
|
||||
# deploy/kubernetes/cluster-image-policy.yaml — update all three together).
|
||||
# NOTE: `${...}` expression syntax is NOT evaluated inside YAML comments, so
|
||||
# the example below uses literal `<owner>/<repo>` placeholders that consumers
|
||||
# substitute themselves; the canonical, fully-rendered command lives in README.md.
|
||||
# cosign verify ghcr.io/<owner>/<slug>:<tag> \
|
||||
# --certificate-identity-regexp '^https://github\.com/<owner>/<repo>/\.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
|
||||
# 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 }}
|
||||
env:
|
||||
# Cosign v2 (installed by sigstore/cosign-installer above) makes
|
||||
# keyless the default. COSIGN_EXPERIMENTAL is a v1-only opt-in flag
|
||||
# that is now deprecated/no-op, so it is intentionally omitted.
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
# Sign every tag at the same digest so consumers can verify by tag or by digest.
|
||||
# Use `while read` instead of `for $TAGS` to be robust against tags that
|
||||
# could ever contain whitespace (the metadata-action output is newline-
|
||||
# separated, not space-separated).
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && cosign sign --yes "${tag}@${DIGEST}"
|
||||
done <<< "$TAGS"
|
||||
|
||||
# Attach the SBOM produced by buildx as a verifiable attestation on the
|
||||
# digest. Attestations are pushed as OCI referrers to the registry named
|
||||
# in `subject-name`, so we call the action once per registry. The digest
|
||||
# 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 }}
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Generate build provenance attestation (Docker Hub)
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-name: docker.io/akonlabs/${{ matrix.image.slug }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -1,60 +0,0 @@
|
||||
name: Gitleaks
|
||||
|
||||
# Deterministic in-CI secret scanning. Defense-in-depth on top of GitHub's
|
||||
# native secret-scanning push protection (which is a repo Settings toggle —
|
||||
# see SECURITY.md for the recommended admin action).
|
||||
#
|
||||
# PR runs scan the diff (fast); main pushes scan full history.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Full history needed for the on-push full-history scan; on PRs the
|
||||
# action diffs against the base ref so the cost is bounded by the PR.
|
||||
fetch-depth: 0
|
||||
# Don't bake the token into the cloned .git/config; downstream
|
||||
# steps (and Gitleaks itself) don't need it for repo operations.
|
||||
persist-credentials: false
|
||||
|
||||
# gitleaks-action builds `base^..head` for pull_request events; both SHAs
|
||||
# must exist locally (fork PRs and shallow checkouts otherwise fail with
|
||||
# "unknown revision" — see gitleaks/gitleaks-action#199).
|
||||
- name: Fetch PR refs for gitleaks range
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$BASE_SHA"
|
||||
git fetch --no-tags origin "$HEAD_SHA"
|
||||
|
||||
# No GITLEAKS_LICENSE secret is required for OSS / public-repo usage.
|
||||
# If this repo becomes private, the action will require a license key.
|
||||
- name: Gitleaks
|
||||
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true
|
||||
GITLEAKS_ENABLE_SUMMARY: true
|
||||
@@ -1,595 +0,0 @@
|
||||
name: PR Autofix (apply)
|
||||
|
||||
# CHATOPS HALF of the autofix pipeline.
|
||||
#
|
||||
# Triggered when a contributor comments `/autofix` on a PR. Validates
|
||||
# permission, locates the most recent successful `pr-autofix.yml`
|
||||
# artifact for the PR's current head SHA, applies the patch to the PR
|
||||
# head, and pushes a commit back to the PR branch.
|
||||
#
|
||||
# This workflow runs from the default branch's copy of the file
|
||||
# regardless of where the comment originates -- that's the trust
|
||||
# anchor. Comment body and author login are untrusted; both flow
|
||||
# through env vars and pattern-matched, never interpolated into shell.
|
||||
#
|
||||
# Fork PR support: `git push` with the GITHUB_TOKEN succeeds against
|
||||
# fork branches only when the contributor enabled "Allow edits by
|
||||
# maintainers" on the PR (the default). When they disabled it, we
|
||||
# fail loud with a 👎 reaction and an explanation comment.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
# Per-PR scope. issue_comment events expose `github.event.issue.number`
|
||||
# for both PR and Issue comments; the `pull_request != null` guard on
|
||||
# the job ensures we only run on PRs, so this number is the PR number.
|
||||
# cancel-in-progress: false — a second `/autofix` should wait for the
|
||||
# first to finish (idempotency check on the second invocation handles
|
||||
# the no-op case).
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
name: apply-autofix
|
||||
# Pre-filter at the workflow level so non-PR comments and unrelated
|
||||
# comments don't even spawn a runner. The job-level body re-check
|
||||
# below (Step 1) is the strict gate.
|
||||
if: >-
|
||||
github.event.issue.pull_request != null
|
||||
&& startsWith(github.event.comment.body, '/autofix')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
# React on the triggering comment + post reply comments.
|
||||
pull-requests: write
|
||||
# Push the apply commit to the PR head branch.
|
||||
contents: write
|
||||
# Required by actions/download-artifact to fetch artifacts produced
|
||||
# by a different workflow run.
|
||||
actions: read
|
||||
steps:
|
||||
- name: Validate comment body precisely
|
||||
id: body
|
||||
env:
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Whole-line, case-sensitive match: `^/autofix\s*$`. The
|
||||
# workflow-level startsWith guard is coarse — `please don't
|
||||
# /autofix this code` would pass that filter but fail this one.
|
||||
# We exit silently (no reaction) on body mismatch so quoted
|
||||
# text in unrelated discussions doesn't get a visible response.
|
||||
if [[ ! "${BODY}" =~ ^/autofix[[:space:]]*$ ]]; then
|
||||
echo "Body did not match strict /autofix regex — exiting silently."
|
||||
echo "match=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "match=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate commenter permission
|
||||
id: perm
|
||||
if: steps.body.outputs.match == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
PR_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Retry wrapper for transient 5xx / 429 / network blips.
|
||||
# Mirrors the helper in pr-autofix-publish.yml. Used on
|
||||
# idempotent GETs only; reactions/comment-POSTs are NOT
|
||||
# wrapped (retrying a POST would dupe the resource).
|
||||
gh_retry() {
|
||||
local n=0 max=3
|
||||
while true; do
|
||||
if gh "$@"; then return 0; fi
|
||||
n=$((n+1))
|
||||
if [ "$n" -ge "$max" ]; then return 1; fi
|
||||
sleep $((n * 2))
|
||||
done
|
||||
}
|
||||
|
||||
# Allowlist the commenter login before it flows into a URL.
|
||||
# GitHub usernames: alphanumeric + dashes, max 39 chars.
|
||||
if ! [[ "${COMMENTER}" =~ ^[A-Za-z0-9-]{1,39}$ ]]; then
|
||||
echo "::error::Invalid commenter login format: $(printf '%q' "${COMMENTER}")"
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Self-comparison: PR author can always /autofix their own PR.
|
||||
if [ "${COMMENTER}" = "${PR_AUTHOR}" ]; then
|
||||
echo "Commenter is PR author — granting access."
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Repo permission lookup. admin/write/maintain are sufficient.
|
||||
# Distinguish API failure (5xx, 429, network) from genuine
|
||||
# permission denial (404 = not a collaborator). Conflating them
|
||||
# would silently refuse a legitimate maintainer with a public
|
||||
# 👎 every time GitHub blips. gh_retry handles transient blips;
|
||||
# the stderr-grep distinguishes 404 from persistent failure.
|
||||
perm_stderr=$(mktemp)
|
||||
if permission=$(gh_retry api "repos/${GH_REPO}/collaborators/${COMMENTER}/permission" \
|
||||
--jq '.permission' 2>"$perm_stderr"); then
|
||||
echo "Commenter permission: ${permission}"
|
||||
case "${permission}" in
|
||||
admin|write|maintain)
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
err=$(cat "$perm_stderr")
|
||||
echo "Permission lookup stderr: ${err}" >&2
|
||||
# 404 (not a collaborator) is a genuine deny.
|
||||
# Anything else is a transient API/network failure.
|
||||
if grep -qE "HTTP 404|Not Found" "$perm_stderr"; then
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Permission lookup failed transiently — refusing to act."
|
||||
echo "allowed=api-failed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: React 😕 on transient permission-API failure
|
||||
if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'api-failed'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ Couldn't verify your repo permission (transient GitHub API failure). Please comment \`/autofix\` again. ([apply run](https://github.com/${GH_REPO}/actions/runs/${RUN_ID}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
|
||||
- name: React 👎 on permission denial
|
||||
if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="🚫 \`/autofix\` is restricted to users with write access or the PR author. Comment ignored." \
|
||||
>/dev/null
|
||||
# Hard exit so the rest of the job is skipped.
|
||||
exit 1
|
||||
|
||||
- name: React 👀 to acknowledge
|
||||
if: steps.perm.outputs.allowed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="eyes" >/dev/null
|
||||
|
||||
- name: Resolve PR head and locate autofix run
|
||||
id: locate
|
||||
if: steps.perm.outputs.allowed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Same retry wrapper used in the permission step, repeated
|
||||
# because each YAML `run:` block is a fresh bash session.
|
||||
gh_retry() {
|
||||
local n=0 max=3
|
||||
while true; do
|
||||
if gh "$@"; then return 0; fi
|
||||
n=$((n+1))
|
||||
if [ "$n" -ge "$max" ]; then return 1; fi
|
||||
sleep $((n * 2))
|
||||
done
|
||||
}
|
||||
|
||||
# Fetch PR metadata. All fields here are server-controlled API
|
||||
# output, but we still allowlist before exporting so anything
|
||||
# weird short-circuits before $GITHUB_OUTPUT. Wrapped in
|
||||
# gh_retry so transient blips don't surface as "no autofix run
|
||||
# found" with a wrong remediation.
|
||||
if ! pr_json=$(gh_retry api "repos/${GH_REPO}/pulls/${PR}"); then
|
||||
echo "::error::PR metadata fetch failed after retries."
|
||||
echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
head_sha=$(jq -r '.head.sha' <<< "${pr_json}")
|
||||
head_ref=$(jq -r '.head.ref' <<< "${pr_json}")
|
||||
head_repo=$(jq -r '.head.repo.full_name' <<< "${pr_json}")
|
||||
|
||||
[[ "${head_sha}" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Bad head_sha"; exit 1; }
|
||||
[[ "${head_ref}" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "::error::Bad head_ref"; exit 1; }
|
||||
[[ "${head_repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "::error::Bad head_repo"; exit 1; }
|
||||
|
||||
# Find the latest successful pr-autofix.yml run for this head SHA.
|
||||
if ! runs_json=$(gh_retry api "repos/${GH_REPO}/actions/workflows/pr-autofix.yml/runs?head_sha=${head_sha}&per_page=10"); then
|
||||
echo "::error::Workflow run lookup failed after retries."
|
||||
echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_id=$(jq -r '[.workflow_runs[] | select(.conclusion == "success")] | .[0].id // empty' <<< "${runs_json}")
|
||||
|
||||
if [ -n "${run_id}" ] && [[ "${run_id}" =~ ^[0-9]+$ ]]; then
|
||||
echo "found_status=success" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "found=true"
|
||||
echo "head_sha=${head_sha}"
|
||||
echo "head_ref=${head_ref}"
|
||||
echo "head_repo=${head_repo}"
|
||||
echo "run_id=${run_id}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No successful run. Distinguish "still running" (producer in
|
||||
# flight after a recent push) from "never ran / all failed".
|
||||
# in_progress / queued / pending / waiting cover the GitHub
|
||||
# workflow-run lifecycle states that precede success/failure.
|
||||
in_progress=$(jq -r '[.workflow_runs[] | select(.status == "in_progress" or .status == "queued" or .status == "pending" or .status == "waiting")] | length' <<< "${runs_json}")
|
||||
if [ "${in_progress:-0}" -gt 0 ]; then
|
||||
echo "::warning::pr-autofix run is still in progress for head ${head_sha}."
|
||||
echo "found_status=in-progress" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::No successful pr-autofix run found for head ${head_sha}."
|
||||
echo "found_status=not-found" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# Existing `found` boolean is preserved so downstream gates
|
||||
# (`steps.locate.outputs.found == 'true'`) still work.
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reply when locate did not yield a usable run
|
||||
if: steps.perm.outputs.allowed == 'true' && steps.locate.outputs.found != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
FOUND_STATUS: ${{ steps.locate.outputs.found_status }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
|
||||
case "${FOUND_STATUS}" in
|
||||
in-progress)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⏳ A pr-autofix run is still in progress for this PR's current head SHA. Wait for it to finish, then comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
;;
|
||||
api-failed)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ Couldn't reach the GitHub API to look up the autofix run (transient failure after retries). Please comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
;;
|
||||
*)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="🤔 No successful autofix run found for this PR's current head SHA. Push a new commit to trigger one, then comment \`/autofix\` again." \
|
||||
>/dev/null
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
|
||||
# Pinned to v8.0.1. Same SHA as pr-autofix-publish.yml.
|
||||
# `continue-on-error: true` lets the workflow proceed when the
|
||||
# artifact is expired or pruned (1-day retention). The apply
|
||||
# step distinguishes "patch file missing entirely" (artifact-
|
||||
# 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
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: autofix
|
||||
run-id: ${{ steps.locate.outputs.run_id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: autofix-in
|
||||
|
||||
# Pinned to v5.0.4. Verify SHA via:
|
||||
# gh api repos/actions/checkout/git/refs/tags/v5.0.4
|
||||
#
|
||||
# `persist-credentials: false` disables the default behavior where
|
||||
# actions/checkout writes the GITHUB_TOKEN into `.git/config` as an
|
||||
# extraheader. That default is convenient (subsequent git commands
|
||||
# auth automatically) but it means the token is sitting on disk in
|
||||
# the checkout directory — an `actions/upload-artifact` step on
|
||||
# this directory would leak the token. We don't upload, but
|
||||
# zizmor's `credential-persistence` lint flags it defensively.
|
||||
# Push auth is provided inline at push time via the URL.
|
||||
- name: Checkout PR head
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4
|
||||
with:
|
||||
repository: ${{ steps.locate.outputs.head_repo }}
|
||||
ref: ${{ steps.locate.outputs.head_sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
persist-credentials: false
|
||||
# Fetch full history so the push doesn't hit shallow-clone errors.
|
||||
fetch-depth: 0
|
||||
path: pr-checkout
|
||||
|
||||
- name: Apply patch and push
|
||||
id: apply
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
env:
|
||||
HEAD_REF: ${{ steps.locate.outputs.head_ref }}
|
||||
HEAD_REPO: ${{ steps.locate.outputs.head_repo }}
|
||||
# The SHA we resolved earlier in `locate` — this is what the
|
||||
# remote ref MUST still equal at push time. If the contributor
|
||||
# force-pushed between resolve and now, the lease fails and
|
||||
# we surface that distinctly from a fork-without-maintainer
|
||||
# -edit push failure.
|
||||
HEAD_SHA: ${{ steps.locate.outputs.head_sha }}
|
||||
# Auth for the push only — never persisted to disk. Provided
|
||||
# via env to avoid interpolating into the shell command line.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
working-directory: pr-checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
patch="../autofix-in/autofix.patch"
|
||||
|
||||
# Distinguish artifact-expired (file missing entirely, because
|
||||
# actions/download-artifact ran with continue-on-error and the
|
||||
# 1-day retention had elapsed) from genuinely empty patch
|
||||
# (file present, zero bytes, formatter found nothing).
|
||||
if [ ! -e "$patch" ]; then
|
||||
echo "::warning::Patch file does not exist — autofix artifact likely expired."
|
||||
echo "result=artifact-expired" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -s "$patch" ]; then
|
||||
echo "::warning::Empty patch — nothing to apply."
|
||||
echo "result=empty-patch" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Sensitive-paths guard: refuse to apply patches that touch
|
||||
# `.github/` — workflow files, action definitions, CODEOWNERS,
|
||||
# dependabot config, etc. A malicious PR could ship a custom
|
||||
# prettier/ESLint config that reformats workflow YAML; the
|
||||
# producer would then capture those edits in autofix.patch,
|
||||
# and a maintainer running `/autofix` would push them under
|
||||
# `contents: write`. The default GITHUB_TOKEN lacks `workflows`
|
||||
# scope so the platform would reject workflow-file pushes
|
||||
# anyway, but that surfaces as a generic `push-failed` and
|
||||
# misleads users into enabling maintainer-edit. Reject early
|
||||
# with a specific reason. CODEOWNERS and dependabot.yml live
|
||||
# under .github/ but outside .github/workflows/ — the broader
|
||||
# match is intentional (they all govern trust boundaries).
|
||||
if grep -qE '^(diff --git|---|\+\+\+) [ab]?/?\.github/' "$patch"; then
|
||||
echo "::warning::Patch touches .github/ — refusing to apply (sensitive paths)."
|
||||
echo "result=sensitive-paths" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Re-entrancy guard: if HEAD itself is an autofix bot commit,
|
||||
# refuse to apply again. Without this, lint/formatter config
|
||||
# drift between runs could pump arbitrary apply commits into
|
||||
# the same PR if an automated agent watches the sticky and
|
||||
# re-fires `/autofix` on each new "fixes-available" surface.
|
||||
# The contributor can still get out by force-pushing a
|
||||
# human-authored commit to revert the autofix and re-trigger.
|
||||
head_author=$(git log -1 --format='%ae' HEAD)
|
||||
head_subject=$(git log -1 --format='%s' HEAD)
|
||||
if [ "${head_author}" = "41898282+github-actions[bot]@users.noreply.github.com" ] \
|
||||
&& [[ "${head_subject}" =~ ^chore\(autofix\) ]]; then
|
||||
echo "::warning::HEAD is an autofix bot commit — refusing to re-apply (loop guard)."
|
||||
echo "result=loop-prevented" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Idempotency probe: does the forward apply work?
|
||||
if git apply --check "$patch" 2>/dev/null; then
|
||||
echo "Patch applies cleanly — proceeding."
|
||||
elif git apply --check --reverse "$patch" 2>/dev/null; then
|
||||
# Reverse-check passes => the patch is already applied to
|
||||
# the current tree. Treat as success no-op.
|
||||
echo "Patch is already applied (reverse-check passed) — no-op."
|
||||
echo "result=already-applied" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
else
|
||||
echo "::error::Patch does not apply (stale or conflicting)."
|
||||
echo "result=stale" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Wrap the apply/commit phase so any non-zero exit sets a
|
||||
# meaningful `result=` instead of leaving it unset (which would
|
||||
# send the user to the `*` "unexpected state" arm with a
|
||||
# non-actionable confused-emoji reply).
|
||||
if ! {
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" &&
|
||||
git config user.name "github-actions[bot]" &&
|
||||
git apply "$patch" &&
|
||||
git add -A &&
|
||||
git commit -m "chore(autofix): apply prettier + eslint fixes via /autofix command"
|
||||
}; then
|
||||
echo "::error::git apply / config / commit failed after idempotency probe passed."
|
||||
echo "result=apply-failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Push to the PR head branch with a lease against the resolved
|
||||
# SHA. The lease ensures the remote ref still points at HEAD_SHA
|
||||
# when the push lands — if the contributor force-pushed in the
|
||||
# window between resolve and now, the lease fails and we return
|
||||
# `lease-failed` (NOT `push-failed`, which would mislead users
|
||||
# into enabling maintainer-edit). For fork PRs, the push still
|
||||
# requires "Allow edits by maintainers" to be enabled.
|
||||
#
|
||||
# Auth is supplied inline via `-c http.<base>.extraheader` (NOT
|
||||
# via a `https://x-access-token:TOKEN@…` URL — those leak into
|
||||
# process listings and `git remote -v` output). The header is
|
||||
# set per-invocation; it never lands in `.git/config` on disk.
|
||||
# The token is base64-encoded for the Basic auth header per
|
||||
# GitHub's documented pattern for this scope.
|
||||
push_url="https://github.com/${HEAD_REPO}.git"
|
||||
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w0)"
|
||||
# GitHub's secret-masker only masks the raw token, not its
|
||||
# base64-encoded form. Mask the encoded value so any subsequent
|
||||
# log line (set -x, GIT_TRACE, error spew) gets ***-redacted.
|
||||
echo "::add-mask::${auth_header}"
|
||||
push_stderr=$(mktemp)
|
||||
if git -c http.extraheader="${auth_header}" \
|
||||
push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \
|
||||
"${push_url}" "HEAD:${HEAD_REF}" 2>"$push_stderr"; then
|
||||
echo "result=applied" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
cat "$push_stderr" >&2
|
||||
# `--force-with-lease` reports "stale info" when the remote
|
||||
# ref has moved past the expected SHA. Other lease-failure
|
||||
# phrases git emits include "remote rejected" (server-side
|
||||
# reject), "non-fast-forward", and the literal flag name. Match
|
||||
# any of those to distinguish from auth/network/maintainer-
|
||||
# edit failures.
|
||||
if grep -qE "stale info|force-with-lease|rejected.*non-fast-forward|remote rejected|! \[rejected\]" "$push_stderr"; then
|
||||
echo "::error::git push lease failed — branch moved during apply."
|
||||
echo "result=lease-failed" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::git push failed — likely fork without maintainer-edit enabled."
|
||||
echo "result=push-failed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: React and reply on outcome
|
||||
if: always() && steps.locate.outputs.found == 'true' && steps.apply.outcome != 'skipped'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
RESULT: ${{ steps.apply.outputs.result }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
|
||||
|
||||
case "${RESULT}" in
|
||||
applied)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="+1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="✅ Applied autofix and pushed a commit. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
;;
|
||||
already-applied)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="+1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="✅ Autofix is already applied — no changes needed." \
|
||||
>/dev/null
|
||||
;;
|
||||
empty-patch)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="+1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="✅ No autofix to apply — formatter found nothing." \
|
||||
>/dev/null
|
||||
;;
|
||||
artifact-expired)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⏳ The autofix artifact for this PR's head SHA has expired (1-day retention). Push a new commit to regenerate it, then comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
loop-prevented)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="🔁 Refusing to re-apply autofix on top of an existing autofix commit. If formatter rules drifted and you genuinely need another pass, push a human-authored commit (or revert the existing autofix commit) before commenting \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
sensitive-paths)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="🛑 Refusing to apply: the autofix patch touches files under \`.github/\` (workflow / CODEOWNERS / dependabot config). Apply formatter changes to those files manually in a regular commit so they get human review. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
stale)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ The autofix patch is stale or conflicts with the current head — push a new commit to regenerate, then comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
apply-failed)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ Autofix applied cleanly in the dry run, but \`git apply\` / \`git commit\` failed when actually landing the patch. This usually means a race with concurrent edits or a corrupt patch. See logs: ${run_url}" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
push-failed)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ Couldn't push the autofix commit. If this is a fork PR, please tick **Allow edits by maintainers** in the PR sidebar, then comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
lease-failed)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ The PR head moved while autofix was applying — a new commit landed in the window between resolve and push. Comment \`/autofix\` again to retry against the latest head. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="❓ Autofix run finished in an unexpected state (\`${RESULT:-unknown}\`). See logs: ${run_url}" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,316 +0,0 @@
|
||||
name: PR Autofix (publish)
|
||||
|
||||
# TRUSTED HALF of the autofix pipeline.
|
||||
#
|
||||
# Triggered by `pr-autofix.yml` completing on a PR (including fork PRs).
|
||||
# Downloads the diff artifact produced by the untrusted job, verifies
|
||||
# its claimed PR identity against the workflow_run authority, then
|
||||
# posts (or edits) a single sticky summary comment plus a
|
||||
# `gitnexus/autofix` Check Run. This job NEVER checks out fork code —
|
||||
# it only consumes the diff (data) and calls the GitHub API. That
|
||||
# isolation is what makes it safe to run under `pull-requests: write`
|
||||
# on fork-triggered events.
|
||||
#
|
||||
# The sticky comment is the contributor signal: heading
|
||||
# "## :sparkles: PR Autofix" in the PR's top-level comments, with a
|
||||
# fenced `gitnexus-autofix` JSON block carrying machine-readable state
|
||||
# for AI agents. Contributors apply the patch by commenting `/autofix`
|
||||
# on the PR — handled by the separate `pr-autofix-apply.yml` workflow.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['PR Autofix']
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
# Key on PR identity, NOT workflow_run.id — workflow_run.id is per-run
|
||||
# unique, which would defeat serialization and let two parallel
|
||||
# publishes both POST a sticky summary comment. CONTRIBUTING.md
|
||||
# § GitHub Actions — Concurrency Convention names this anti-pattern
|
||||
# explicitly. For fork PRs, `pull_requests[]` is empty in the
|
||||
# workflow_run payload, so we fall back to head-repo + head-branch.
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: publish-autofix
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
&& github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
# Required by actions/download-artifact to fetch artifacts produced
|
||||
# by a different workflow run.
|
||||
actions: read
|
||||
# Required to create the `gitnexus/autofix` Check Run that reports
|
||||
# the outcome (clean / fixes-available) to the PR's Checks tab.
|
||||
# Branch protection or agents can grep the conclusion + output
|
||||
# title without parsing the sticky comment.
|
||||
checks: write
|
||||
steps:
|
||||
# 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
|
||||
with:
|
||||
name: autofix
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: autofix-in
|
||||
|
||||
- name: Read and validate metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f autofix-in/metadata.json
|
||||
jq . autofix-in/metadata.json
|
||||
|
||||
# The artifact comes from the untrusted half running fork code.
|
||||
# Every field is allowlist-validated before it can flow into
|
||||
# $GITHUB_OUTPUT. A newline in head_ref would otherwise let a
|
||||
# malicious branch name inject a second `pr_number=N` line and
|
||||
# redirect this job's reviewdog suggestions / sticky summary
|
||||
# comment onto a victim PR under github-actions[bot] with
|
||||
# pull-requests: write.
|
||||
assert_field() {
|
||||
local key="$1" pattern="$2" value
|
||||
value=$(jq -r ".${key} // empty" autofix-in/metadata.json)
|
||||
if [ -z "$value" ] || ! [[ "$value" =~ $pattern ]]; then
|
||||
echo "::error::metadata.${key} failed allowlist (got: $(printf '%q' "$value"))"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
SCHEMA=$(assert_field schema '^gitnexus\.pr-autofix/v[0-9]+$')
|
||||
PR_NUMBER=$(assert_field pr_number '^[0-9]+$')
|
||||
HEAD_SHA=$(assert_field head_sha '^[0-9a-f]{40}$')
|
||||
HEAD_REF=$(assert_field head_ref '^[A-Za-z0-9._/-]+$')
|
||||
HEAD_REPO=$(assert_field head_repo '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')
|
||||
BASE_REPO=$(assert_field base_repo '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')
|
||||
CHANGED=$(assert_field changed_lines '^[0-9]+$')
|
||||
|
||||
# Defence-in-depth: refuse to act if the artifact claims to
|
||||
# belong to a different repo than the one that triggered us.
|
||||
if [ "$BASE_REPO" != "${GITHUB_REPOSITORY}" ]; then
|
||||
echo "::error::Artifact base_repo does not match \$GITHUB_REPOSITORY — refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "schema=${SCHEMA}"
|
||||
echo "pr_number=${PR_NUMBER}"
|
||||
echo "head_sha=${HEAD_SHA}"
|
||||
echo "head_ref=${HEAD_REF}"
|
||||
echo "head_repo=${HEAD_REPO}"
|
||||
echo "base_repo=${BASE_REPO}"
|
||||
echo "changed_lines=${CHANGED}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cross-verify the artifact's claimed identity against the
|
||||
# GitHub-controlled workflow_run event. The previous step's
|
||||
# allowlist only proves the fields are well-formed — not that
|
||||
# they refer to the PR/SHA that actually triggered this run.
|
||||
# A fork-controlled `npm run lint:fix` could plausibly mutate
|
||||
# metadata.json to reference another PR or SHA, redirecting our
|
||||
# write-scoped sticky/check-run onto an attacker-chosen target.
|
||||
#
|
||||
# Authority sources are all server-controlled GitHub event fields:
|
||||
# - workflow_run.head_sha
|
||||
# - workflow_run.head_repository.full_name
|
||||
# - workflow_run.pull_requests[].number (within-repo PRs only;
|
||||
# empty array on fork PRs — fall back to commits/{sha}/pulls)
|
||||
#
|
||||
# Mismatch => fail loud BEFORE any sticky/check-run side effect.
|
||||
- name: Verify metadata against workflow_run authority
|
||||
id: verify
|
||||
if: steps.meta.outputs.changed_lines != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
META_PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
|
||||
META_HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
|
||||
META_HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
|
||||
WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
WF_PR_NUMBERS: ${{ toJSON(github.event.workflow_run.pull_requests.*.number) }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1) head_sha must match exactly. workflow_run.head_sha is the
|
||||
# commit GitHub actually ran the producer against — definitive.
|
||||
if [ "${META_HEAD_SHA}" != "${WF_HEAD_SHA}" ]; then
|
||||
echo "::error::Artifact head_sha (${META_HEAD_SHA}) does not match workflow_run.head_sha (${WF_HEAD_SHA}) — refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2) head_repo must match exactly. Same authority anchor.
|
||||
if [ "${META_HEAD_REPO}" != "${WF_HEAD_REPO}" ]; then
|
||||
echo "::error::Artifact head_repo (${META_HEAD_REPO}) does not match workflow_run.head_repository (${WF_HEAD_REPO}) — refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3) pr_number must reference an open PR with this head SHA.
|
||||
# Within-repo PRs: workflow_run.pull_requests[] is populated.
|
||||
# Fork PRs: that array is empty by GitHub design — fall back
|
||||
# to the REST commit-to-PRs lookup. Fail closed if the lookup
|
||||
# finds no matching open PR (avoids attacker-forged PR ids).
|
||||
allowed_numbers=$(jq -c '.' <<< "${WF_PR_NUMBERS}")
|
||||
if [ "${allowed_numbers}" = "[]" ]; then
|
||||
echo "workflow_run.pull_requests is empty (fork PR) — falling back to commits/{sha}/pulls."
|
||||
allowed_numbers=$(gh api "repos/${GH_REPO}/commits/${WF_HEAD_SHA}/pulls" \
|
||||
--jq '[.[] | select(.state == "open") | .number]' 2>/dev/null || echo "[]")
|
||||
if [ "${allowed_numbers}" = "[]" ]; then
|
||||
echo "::error::No open PR found for head ${WF_HEAD_SHA} via commits/{sha}/pulls — refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! jq -e --argjson n "${META_PR_NUMBER}" 'index($n) != null' <<< "${allowed_numbers}" >/dev/null; then
|
||||
echo "::error::Artifact pr_number (${META_PR_NUMBER}) is not in the authoritative PR list (${allowed_numbers}) — refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified: metadata identity matches workflow_run authority (PR=${META_PR_NUMBER}, head_sha=${META_HEAD_SHA}, head_repo=${META_HEAD_REPO})."
|
||||
|
||||
- name: Upsert sticky summary comment
|
||||
# Only post when ci-quality found something fixable (= the
|
||||
# autofix patch is non-empty). When prettier/eslint are clean
|
||||
# the patch is zero bytes and the sticky comment is pure noise,
|
||||
# so we skip it.
|
||||
if: >-
|
||||
always()
|
||||
&& steps.meta.outputs.pr_number != ''
|
||||
&& steps.meta.outputs.changed_lines != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR: ${{ steps.meta.outputs.pr_number }}
|
||||
CHANGED: ${{ steps.meta.outputs.changed_lines }}
|
||||
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Stable heading + marker — agents grep for these exact strings.
|
||||
marker="<!-- gitnexus:pr-autofix-summary -->"
|
||||
heading="## :sparkles: PR Autofix"
|
||||
|
||||
# Single state. The /autofix slash command works for any diff
|
||||
# size — there's no 3K cap and no no-overlap dead-end because
|
||||
# the apply workflow uses `git apply` + push, not the GitHub
|
||||
# review-comment API.
|
||||
ui_state="fixes-available"
|
||||
prose="Found fixable formatting / unused-import issues across **${CHANGED}** changed lines. **Comment \`/autofix\` on this PR to apply them**, or run \`npm run lint:fix && npm run format\` locally."
|
||||
|
||||
# Machine-readable JSON block — agents parse this instead of
|
||||
# regexing English. Fenced code-block info string is
|
||||
# `gitnexus-autofix` so agents can locate it without ambiguity.
|
||||
# Schema bumped from v1 -> v2: adds `apply_command`. The v1
|
||||
# field set is preserved as a superset, but the `state` enum
|
||||
# is redefined (v1: suggestions-posted | skipped-too-large |
|
||||
# diff-no-overlap; v2: fixes-available). v1 readers checking
|
||||
# `schema == 'gitnexus.pr-autofix/v1'` see an unfamiliar version
|
||||
# and fall back to prose, which is the intended migration path.
|
||||
json=$(jq -n -c \
|
||||
--arg state "${ui_state}" \
|
||||
--argjson pr_number "${PR}" \
|
||||
--argjson changed_lines "${CHANGED}" \
|
||||
--arg head_sha "${HEAD_SHA}" \
|
||||
--arg run_id "${RUN_ID}" \
|
||||
'{schema:"gitnexus.pr-autofix/v2", state:$state, pr_number:$pr_number, changed_lines:$changed_lines, head_sha:$head_sha, run_id:$run_id, apply_command:"/autofix"}')
|
||||
|
||||
# Multi-line quoted string instead of a column-0 heredoc — YAML's
|
||||
# `run: |` block ends as soon as a content line dedents below the
|
||||
# block's first-line indent, which would mis-parse the workflow.
|
||||
body="${marker}
|
||||
${heading}
|
||||
|
||||
${prose}
|
||||
|
||||
\`\`\`gitnexus-autofix
|
||||
${json}
|
||||
\`\`\`"
|
||||
# Strip the leading 10-space indent that the YAML block requires
|
||||
# so the rendered comment body starts at column 0.
|
||||
body="$(printf '%s\n' "$body" | sed 's/^ //')"
|
||||
|
||||
# Small retry wrapper for transient 5xx / rate-limit responses
|
||||
# on the GitHub REST API. Three tries with linear backoff. We
|
||||
# only retry GET (idempotent) and PATCH on a known comment id
|
||||
# (idempotent). POST is NOT wrapped — retrying a comment-create
|
||||
# would create duplicates if the first attempt actually landed.
|
||||
gh_retry() {
|
||||
local n=0 max=3
|
||||
while true; do
|
||||
if gh "$@"; then return 0; fi
|
||||
n=$((n+1))
|
||||
if [ "$n" -ge "$max" ]; then return 1; fi
|
||||
sleep $((n * 2))
|
||||
done
|
||||
}
|
||||
|
||||
# Find existing bot comment by the marker and edit-in-place; else create.
|
||||
# CRITICAL: filter by `.user.login == "github-actions[bot]"`. A regular
|
||||
# user posting a comment containing the marker would otherwise be the
|
||||
# `head -n1` match; PATCH on someone else's comment 403s, `set -e`
|
||||
# aborts, and the bot is permanently DoS'd for that PR.
|
||||
existing=$(gh_retry api "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
--paginate --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${marker}\"))) | .id" \
|
||||
| head -n1 || true)
|
||||
|
||||
if [ -n "${existing}" ]; then
|
||||
gh_retry api -X PATCH "repos/${GH_REPO}/issues/comments/${existing}" \
|
||||
-f body="${body}" >/dev/null
|
||||
echo "Updated comment ${existing}."
|
||||
else
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="${body}" >/dev/null
|
||||
echo "Created summary comment."
|
||||
fi
|
||||
|
||||
- name: Emit gitnexus/autofix Check Run
|
||||
# Stable check name `gitnexus/autofix` so PR-watching agents can
|
||||
# `gh pr checks <pr>` and read the conclusion + title without
|
||||
# parsing the sticky comment. Two outcomes:
|
||||
# clean → conclusion: success
|
||||
# fixes-available → conclusion: neutral
|
||||
# `neutral` does not block branch-protection required-checks but
|
||||
# is visually distinct from a green pass.
|
||||
if: always() && steps.meta.outputs.head_sha != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
|
||||
CHANGED: ${{ steps.meta.outputs.changed_lines }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${CHANGED}" = "0" ]; then
|
||||
conclusion="success"
|
||||
title="Formatting clean"
|
||||
summary="Prettier and ESLint --fix produced no changes."
|
||||
else
|
||||
conclusion="neutral"
|
||||
title="Autofix available — comment /autofix to apply"
|
||||
summary="Comment \`/autofix\` on this PR to apply formatter + unused-import fixes (works at any diff size). Or run \`npm run lint:fix && npm run format\` locally."
|
||||
fi
|
||||
|
||||
gh api -X POST "repos/${GH_REPO}/check-runs" \
|
||||
-f name="gitnexus/autofix" \
|
||||
-f head_sha="${HEAD_SHA}" \
|
||||
-f status="completed" \
|
||||
-f conclusion="${conclusion}" \
|
||||
-f "output[title]=${title}" \
|
||||
-f "output[summary]=${summary}" \
|
||||
>/dev/null
|
||||
echo "Posted check-run gitnexus/autofix=${conclusion} (${title})"
|
||||
@@ -1,146 +0,0 @@
|
||||
name: PR Autofix
|
||||
|
||||
# UNTRUSTED HALF of the autofix pipeline.
|
||||
#
|
||||
# Runs `npm run lint:fix` + `npm run format` against the PR head
|
||||
# (including fork heads) and uploads the resulting diff as an artifact.
|
||||
# This job has NO privileged token and CANNOT post to the PR. The trusted
|
||||
# `pr-autofix-publish.yml` workflow downloads the artifact via
|
||||
# `workflow_run` and posts a sticky summary comment + Check Run.
|
||||
# Contributors apply the patch by commenting `/autofix` on the PR —
|
||||
# handled by the separate `pr-autofix-apply.yml` ChatOps workflow.
|
||||
#
|
||||
# Why the split:
|
||||
# ESLint loads plugins from fork-controlled `node_modules`, so running
|
||||
# it in a job with `pull-requests: write` would let a malicious fork PR
|
||||
# ship a poisoned eslint plugin and execute arbitrary code under that
|
||||
# token. By keeping fork code execution in this job (token: read-only)
|
||||
# and posting from a separate trusted job that never touches fork
|
||||
# code, we get the autofix UX for fork PRs without the supply-chain
|
||||
# hole. (See autofix.ci for the same pattern.)
|
||||
#
|
||||
# Removes unused imports via `eslint-plugin-unused-imports`, already in
|
||||
# devDependencies and wired into the `lint` config.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# Skip lockfile / generated-file PRs entirely — `action-suggester`
|
||||
# cannot post on diffs > ~3k lines (GitHub returns 406) and these
|
||||
# paths produce massive diffs no human wants suggested back inline.
|
||||
paths-ignore:
|
||||
- '**/package-lock.json'
|
||||
- '**/*.snap'
|
||||
- '**/dist/**'
|
||||
- '**/node_modules/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
# Don't cancel in-flight runs; the publish workflow may already be
|
||||
# downloading the artifact and a cancelled untrusted run produces no
|
||||
# signal at all (worse DX than waiting).
|
||||
cancel-in-progress: false
|
||||
|
||||
# This workflow runs untrusted fork code. Top-level deny-all and NO
|
||||
# job-level grants — the job can only read its own checkout.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
name: autofix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# PR head commit (not the synthetic merge ref) — we need the
|
||||
# exact tree the contributor pushed so suggestions line up.
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
# `--ignore-scripts` blocks pre/postinstall lifecycle hooks. ESLint
|
||||
# plugins still load from node_modules (that is the actual escape
|
||||
# hatch on a typical fork), but this job has no token to abuse —
|
||||
# which is the whole point of the split.
|
||||
- run: npm ci --ignore-scripts
|
||||
|
||||
- name: ESLint --fix (removes unused imports)
|
||||
run: npm run lint:fix
|
||||
# Lint errors that --fix can't auto-resolve must not block the
|
||||
# diff artifact — partial fixes are still useful as suggestions.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Prettier --write
|
||||
run: npm run format
|
||||
continue-on-error: true
|
||||
|
||||
- name: Capture diff and metadata
|
||||
id: capture
|
||||
# Pass GitHub-context values via env: rather than `${{ }}`
|
||||
# interpolated directly into the bash body. `head.ref` and
|
||||
# `head.repo.full_name` are fork-controlled strings; expanding
|
||||
# them into shell source is the canonical template-injection
|
||||
# vector zizmor flags. Even though this job has `permissions: {}`,
|
||||
# routing through env: makes it impossible for a future scope
|
||||
# grant to turn into RCE. Inside bash, reference as `$HEAD_REF`
|
||||
# etc. — the values are then plain strings, not code.
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p autofix-out
|
||||
|
||||
# Produce a unified diff of the working tree vs. the PR head.
|
||||
# Empty diff => nothing to suggest; the publish job short-circuits.
|
||||
git diff --no-color > autofix-out/autofix.patch
|
||||
|
||||
# NOTE: `changed_lines` is the line-count of the patch file,
|
||||
# (hunk headers + context lines + added/removed). Surfaced in
|
||||
# the sticky comment so contributors and AI agents have a
|
||||
# quick size hint before invoking `/autofix`.
|
||||
changed_lines=$(wc -l < autofix-out/autofix.patch | tr -d ' ')
|
||||
echo "changed_lines=${changed_lines}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Carry PR identity over to the trusted job. workflow_run
|
||||
# context is base-repo-only, so the publish job needs these
|
||||
# to call the GitHub PR API on the right resource.
|
||||
# CONTRACT: keep this schema in sync with pr-autofix-publish.yml's
|
||||
# `assert_field` validators and the agent-facing JSON block in
|
||||
# the sticky comment. Bump `schema` when changing field names.
|
||||
jq -n \
|
||||
--arg schema 'gitnexus.pr-autofix/v1' \
|
||||
--argjson pr_number "${PR_NUMBER}" \
|
||||
--arg head_sha "${HEAD_SHA}" \
|
||||
--arg head_ref "${HEAD_REF}" \
|
||||
--arg head_repo "${HEAD_REPO}" \
|
||||
--arg base_repo "${BASE_REPO}" \
|
||||
--argjson changed_lines "${changed_lines}" \
|
||||
'{schema:$schema, pr_number:$pr_number, head_sha:$head_sha, head_ref:$head_ref, head_repo:$head_repo, base_repo:$base_repo, changed_lines:$changed_lines}' \
|
||||
> autofix-out/metadata.json
|
||||
|
||||
echo "--- metadata ---"
|
||||
cat autofix-out/metadata.json
|
||||
echo "--- diff (head) ---"
|
||||
head -c 2000 autofix-out/autofix.patch || true
|
||||
|
||||
# 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
|
||||
with:
|
||||
name: autofix
|
||||
path: autofix-out/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
@@ -1,95 +0,0 @@
|
||||
name: PR Description Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-description:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check PR description quality
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const MIN_BODY_LENGTH = 50;
|
||||
const LABEL = 'needs-description';
|
||||
|
||||
const pr = context.payload.pull_request;
|
||||
const body = (pr.body || '').trim();
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const number = pr.number;
|
||||
|
||||
const hasLabel = pr.labels.some(l => l.name === LABEL);
|
||||
|
||||
if (body.length < MIN_BODY_LENGTH) {
|
||||
// Add label if not already present
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: number,
|
||||
labels: [LABEL],
|
||||
});
|
||||
}
|
||||
|
||||
// Post or update a comment
|
||||
const marker = '<!-- pr-desc-check -->';
|
||||
const message = [
|
||||
marker,
|
||||
`### PR description is too short`,
|
||||
'',
|
||||
`This PR's description is **${body.length}** characters, ` +
|
||||
`but the minimum is **${MIN_BODY_LENGTH}**.`,
|
||||
'',
|
||||
'Please update the PR description to explain:',
|
||||
'- **What** this PR changes',
|
||||
'- **Why** the change is needed',
|
||||
'',
|
||||
'Use the PR template as a guide. This check will re-run when you edit the description.',
|
||||
].join('\n');
|
||||
|
||||
// Find existing bot comment to update (avoid spam)
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner, repo, issue_number: number,
|
||||
});
|
||||
const existing = comments.data.find(c =>
|
||||
c.body && c.body.includes(marker)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo, comment_id: existing.id,
|
||||
body: message,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: number,
|
||||
body: message,
|
||||
});
|
||||
}
|
||||
|
||||
core.setFailed(
|
||||
`PR description is ${body.length} chars (minimum: ${MIN_BODY_LENGTH})`
|
||||
);
|
||||
} else {
|
||||
// Description is acceptable — remove the label if present
|
||||
if (hasLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: number,
|
||||
name: LABEL,
|
||||
}).catch(() => {});
|
||||
// .catch: label may have been removed manually
|
||||
}
|
||||
|
||||
core.info(`PR description OK (${body.length} chars)`);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
name: PR Conventional Labeler
|
||||
|
||||
# Two workflows in one file with different triggers, matched to the minimum
|
||||
# privilege each needs:
|
||||
#
|
||||
# validate-title (on: pull_request)
|
||||
# Fork-safe. Runs with the PR-head's read-only GITHUB_TOKEN. Uses
|
||||
# `amannn/action-semantic-pull-request` to fail the check when the PR
|
||||
# title doesn't follow the conventional-commit format. Because the
|
||||
# action only reads the event payload, no fork-controlled code runs.
|
||||
#
|
||||
# autolabel (on: pull_request_target)
|
||||
# Needs `pull-requests: write` to apply labels, so must be
|
||||
# pull_request_target. Uses `release-drafter/release-drafter` with
|
||||
# `dry-run: true` to only run the autolabeler against the
|
||||
# `.github/release-drafter.yml` config from the BASE ref (release-
|
||||
# drafter reads the config from the repository's default branch, NOT
|
||||
# the PR head — verify with `gh api repos/release-drafter/release-drafter/contents/...`
|
||||
# or a fork-test PR before merging if the repo is high-value).
|
||||
# `sync-labels: true` in the config removes managed autolabels that no
|
||||
# longer match (e.g. when `!` or `BREAKING CHANGE:` is dropped).
|
||||
#
|
||||
# Title format: <type>[(scope)][!]: <subject>
|
||||
# Allowed types: feat, fix, perf, refactor, docs, test, ci, build, chore, revert, deps
|
||||
# Trailing `!` on the type marks a breaking change.
|
||||
# See CONTRIBUTING.md → "Pull request titles".
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Title-only changes fire `edited`. `opened` and `reopened` cover creation.
|
||||
# `synchronize` (push to the PR branch) is intentionally excluded — titles
|
||||
# don't change on push, so it only wastes CI minutes and broadens the
|
||||
# privileged-token exposure window on the autolabel job.
|
||||
types: [opened, edited, reopened]
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Include `github.event_name` so `pull_request` (validate-title) and
|
||||
# `pull_request_target` (autolabel) runs for the same PR do NOT share a slot
|
||||
# and therefore cannot cancel each other — a cancelled required-check would
|
||||
# permanently block merge until the next title edit.
|
||||
# Within each trigger the latest title edit still supersedes the prior run.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate-title:
|
||||
# Fork-safe job — only runs on `pull_request` (not `pull_request_target`).
|
||||
# Token is read-only; writes a commit status that branch protection can
|
||||
# require before merge.
|
||||
name: Validate PR title
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
# Pinned to v6.1.1. Verify SHA via:
|
||||
# gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v6.1.1
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
perf
|
||||
refactor
|
||||
docs
|
||||
test
|
||||
ci
|
||||
build
|
||||
chore
|
||||
revert
|
||||
deps
|
||||
requireScope: false
|
||||
# Subject must be non-empty. We DO allow capitalized proper nouns
|
||||
# (MCP, GitHub, API, etc.) — the old `^(?![A-Z]).+$` pattern
|
||||
# rejected legitimate titles like `fix: MCP tool schema`.
|
||||
subjectPattern: ^\S.{2,}$
|
||||
subjectPatternError: |
|
||||
The subject "{subject}" in PR title "{title}" is invalid.
|
||||
Subjects must be at least 3 characters and must not start with whitespace.
|
||||
wip: false
|
||||
|
||||
autolabel:
|
||||
# Privileged job — runs only on `pull_request_target` so it can write labels.
|
||||
# Never checks out fork code, never executes fork-controlled input; only
|
||||
# reads the PR metadata (title, body, labels) and calls the GitHub API.
|
||||
name: Apply conventional label
|
||||
if: github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
# `contents: read` is required — release-drafter's context.config() reads
|
||||
# `.github/release-drafter.yml` from the repo's default branch via the
|
||||
# repo-contents API. Without it the job silently 403s and no labels are
|
||||
# applied. Job-level permissions nullify all unlisted scopes, so an
|
||||
# explicit grant is necessary here.
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Pinned to v7.2.0. Verify SHA via:
|
||||
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
|
||||
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
|
||||
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+20
-856
@@ -1,898 +1,62 @@
|
||||
name: Publish
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Sole publisher for the `gitnexus` npm package, GitHub Releases, and Docker
|
||||
# images. Replaces the former two-workflow design — see issue #1609 for the
|
||||
# double-publish race this unification closes.
|
||||
#
|
||||
# Two release modes, both routed through this file:
|
||||
# • Release candidate (rc) — triggered by push to `main` or workflow_dispatch.
|
||||
# The RC path computes the next rc version, applies it in-CI, pushes a
|
||||
# detached release commit with v<X.Y.Z>-rc.<N> + rc/<SHA> marker
|
||||
# atomically, then publishes to npm with --tag rc and creates a GitHub
|
||||
# prerelease. RC-only docker.yml invocation follows.
|
||||
# • Stable — triggered by push of a v<X.Y.Z> tag (no -rc.*
|
||||
# suffix). Verifies package.json matches the tag, publishes to npm with
|
||||
# --tag latest, creates a stable GitHub Release. No docker (RC-only).
|
||||
#
|
||||
# ⚠️ SELF-TRIGGER INVARIANT — DO NOT WEAKEN ⚠️
|
||||
# The `tags:` filter below uses a negative glob `'!v*-rc.*'` to prevent the
|
||||
# workflow from re-triggering itself when the RC path pushes its own v-tag.
|
||||
# Without this exclusion, every RC publish double-fires (the bug fixed by
|
||||
# #1609). If a NEW prerelease channel is introduced (e.g. `-beta.N`,
|
||||
# `-alpha.N`, `-next.N`), the negative-glob list MUST be extended in
|
||||
# lock-step or self-trigger returns. The same invariant applies to the
|
||||
# `Classify` step further below — its accepted-tag regex must align with
|
||||
# the trigger filter's exclusion list.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: Publish to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- 'LICENSE'
|
||||
tags:
|
||||
# Negative-globbed exclusion of RC tags this workflow itself produces
|
||||
# (see the SELF-TRIGGER INVARIANT in the header comment).
|
||||
- 'v*'
|
||||
- '!v*-rc.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: >-
|
||||
Cycle policy. 'auto' (default) continues the active rc cycle on
|
||||
this branch if there is one, otherwise bumps patch from latest.
|
||||
Choose 'patch' / 'minor' / 'major' to explicitly start or reset
|
||||
an rc cycle.
|
||||
required: false
|
||||
default: 'auto'
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
force:
|
||||
description: 'Publish even when HEAD already has an rc marker'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
# Workflow-level deny-all; each job declares the minimum it needs.
|
||||
permissions: {}
|
||||
|
||||
# Distinct refs (refs/heads/main, refs/tags/v*) run in parallel. The
|
||||
# release-PR-skip in rc-guard is the load-bearing invariant that prevents
|
||||
# an RC main-push and a stable tag-push colliding on the same release commit.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Phase 1: classify the triggering event into a release mode ─────────────
|
||||
route:
|
||||
name: Classify release event
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
mode: ${{ steps.classify.outputs.mode }}
|
||||
head_sha: ${{ steps.classify.outputs.head_sha }}
|
||||
bump_input: ${{ inputs.bump }}
|
||||
force_input: ${{ inputs.force }}
|
||||
steps:
|
||||
- name: Classify
|
||||
id: classify
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GH_REF: ${{ github.ref }}
|
||||
GH_REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HEAD_SHA="${GITHUB_SHA}"
|
||||
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Sanitize before logging (annotation-injection defense in depth).
|
||||
REF_SAFE="${GH_REF//::/__}"
|
||||
REF_NAME_SAFE="${GH_REF_NAME//::/__}"
|
||||
echo "event=${EVENT_NAME} ref=${REF_SAFE} ref_name=${REF_NAME_SAFE}"
|
||||
|
||||
MODE=""
|
||||
case "${EVENT_NAME}" in
|
||||
workflow_dispatch)
|
||||
# Manual dispatch is only valid on main — that's the only ref
|
||||
# where a real publish makes sense.
|
||||
if [ "${GH_REF}" = "refs/heads/main" ]; then
|
||||
MODE="rc"
|
||||
else
|
||||
echo "::error::workflow_dispatch is only permitted on refs/heads/main (got ${REF_SAFE})."
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
push)
|
||||
case "${GH_REF}" in
|
||||
refs/heads/main)
|
||||
MODE="rc"
|
||||
;;
|
||||
refs/tags/v*)
|
||||
# The trigger filter already excluded v*-rc.* tags. Anything
|
||||
# reaching here is either a stable semver or a malformed v*.
|
||||
TAG="${GH_REF#refs/tags/}"
|
||||
if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
MODE="stable"
|
||||
else
|
||||
echo "::error::malformed v* tag rejected: ${REF_NAME_SAFE}"
|
||||
echo "::error::stable tags must match ^v[0-9]+\\.[0-9]+\\.[0-9]+\$"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::unexpected push ref ${REF_SAFE} reached publish workflow."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "::error::unsupported event ${EVENT_NAME}."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "mode=${MODE}" >> "$GITHUB_OUTPUT"
|
||||
echo "Classified as mode=${MODE}"
|
||||
|
||||
# ── Phase 2 (RC only): dedup marker + release-PR skip ──────────────────────
|
||||
rc-guard:
|
||||
name: RC guard (marker + release-PR skip)
|
||||
needs: route
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
should_run: ${{ steps.decide.outputs.should_run }}
|
||||
head_sha: ${{ steps.decide.outputs.head_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
# rc-guard reads only — no git pushes from this job. Skip the
|
||||
# default extraheader credential persistence (artipacked audit).
|
||||
persist-credentials: false
|
||||
|
||||
- name: Decide
|
||||
id: decide
|
||||
shell: bash
|
||||
env:
|
||||
FORCE: ${{ inputs.force }}
|
||||
BUMP_INPUT: ${{ inputs.bump }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
echo "Force flag set — running regardless of marker tag."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Explicit cycle reset on dispatch bypasses dedup.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
||||
&& [ -n "${BUMP_INPUT:-}" ] \
|
||||
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
||||
echo "Explicit bump=$BUMP_INPUT — bypassing marker dedup."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Skip when the merge commit corresponds to a release ───────────
|
||||
# This skip is load-bearing: it prevents an RC build firing on the
|
||||
# release-PR commit from racing the imminent stable-tag push on the
|
||||
# same SHA. Two complementary checks:
|
||||
# 1. HEAD subject matches `chore: release vX.Y.Z` (the canonical
|
||||
# release-PR title). Anchored to require the bare title or the
|
||||
# squash-merge `(#NNNN)` suffix exactly. Case-insensitive so
|
||||
# `Chore: Release v1.2.3` (IDE auto-capitalization) still
|
||||
# matches — prior commit-author conventions left the door open.
|
||||
# 2. Squash-merged PR carries the `release` label.
|
||||
# Either match suppresses the rc build — stable releases publish on
|
||||
# the v-tag instead.
|
||||
HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
|
||||
# Sanitize GitHub-Actions annotation prefixes before logging — even
|
||||
# though %s strips newlines, a crafted subject containing `::error::`
|
||||
# could forge log annotations.
|
||||
HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
|
||||
RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$'
|
||||
shopt -s nocasematch
|
||||
if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then
|
||||
shopt -u nocasematch
|
||||
echo "HEAD commit subject matches a release commit — skipping rc."
|
||||
echo " subject (sanitised): $HEAD_SUBJECT_SAFE"
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
shopt -u nocasematch
|
||||
|
||||
# Squash-merge commits include `(#NNNN)` at the end of the subject.
|
||||
if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then
|
||||
PR_NUM="${BASH_REMATCH[1]}"
|
||||
echo "Detected squash-merge of PR #$PR_NUM — checking labels."
|
||||
if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then
|
||||
if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then
|
||||
echo "PR #$PR_NUM has the 'release' label — skipping rc."
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "PR #$PR_NUM has no 'release' label — proceeding."
|
||||
else
|
||||
# Lookup failure is not fatal — fall through to dedup check.
|
||||
echo "::warning::Could not read labels for PR #${PR_NUM} — falling through."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Dedup: is there already an rc/<HEAD_SHA> marker pointing at HEAD?
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
|
||||
echo "HEAD already has marker $MARKER — skipping."
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No marker on HEAD — proceeding."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Phase 3: reusable CI gate ──────────────────────────────────────────────
|
||||
# Runs for both rc (when guard says go) and stable. No `secrets:` passed —
|
||||
# ci.yml and its entire reusable-workflow chain (ci-quality, ci-tests,
|
||||
# ci-e2e, ci-report) reference zero `secrets.*` values;
|
||||
# passing any would be unused surface. GITHUB_TOKEN is implicit.
|
||||
ci:
|
||||
needs: [route, rc-guard]
|
||||
if: ${{ always() && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
# ── Phase 4: publish to npm + push refs (RC path) ──────────────────────────
|
||||
# INVARIANT: `timeout-minutes` MUST stay below the App-token TTL (~60 min
|
||||
# for actions/create-github-app-token installation tokens). The atomic
|
||||
# tag-push step relies on the token minted at job start; if the job ever
|
||||
# runs longer than the TTL, the push fails with an opaque 401. If you
|
||||
# need to raise the timeout, re-mint the token immediately before the
|
||||
# `Create and push rc tags` step instead.
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: [route, rc-guard, ci]
|
||||
if: ${{ always() && needs.ci.result == 'success' && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
|
||||
needs: ci
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
# contents: write — RC path needs it for `git push --atomic` (v-tag +
|
||||
# marker). Stable path runs in the same job and inherits the grant; it
|
||||
# never invokes `git push`, so the elevated scope is unused there.
|
||||
# id-token: write — npm provenance attestation.
|
||||
contents: write
|
||||
id-token: write
|
||||
outputs:
|
||||
# Two distinct step IDs feed this output; exactly one fires per run.
|
||||
vtag: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
|
||||
steps:
|
||||
# ── Mint short-lived GitHub App token (RC only) ──────────────────────
|
||||
# Industry direction (2025-2026): GitHub Apps with
|
||||
# `actions/create-github-app-token` over long-lived PATs for
|
||||
# workflow-touching tag pushes. Same fine-grained permission surface,
|
||||
# ~1h expiry, not tied to a user seat, organizationally auditable.
|
||||
# Replaces a prior fine-grained PAT.
|
||||
#
|
||||
# Required secrets (set in repo Settings → Secrets and variables → Actions):
|
||||
# secrets.RELEASE_APP_ID — the App's numeric ID
|
||||
# secrets.RELEASE_APP_PRIVATE_KEY — the App's PEM private key
|
||||
# (The App ID is technically not sensitive — it's visible on the App's
|
||||
# settings page — but storing it as a secret is harmless and avoids
|
||||
# mixing storage classes for the same App.)
|
||||
# The App must be installed on this repository with:
|
||||
# - Contents: write (push the v-tag and rc marker)
|
||||
# - Workflows: write (because the v-tag's tree may touch
|
||||
# .github/workflows/**, which the default
|
||||
# GITHUB_TOKEN cannot author)
|
||||
# - Metadata: read (required for the `gh api /users/<slug>[bot]`
|
||||
# bot-identity lookup in the tag-push step)
|
||||
- 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/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
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
|
||||
# its Client ID under this name. We pass the numeric App ID,
|
||||
# which the action resolves correctly.
|
||||
client-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
# ── Separate checkout steps per mode ─────────────────────────────────
|
||||
# Conditional `token:` expressions are footguns: empty string passed to
|
||||
# actions/checkout fails opaquely, and `|| github.token` silently
|
||||
# degrades a missing token to GITHUB_TOKEN, masking auth failures until
|
||||
# the eventual `git push`. Two distinct steps make the auth contract
|
||||
# explicit and fail loudly at checkout when the App token mint failed
|
||||
# on the RC path.
|
||||
- name: Checkout (RC)
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
# Short-lived GitHub App installation token. Required because the
|
||||
# v-tag push lands at a SHA whose tree may touch
|
||||
# `.github/workflows/**`, which the default GITHUB_TOKEN cannot
|
||||
# author.
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# Do not persist the token in .git/config (artipacked audit). The
|
||||
# RC tag push uses an inline `http.extraheader` at push time only;
|
||||
# the credential never lands on disk. See the
|
||||
# `Create and push rc tags` step below.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout (stable)
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
|
||||
# path performs no git pushes; the default scope is sufficient.
|
||||
with:
|
||||
# No git pushes from the stable path either. Skip credential
|
||||
# persistence (artipacked audit).
|
||||
persist-credentials: false
|
||||
|
||||
- name: Working-tree sanity
|
||||
# Defense in depth (mirrors the vtag integrity gate, but on the input side):
|
||||
# if a route-mode regression skipped both checkout `if:` gates, all
|
||||
# downstream steps would run on a bare runner and produce confusing
|
||||
# ENOENT errors. Fail loudly and early here instead.
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f gitnexus/package.json ]; then
|
||||
echo "::error::no working tree at gitnexus/package.json — route classification likely failed silently."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.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
|
||||
# 10.9.x (no OIDC) and `npm install -g npm@latest` to self-upgrade
|
||||
# is fragile — it can crash the in-flight reify with
|
||||
# `MODULE_NOT_FOUND` on `promise-retry` etc. Bumping the Node
|
||||
# version is the clean fix; the package's `engines` field is
|
||||
# `>=22.0.0` so consumer-side compatibility is unaffected (this
|
||||
# Node version is only used during publish, not by package users).
|
||||
node-version: 24
|
||||
# `registry-url:` is intentionally OMITTED. Under npm Trusted
|
||||
# Publishing, OIDC only engages when no credential is configured.
|
||||
# Setting `registry-url:` would make setup-node write
|
||||
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into the
|
||||
# runner's .npmrc AND export NODE_AUTH_TOKEN from its `token:`
|
||||
# input (default github.token). `npm publish` would then attempt
|
||||
# GITHUB_TOKEN as the npm token, get rejected with 404, and OIDC
|
||||
# would never be tried. See actions/setup-node#1440 and the GitHub
|
||||
# Community discussion #176761 for the upstream bug and consensus
|
||||
# workaround.
|
||||
#
|
||||
# Hermetic install for published artifacts — opt out of the v5+
|
||||
# default packageManager-based caching (clears the zizmor
|
||||
# cache-poisoning audit). ~30s slower per release; runs rarely.
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- name: Install gitnexus dependencies
|
||||
run: npm ci
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
|
||||
# ── Stable-only: verify the tag and package.json agree ───────────────
|
||||
- name: Verify version consistency (stable)
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
- name: Verify version consistency
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
# Stable mode REJECTS prerelease suffixes — those are filtered at
|
||||
# trigger by the negative-glob filter, but defend at the bash layer too.
|
||||
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Stable tag must be ^v[0-9]+.[0-9]+.[0-9]+$ — got v$TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
|
||||
echo "::error::Tag version (v$TAG_VERSION) does not match package.json version ($PKG_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version verified: $PKG_VERSION"
|
||||
|
||||
# ── RC-only: compute the next rc version against the live registry ──
|
||||
- name: Resolve rc version (rc)
|
||||
id: rc-version
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
BUMP_INPUT: ${{ inputs.bump }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PKG_NAME: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Current published `latest` — the floor for any new rc base.
|
||||
# Only E404 ("never published") falls back to package.json; any
|
||||
# other error (network, auth, malformed response) fails fast
|
||||
# (retry-loud policy: never silently substitute on transient errors).
|
||||
NPM_STDERR_LATEST="$(mktemp)"
|
||||
if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then
|
||||
:
|
||||
else
|
||||
if grep -qiE 'E404|not found' "$NPM_STDERR_LATEST"; then
|
||||
CURRENT_LATEST="$(node -p "require('./package.json').version")"
|
||||
echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST"
|
||||
else
|
||||
echo "::error::npm registry unreachable for 'view version':" >&2
|
||||
cat "$NPM_STDERR_LATEST" >&2
|
||||
rm -f "$NPM_STDERR_LATEST"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f "$NPM_STDERR_LATEST"
|
||||
CURRENT_LATEST_CLEAN="${CURRENT_LATEST%%-*}"
|
||||
|
||||
# 2. Full version list — needed for the counter and active-cycle
|
||||
# inference. Same E404-only fallback.
|
||||
NPM_STDERR_VERSIONS="$(mktemp)"
|
||||
if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then
|
||||
:
|
||||
else
|
||||
if grep -qiE 'E404|not found' "$NPM_STDERR_VERSIONS"; then
|
||||
VERSIONS_JSON='[]'
|
||||
echo "No published versions for $PKG_NAME yet (E404)."
|
||||
else
|
||||
echo "::error::npm registry unreachable for 'view versions':" >&2
|
||||
cat "$NPM_STDERR_VERSIONS" >&2
|
||||
rm -f "$NPM_STDERR_VERSIONS"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f "$NPM_STDERR_VERSIONS"
|
||||
|
||||
# 3. Base selection.
|
||||
# - workflow_dispatch + bump != auto → explicit cycle reset.
|
||||
# - Otherwise (push, or dispatch with bump=auto) → continue the
|
||||
# highest active rc base > latest if any; else patch from latest.
|
||||
# Curated wrapper around `npx semver` — bare npx errors are noisy
|
||||
# and don't distinguish registry-unreachable from invalid-bump-spec.
|
||||
semver_bump() {
|
||||
local kind="$1" current="$2" stderr_file out
|
||||
stderr_file="$(mktemp)"
|
||||
if out="$(npx --yes -p semver@7 semver -i "$kind" "$current" 2>"$stderr_file")"; then
|
||||
rm -f "$stderr_file"
|
||||
printf '%s' "$out"
|
||||
return 0
|
||||
fi
|
||||
echo "::error::semver bump failed (kind=${kind}, current=${current}):" >&2
|
||||
cat "$stderr_file" >&2
|
||||
rm -f "$stderr_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
||||
&& [ -n "${BUMP_INPUT:-}" ] \
|
||||
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
||||
BASE="$(semver_bump "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")"
|
||||
echo "Explicit bump=$BUMP_INPUT → BASE=$BASE"
|
||||
else
|
||||
cat > /tmp/active_base.mjs <<'NODESCRIPT'
|
||||
const latest = process.env.LATEST;
|
||||
let v;
|
||||
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
|
||||
if (!Array.isArray(v)) v = [v];
|
||||
const parse = s => s.split(".").map(n => parseInt(n, 10));
|
||||
const gt = (a, b) => {
|
||||
const [A, B] = [parse(a), parse(b)];
|
||||
for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] > B[i];
|
||||
return false;
|
||||
};
|
||||
const bases = new Set();
|
||||
for (const s of v) {
|
||||
const m = /^(\d+\.\d+\.\d+)-rc\.\d+$/.exec(s);
|
||||
if (m && gt(m[1], latest)) bases.add(m[1]);
|
||||
}
|
||||
if (!bases.size) { process.stdout.write(""); process.exit(0); }
|
||||
const sorted = [...bases].sort((a, b) => gt(a, b) ? 1 : -1);
|
||||
process.stdout.write(sorted[sorted.length - 1]);
|
||||
NODESCRIPT
|
||||
ACTIVE_BASE="$(LATEST="$CURRENT_LATEST_CLEAN" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/active_base.mjs)"
|
||||
if [ -n "$ACTIVE_BASE" ]; then
|
||||
BASE="$ACTIVE_BASE"
|
||||
echo "Continuing active rc cycle → BASE=$BASE"
|
||||
else
|
||||
BASE="$(semver_bump patch "$CURRENT_LATEST_CLEAN")"
|
||||
echo "No active rc cycle → patch bump from latest → BASE=$BASE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Counter: 1 + max existing N for `${BASE}-rc.*`, else 1.
|
||||
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
|
||||
.filter(s => typeof s === "string" && s.startsWith(prefix))
|
||||
.map(s => parseInt(s.slice(prefix.length), 10))
|
||||
.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)"
|
||||
RC_VERSION="${BASE}-rc.${NEXT_N}"
|
||||
echo "Computed rc: $RC_VERSION"
|
||||
|
||||
# 5. Defensive: if the exact version already exists on the registry
|
||||
# (race with another run), abort before re-publishing.
|
||||
NPM_STDERR_EXISTS="$(mktemp)"
|
||||
if npm view "$PKG_NAME@$RC_VERSION" version 2>"$NPM_STDERR_EXISTS" >/dev/null; then
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
echo "::error::Version $RC_VERSION already exists on npm — aborting."
|
||||
exit 1
|
||||
else
|
||||
if grep -qiE 'E404|not found' "$NPM_STDERR_EXISTS"; then
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
# Version doesn't exist — safe to proceed.
|
||||
else
|
||||
echo "::error::npm registry unreachable for existence check:" >&2
|
||||
cat "$NPM_STDERR_EXISTS" >&2
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
echo "base=$BASE"
|
||||
echo "rc_n=$NEXT_N"
|
||||
echo "rc_version=$RC_VERSION"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Apply rc version in-CI
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm version "${{ steps.rc-version.outputs.rc_version }}" \
|
||||
--no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build gitnexus
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Dry-run publish
|
||||
# Cheap verification that the tarball assembles before the real publish.
|
||||
shell: bash
|
||||
run: npm publish --dry-run
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
|
||||
run: npm publish --dry-run --tag "$NPM_TAG"
|
||||
|
||||
# ── 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
|
||||
# tree contains the rewritten package.json, so the
|
||||
# tag's source matches the npm tarball.
|
||||
# rc/<HEAD_SHA> → lightweight tag on HEAD; the guard's dedup key.
|
||||
# Push fails → nothing published. Push succeeds, npm fails → marker
|
||||
# blocks retries until manual cleanup (see Rollback Runbook in plan).
|
||||
- name: Create and push rc tags
|
||||
id: rc-tags
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
RC_VERSION: ${{ steps.rc-version.outputs.rc_version }}
|
||||
HEAD_SHA: ${{ needs.rc-guard.outputs.head_sha }}
|
||||
# Short-lived GitHub App token. Auth is supplied inline at push
|
||||
# time via `http.extraheader` (per GitHub's documented
|
||||
# x-access-token Basic pattern). It is NOT persisted in
|
||||
# .git/config (artipacked audit) — checkout above ran with
|
||||
# `persist-credentials: false`.
|
||||
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
# App's slug from create-github-app-token (e.g. `gitnexus-release-bot`).
|
||||
# Used to attribute the release commit to the App identity rather
|
||||
# than the generic github-actions[bot]. The bot's numeric user-id
|
||||
# is resolved at runtime via the GitHub API (the action does not
|
||||
# expose it directly as of v3.2.0).
|
||||
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VTAG="v${RC_VERSION}"
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
|
||||
# Resolve the App's bot user-id and construct the noreply email
|
||||
# in the GitHub-canonical `<id>+<slug>[bot]@users.noreply.github.com`
|
||||
# shape. `[bot]` is part of the actual login on GitHub.
|
||||
#
|
||||
# The lookup is wrapped in a bounded retry because the first RC
|
||||
# after App installation may hit propagation delay (404), and
|
||||
# transient api.github.com 5xx during heavy org activity is a real
|
||||
# failure class. Without retry, every transient blip aborts the
|
||||
# entire release after CI has already succeeded.
|
||||
BOT_LOGIN="${APP_SLUG}[bot]"
|
||||
BOT_USER_ID=""
|
||||
api_stderr="$(mktemp)"
|
||||
for attempt in 1 2 3; do
|
||||
if BOT_USER_ID="$(gh api "/users/${BOT_LOGIN}" --jq .id 2>"$api_stderr")" \
|
||||
&& [[ "${BOT_USER_ID}" =~ ^[0-9]+$ ]]; then
|
||||
break
|
||||
fi
|
||||
BOT_USER_ID=""
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
echo "::warning::bot user-id lookup attempt ${attempt} failed; retrying in $((attempt * 5))s"
|
||||
sleep $((attempt * 5))
|
||||
fi
|
||||
done
|
||||
if ! [[ "${BOT_USER_ID}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::Could not resolve bot user-id for ${BOT_LOGIN} after 3 attempts."
|
||||
echo "::error::gh api stderr:"
|
||||
cat "$api_stderr" >&2 || true
|
||||
echo "::error::Common causes: (a) newly-installed App — user record still propagating to /users/ (wait ~5min, redispatch with force=true); (b) App lacks Metadata: read permission; (c) transient api.github.com 5xx (redispatch)."
|
||||
rm -f "$api_stderr"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$api_stderr"
|
||||
git config user.name "${BOT_LOGIN}"
|
||||
git config user.email "${BOT_USER_ID}+${BOT_LOGIN}@users.noreply.github.com"
|
||||
|
||||
# Detached release commit with the version bump — main stays
|
||||
# pristine, but the v-tag's tree matches the published package
|
||||
# exactly (release-integrity).
|
||||
git add package.json package-lock.json 2>/dev/null || git add package.json
|
||||
git commit -m "release: ${VTAG}" --allow-empty
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
echo "Detached release commit: $RELEASE_SHA"
|
||||
|
||||
git tag -a "$VTAG" "$RELEASE_SHA" -m "$VTAG"
|
||||
git tag "$MARKER" "$HEAD_SHA"
|
||||
|
||||
# Inline auth header. The base64-encoded form is masked as well
|
||||
# as the raw token, because GitHub's secret-masker only masks the
|
||||
# raw value — any subsequent `set -x` / GIT_TRACE line would
|
||||
# otherwise expose the encoded credential.
|
||||
#
|
||||
# `set +x` wraps the compute+mask pair so that if an operator
|
||||
# enables ACTIONS_STEP_DEBUG=true for triage (which turns on
|
||||
# `set -x` globally), the assignment is NOT traced for the one
|
||||
# line between compute and mask-registration. Without this wrap,
|
||||
# debug mode would log `+ auth_header='Authorization: Basic <encoded>'`
|
||||
# exposing a still-valid (~1h) App token.
|
||||
{ set +x; } 2>/dev/null
|
||||
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${PUSH_TOKEN}" | base64 -w0)"
|
||||
echo "::add-mask::${auth_header}"
|
||||
# Re-enable tracing only when explicitly requested via step-debug.
|
||||
if [ "${ACTIONS_STEP_DEBUG:-false}" = "true" ]; then set -x; fi
|
||||
|
||||
# Atomic push of both refs. If either would clobber an existing
|
||||
# remote ref, the push fails and we stop before npm publish.
|
||||
git -c http.extraheader="${auth_header}" \
|
||||
push --atomic origin "refs/tags/$VTAG" "refs/tags/$MARKER"
|
||||
|
||||
{
|
||||
echo "vtag=$VTAG"
|
||||
echo "marker=$MARKER"
|
||||
echo "release_sha=$RELEASE_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set vtag (stable)
|
||||
id: stable-vtag
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
shell: bash
|
||||
# github.ref_name flows in via env to avoid templating into the
|
||||
# shell source (template-injection audit). Even though refs are
|
||||
# constrained by git naming rules, the env-passthrough pattern
|
||||
# makes injection structurally impossible.
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
echo "vtag=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── vtag integrity gate ──────────────────────────────────────────────
|
||||
# Fail closed before any artifact-producing step (npm publish, Release,
|
||||
# Docker) runs against an empty or mode-mismatched vtag. Prevents the
|
||||
# silent "Release named main" / "Docker tagged from ref fallback"
|
||||
# failure modes that the previous draft was vulnerable to.
|
||||
- name: vtag integrity gate
|
||||
id: vtag-gate
|
||||
shell: bash
|
||||
env:
|
||||
MODE: ${{ needs.route.outputs.mode }}
|
||||
VTAG: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$VTAG" ]; then
|
||||
echo "::error::vtag is empty — refusing to create GitHub Release or trigger Docker."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
rc)
|
||||
if ! [[ "$VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
echo "::error::vtag '${VTAG}' does not match rc shape ^v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+$"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
stable)
|
||||
if ! [[ "$VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::vtag '${VTAG}' does not match stable shape ^v[0-9]+.[0-9]+.[0-9]+$"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::unknown mode '${MODE}' at vtag integrity gate."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "vtag verified: ${VTAG} (mode=${MODE})"
|
||||
echo "vtag=${VTAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# npm Trusted Publishing (GA'd 2025-07-31). OIDC authentication only
|
||||
# engages when no npm credential is configured anywhere — the absence
|
||||
# is the signal. Two upstream behaviors had to be neutralized for
|
||||
# this to work:
|
||||
#
|
||||
# 1. setup-node's `registry-url:` is omitted (see the setup-node
|
||||
# step above). With it, setup-node writes
|
||||
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into
|
||||
# .npmrc and exports NODE_AUTH_TOKEN from `token:` (defaulting
|
||||
# to github.token). npm publish then sends GITHUB_TOKEN as the
|
||||
# bearer credential and the registry returns 404. OIDC is never
|
||||
# tried because npm thinks it already has a credential.
|
||||
# 2. The runner's bundled npm (10.9.x on Node 22) has no OIDC
|
||||
# support; the upgrade step above pins it to >= 11.5.1.
|
||||
#
|
||||
# Provenance is auto-attached by the registry on trusted-publisher
|
||||
# publishes — no --provenance flag needed.
|
||||
#
|
||||
# Prerequisite: register the package as a trusted publisher at
|
||||
# https://www.npmjs.com/package/gitnexus/access (Publishing access →
|
||||
# Trusted Publishers → GitHub Actions):
|
||||
# Owner: abhigyanpatwari
|
||||
# Repository: GitNexus
|
||||
# Workflow: publish.yml
|
||||
# Environment: (none)
|
||||
- name: Publish to npm
|
||||
shell: bash
|
||||
run: npm publish --provenance --access public
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
|
||||
run: npm publish --access public --tag "$NPM_TAG"
|
||||
|
||||
# ── Stable-only: pull CHANGELOG body if present ──────────────────────
|
||||
- name: Extract release notes from CHANGELOG (stable)
|
||||
id: changelog
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
NOTES=$(awk "/^## \\[$VERSION\\]/{found=1; next} /^## \\[/{if(found) exit} found" gitnexus/CHANGELOG.md)
|
||||
if [ -z "$NOTES" ]; then
|
||||
echo "::warning::No CHANGELOG entry found for v$VERSION, falling back to auto-generated notes"
|
||||
echo "fallback=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "$NOTES" > /tmp/release-notes.md
|
||||
echo "fallback=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.vtag-gate.outputs.vtag }}
|
||||
name: >-
|
||||
${{ needs.route.outputs.mode == 'rc'
|
||||
&& format('Release Candidate {0}', steps.vtag-gate.outputs.vtag)
|
||||
|| 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' || '' }}
|
||||
generate_release_notes: >-
|
||||
${{ needs.route.outputs.mode == 'rc'
|
||||
|| steps.changelog.outputs.fallback == 'true' }}
|
||||
body: >-
|
||||
${{ needs.route.outputs.mode == 'rc' && format(
|
||||
'Automated release candidate build from `main`.{0}{0}**npm:** `npm install gitnexus@rc`{0}**Version:** `{1}`{0}**Target base:** `{2}` (rc #{3}){0}**Source commit (main):** {4}{0}**Release commit (versioned tree):** {5}{0}{0}Release candidates are pre-stable builds intended for early testing. Stable releases remain on the `latest` dist-tag.',
|
||||
'\n',
|
||||
steps.rc-version.outputs.rc_version,
|
||||
steps.rc-version.outputs.base,
|
||||
steps.rc-version.outputs.rc_n,
|
||||
needs.rc-guard.outputs.head_sha,
|
||||
steps.rc-tags.outputs.release_sha
|
||||
) || '' }}
|
||||
|
||||
# ── RC partial-failure cleanup ───────────────────────────────────────
|
||||
# If anything after the atomic tag-push step failed (npm publish
|
||||
# blew up, GitHub Release call timed out, etc.), the v-tag and
|
||||
# rc/<SHA> marker are already on origin. External consumers
|
||||
# (Renovate, Dependabot, Releases RSS) can ingest a phantom tag for
|
||||
# a version that was never published to npm. This step deletes them
|
||||
# automatically so the operator's recovery is just "redispatch with
|
||||
# force=true on the next commit", not a manual ref cleanup.
|
||||
#
|
||||
# Scoped strictly to RC + real (non-dry-run) + the rc-tags step
|
||||
# actually produced a vtag (otherwise nothing to clean up). The
|
||||
# App token is still valid (~1h TTL, job timeout 20min).
|
||||
- name: Cleanup pushed tags on partial failure
|
||||
if: ${{ failure() && needs.route.outputs.mode == 'rc' && steps.rc-tags.outputs.vtag != '' }}
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
VTAG: ${{ steps.rc-tags.outputs.vtag }}
|
||||
MARKER: ${{ steps.rc-tags.outputs.marker }}
|
||||
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
echo "::warning::Publish step failed after tag push. Cleaning up remote refs to prevent phantom-version ingestion by downstream consumers."
|
||||
|
||||
{ set +x; } 2>/dev/null
|
||||
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${PUSH_TOKEN}" | base64 -w0)"
|
||||
echo "::add-mask::${auth_header}"
|
||||
if [ "${ACTIONS_STEP_DEBUG:-false}" = "true" ]; then set -x; fi
|
||||
|
||||
# Delete v-tag and marker. Each delete is best-effort — if one
|
||||
# is already absent (atomic push partially rejected, or earlier
|
||||
# cleanup ran), the other still gets attempted.
|
||||
for ref in "refs/tags/${VTAG}" "refs/tags/${MARKER}"; do
|
||||
if git -c http.extraheader="${auth_header}" push origin --delete "${ref}" 2>&1; then
|
||||
echo "deleted origin ${ref}"
|
||||
else
|
||||
echo "::warning::could not delete origin ${ref} — may already be absent or protected. Manual cleanup may be required."
|
||||
fi
|
||||
done
|
||||
|
||||
echo "::notice::Cleanup complete. To retry the release, redispatch the workflow with force=true on the same SHA, or push a new commit to main."
|
||||
|
||||
# ── Phase 5 (RC only): Docker images ───────────────────────────────────────
|
||||
# R6: Docker remains RC-only. Stable Docker builds are explicitly deferred.
|
||||
# Secrets are passed explicitly (not via `secrets: inherit`) so the
|
||||
# callee's secret surface is auditable from the caller's source.
|
||||
docker:
|
||||
name: Build & Push RC Docker images
|
||||
needs: [route, publish]
|
||||
if: ${{ needs.route.outputs.mode == 'rc' && needs.publish.outputs.vtag != '' }}
|
||||
uses: ./.github/workflows/docker.yml
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
with:
|
||||
tag: ${{ needs.publish.outputs.vtag }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Scorecard
|
||||
|
||||
# OpenSSF Scorecard supply-chain posture check. Runs weekly + on main push +
|
||||
# branch_protection_rule changes. SARIF uploads to the Security tab; the public
|
||||
# badge URL resolves once the first scheduled run lands (see README badge wiring).
|
||||
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: '0 7 * * 1'
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
# Needed to upload SARIF results to the Security tab.
|
||||
security-events: write
|
||||
# Needed for the publish_results badge flow (OIDC).
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Scorecard
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# publish_results enables the public Scorecard badge.
|
||||
publish_results: true
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -1,185 +0,0 @@
|
||||
name: Tree-sitter Upgrade Readiness
|
||||
|
||||
# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks:
|
||||
# 1. Peer-dep compatibility — can each grammar install cleanly with
|
||||
# tree-sitter@0.25.0 without --legacy-peer-deps?
|
||||
# 2. Vendored proto drift — has coder3101/tree-sitter-proto moved
|
||||
# ahead of our vendored snapshot?
|
||||
# See .github/scripts/check-tree-sitter-upgrade-readiness.py for the logic.
|
||||
#
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Daily at 09:00 UTC. Matches Dependabot's daily cadence so drift
|
||||
# and dep PRs surface together.
|
||||
- cron: '0 9 * * *'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/scripts/check-tree-sitter-upgrade-readiness.py'
|
||||
- '.github/workflows/tree-sitter-upgrade-readiness.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
readiness:
|
||||
name: Check upgrade readiness
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to open/update the tracking issue on scheduled runs.
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'false'
|
||||
|
||||
- name: Run upgrade readiness check
|
||||
id: readiness
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
python3 .github/scripts/check-tree-sitter-upgrade-readiness.py > drift-report.md
|
||||
code=$?
|
||||
set -e
|
||||
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo 'report<<DRIFT_EOF'
|
||||
cat drift-report.md
|
||||
echo 'DRIFT_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "=== Report ==="
|
||||
cat drift-report.md
|
||||
|
||||
# On PR runs, the script validates that it runs correctly. Blockers
|
||||
# are informational — the scheduled run opens a tracking issue.
|
||||
- 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."
|
||||
|
||||
- name: Upsert tracking issue on scheduled runs
|
||||
if: >
|
||||
github.event_name == 'schedule' &&
|
||||
steps.readiness.outputs.exit_code != '0'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
REPORT: ${{ steps.readiness.outputs.report }}
|
||||
with:
|
||||
script: |
|
||||
const 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>';
|
||||
const { data: open } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
labels: 'tree-sitter-drift',
|
||||
per_page: 10,
|
||||
});
|
||||
const existing = open.find(i => i.title === title);
|
||||
if (existing) {
|
||||
// Extract ready/total count for the changelog comment.
|
||||
const readyMatch = report.match(/\*\*(\d+)\/(\d+)\*\* grammars ready/);
|
||||
const blockerMatch = report.match(/\*\*(\d+) blocker/);
|
||||
const ready = readyMatch ? readyMatch[1] : '?';
|
||||
const total = readyMatch ? readyMatch[2] : '?';
|
||||
const blockers = blockerMatch ? blockerMatch[1] : '?';
|
||||
|
||||
// Find grammars whose status changed by diffing the old and
|
||||
// new table rows. Each row looks like:
|
||||
// | `tree-sitter-foo` | ... | Ready |
|
||||
// | `tree-sitter-foo` | ... | Blocking |
|
||||
const parseRows = (md) => {
|
||||
const map = {};
|
||||
for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*?\| (\S+(?:\s\S+)*?) \|$/gm)) {
|
||||
map[m[1]] = m[2].trim();
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const oldRows = parseRows(existing.body || '');
|
||||
const newRows = parseRows(report);
|
||||
const changes = [];
|
||||
for (const [name, newStatus] of Object.entries(newRows)) {
|
||||
const oldStatus = oldRows[name];
|
||||
if (oldStatus && oldStatus !== newStatus) {
|
||||
changes.push(`\`${name}\`: ${oldStatus} → ${newStatus}`);
|
||||
}
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let comment = `**${today}:** ${ready}/${total} ready. ${blockers} blocker(s) remaining.`;
|
||||
if (changes.length > 0) {
|
||||
comment += '\n\nChanges:\n' + changes.map(c => `- ${c}`).join('\n');
|
||||
} else {
|
||||
comment += ' No changes from previous run.';
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: existing.number,
|
||||
body: comment,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: existing.number,
|
||||
body,
|
||||
});
|
||||
core.info(`Updated existing issue #${existing.number}`);
|
||||
} else {
|
||||
const { data: created } = await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
body,
|
||||
labels: ['tree-sitter-drift', 'dependencies'],
|
||||
});
|
||||
core.info(`Opened issue #${created.number}`);
|
||||
}
|
||||
|
||||
- name: Close tracking issue on clean scheduled runs
|
||||
if: >
|
||||
github.event_name == 'schedule' &&
|
||||
steps.readiness.outputs.exit_code == '0'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const title = 'Tree-sitter 0.25 upgrade readiness';
|
||||
const { data: open } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
labels: 'tree-sitter-drift',
|
||||
per_page: 10,
|
||||
});
|
||||
const existing = open.find(i => i.title === 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.',
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: existing.number,
|
||||
state: 'closed',
|
||||
});
|
||||
core.info(`Closed issue #${existing.number}`);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
name: Triage Sweep
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
iqr_multiplier:
|
||||
description: >-
|
||||
IQR multiplier for outlier cutoff.
|
||||
cutoff = Q75 + multiplier * IQR.
|
||||
Higher = fewer outliers flagged.
|
||||
type: number
|
||||
default: 3.0
|
||||
max_outlier_pct:
|
||||
description: >-
|
||||
Maximum fraction of items that can be flagged as outliers (0-1).
|
||||
Hard cap to prevent over-flagging.
|
||||
type: number
|
||||
default: 0.05
|
||||
contamination:
|
||||
description: >-
|
||||
Expected fraction of outliers in the data (0-0.5).
|
||||
Controls how aggressively EllipticEnvelope downweights extremes.
|
||||
type: number
|
||||
default: 0.1
|
||||
cosine_threshold:
|
||||
description: >-
|
||||
Cosine similarity threshold for duplicate detection.
|
||||
Pairs with similarity above this are flagged as potential duplicates.
|
||||
Higher = only very similar pairs flagged.
|
||||
type: number
|
||||
default: 0.92
|
||||
max_items:
|
||||
description: >-
|
||||
Maximum number of open issues + PRs to process.
|
||||
Hard cap to prevent runaway costs on very large repos.
|
||||
type: number
|
||||
default: 500
|
||||
dry_run:
|
||||
description: >-
|
||||
Check this to only log results to the workflow summary.
|
||||
Uncheck to create a GitHub issue with the report and apply labels.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Single global slot — newest manual dispatch supersedes any in-flight run.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts/triage
|
||||
sparse-checkout-cone-mode: false
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: .github/scripts/triage/requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r .github/scripts/triage/requirements.txt
|
||||
|
||||
- name: Cache FastEmbed model weights
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.fastembed_cache
|
||||
key: fastembed-bge-small-en-v1.5
|
||||
|
||||
- name: Run triage sweep
|
||||
id: sweep
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
FASTEMBED_CACHE_PATH: ${{ github.workspace }}/.fastembed_cache
|
||||
INPUT_IQR_MULTIPLIER: ${{ inputs.iqr_multiplier }}
|
||||
INPUT_MAX_OUTLIER_PCT: ${{ inputs.max_outlier_pct }}
|
||||
INPUT_CONTAMINATION: ${{ inputs.contamination }}
|
||||
INPUT_COSINE_THRESHOLD: ${{ inputs.cosine_threshold }}
|
||||
INPUT_MAX_ITEMS: ${{ inputs.max_items }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: python .github/scripts/triage/sweep.py
|
||||
|
||||
- name: Post summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/triage-report.md ]; then
|
||||
cat /tmp/triage-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "No report generated." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -1,82 +0,0 @@
|
||||
name: Trivy Image Scan
|
||||
|
||||
# Builds Dockerfile.cli and Dockerfile.web, then scans the resulting images
|
||||
# for OS-package and language-package CVEs at MEDIUM+ severity.
|
||||
# Findings upload to the Security tab; record-only (does not block merges).
|
||||
#
|
||||
# Trigger on Dockerfile changes in PRs so base-image/npm-layer remediation can
|
||||
# be verified before merge without running image scans on every PR.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile.cli'
|
||||
- 'Dockerfile.web'
|
||||
- 'gitnexus/Dockerfile.test'
|
||||
- '.github/workflows/trivy.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Trivy (${{ matrix.image.name }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
image:
|
||||
- { dockerfile: Dockerfile.cli, name: gitnexus-cli }
|
||||
- { dockerfile: Dockerfile.web, name: gitnexus-web }
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Build image (load locally for scan)
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.image.dockerfile }}
|
||||
load: true
|
||||
push: false
|
||||
tags: scan-target:${{ matrix.image.name }}
|
||||
|
||||
# aquasecurity/trivy-action versions < 0.35.0 are flagged by
|
||||
# GHSA-69fq-xp46-6x23 (briefly compromised supply chain). Pinned to
|
||||
# v0.36.0 (post-incident clean release) by commit SHA.
|
||||
- name: Run Trivy
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: scan-target:${{ matrix.image.name }}
|
||||
format: sarif
|
||||
output: trivy-${{ matrix.image.name }}.sarif
|
||||
severity: MEDIUM,HIGH,CRITICAL
|
||||
# Hides CVEs with no available fix in the base image.
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
@@ -1,85 +0,0 @@
|
||||
name: Workflow Lint
|
||||
|
||||
# Lints .github/workflows/** for both:
|
||||
# - actionlint: YAML syntax, expression typing, shellcheck inside `run:`
|
||||
# blocks, unknown contexts, deprecated runner labels.
|
||||
# - zizmor: security misconfigurations — unpinned actions, dangerous
|
||||
# `${{ }}` interpolation, missing per-job permissions, etc.
|
||||
#
|
||||
# Scoped to PRs that touch .github/** only — keeps off the typical PR
|
||||
# critical path.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.github/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Pinned to v2.1.2. Verify SHA via:
|
||||
# gh api repos/raven-actions/actionlint/git/refs/tags/v2.1.2
|
||||
# The action wraps the upstream `rhysd/actionlint` binary and emits
|
||||
# GitHub-annotation-formatted findings on PRs.
|
||||
- name: Run actionlint
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2
|
||||
with:
|
||||
fail-on-error: true
|
||||
|
||||
zizmor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install zizmor
|
||||
# Pinned — resolves to whatever's latest on PyPI otherwise.
|
||||
# Bump via Dependabot pip ecosystem (see .github/dependabot.yml).
|
||||
run: pipx install zizmor==1.24.1
|
||||
|
||||
# Initial threshold: medium. High+ findings fail the job; medium findings
|
||||
# appear in the Security tab without blocking. Tune after first run.
|
||||
# Per-rule exemptions for pre-existing intentional patterns live in
|
||||
# .github/zizmor.yml (each carries a documented mitigation).
|
||||
- name: Run zizmor
|
||||
run: zizmor --config .github/zizmor.yml --format sarif --min-severity medium . > zizmor.sarif
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
||||
- name: Fail on high+ findings
|
||||
run: zizmor --config .github/zizmor.yml --min-severity high .
|
||||
@@ -1,44 +0,0 @@
|
||||
# zizmor config — pre-existing intentional patterns flagged on initial introduction.
|
||||
# Each ignore below has a documented mitigation. Re-evaluate when the source workflow changes.
|
||||
#
|
||||
# To run zizmor locally with this config:
|
||||
# zizmor --config .github/zizmor.yml .
|
||||
|
||||
rules:
|
||||
dangerous-triggers:
|
||||
ignore:
|
||||
# workflow_run is REQUIRED to post sticky comments on fork PRs — the
|
||||
# default-branch privileged token isn't accessible from `pull_request`
|
||||
# on a fork. Mitigated by: read-only `actions:read` + `contents:read`
|
||||
# for artifact download; `pull-requests:write` is the only write scope;
|
||||
# no checkout of fork code occurs. Header comment in the file documents.
|
||||
- ci-report.yml
|
||||
|
||||
# workflow_run is the trusted half of the autofix pipeline. The
|
||||
# untrusted half (pr-autofix.yml) runs fork code with permissions:{}
|
||||
# and produces only a diff artifact (data, not executable code). The
|
||||
# publish job consumes the artifact, allowlist-validates every field
|
||||
# of metadata.json before exporting to $GITHUB_OUTPUT, never checks
|
||||
# out fork code, and never executes anything fork-controlled. Header
|
||||
# comment in the file documents the split.
|
||||
- pr-autofix-publish.yml
|
||||
|
||||
# pull_request_target needed by claude-code-action to access secrets
|
||||
# and post review comments on fork PRs. Mitigated by: PR checkouts pin
|
||||
# the fork's HEAD SHA (not the branch ref) to prevent TOCTOU races,
|
||||
# and claude-code-action sandboxes execution. Header comment documents.
|
||||
- claude.yml
|
||||
|
||||
# pull_request_target on the autolabel job needs `pull-requests:write`
|
||||
# to apply labels. Mitigated by: release-drafter runs with `dry-run:
|
||||
# true`, reads only `.github/release-drafter.yml` from the BASE ref,
|
||||
# and the validate-title job (which runs untrusted `pull_request`
|
||||
# context) holds no write permissions. Header comment documents.
|
||||
- pr-labeler.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
|
||||
# by PyO3/maturin for the same audit). After the publish-workflow
|
||||
# unification (issue #1609), only publish.yml remains; the same
|
||||
# cache-poisoning hardening applies there.
|
||||
+3
-53
@@ -23,7 +23,6 @@ Thumbs.db
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
docker/.env
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -34,8 +33,6 @@ coverage/
|
||||
|
||||
# Misc
|
||||
*.local
|
||||
HANDOFF.md
|
||||
HANDOFF*.md
|
||||
|
||||
.vercel
|
||||
|
||||
@@ -51,62 +48,15 @@ HANDOFF*.md
|
||||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
||||
# Claude code skills
|
||||
.claude/skills/generated/
|
||||
|
||||
# Assets (screenshots, images)
|
||||
assets/
|
||||
|
||||
# Generated files (should not be indexed)
|
||||
repomix-output*
|
||||
|
||||
# Playwright artifacts
|
||||
gitnexus-web/playwright-report/
|
||||
gitnexus-web/test-results/
|
||||
|
||||
# Python test artifacts
|
||||
eval/.coverage
|
||||
eval/.hypothesis/
|
||||
|
||||
# Local docs
|
||||
docs/
|
||||
# Design docs (local only)
|
||||
docs/plans/
|
||||
|
||||
gitnexus/test/fixtures/mini-repo/*.md
|
||||
gitnexus/test/fixtures/mini-repo/.claude
|
||||
gitnexus/test/fixtures/mini-repo/.gitignore
|
||||
|
||||
# Ignore csharp generated obj and bin folders
|
||||
gitnexus/test/fixtures/lang-resolution/**/obj
|
||||
gitnexus/test/fixtures/lang-resolution/**/bin
|
||||
GitNexus.sln
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# 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
|
||||
gitnexus/vendor/**/build/
|
||||
gitnexus/vendor/**/node_modules/
|
||||
|
||||
/github/scripts/triage/__pycache__/
|
||||
|
||||
.claude-flow/
|
||||
|
||||
.claude/agents/*
|
||||
!.claude/agents/gitnexus-*.md
|
||||
.claude/commands/
|
||||
.claude/helpers
|
||||
.claude/skills/*
|
||||
!.claude/skills/gitnexus/
|
||||
!.claude/skills/gitnexus-pr-swarm-review/
|
||||
|
||||
.history/
|
||||
|
||||
.swarm/
|
||||
|
||||
local_docs/
|
||||
|
||||
# Local agent scratch / review prompts (never commit)
|
||||
.tmp/
|
||||
.agents/
|
||||
.context/
|
||||
gitnexus/web/
|
||||
gitnexus/test/fixtures/mini-repo/.gitignore
|
||||
@@ -1,12 +0,0 @@
|
||||
title = "GitNexus"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
# Fake embedding API keys in unit tests (current probe + historical placeholder).
|
||||
[allowlist]
|
||||
description = "fake embedding API keys in http-embedder unit tests"
|
||||
regexes = [
|
||||
'''secret-key-12345''',
|
||||
'''test-api-key-redaction-check''',
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globalSetup: ['test/global-setup.ts'],
|
||||
include: ['test/**/*.test.ts'],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 120000,
|
||||
pool: 'forks',
|
||||
globals: true,
|
||||
setupFiles: ['test/setup.ts'],
|
||||
teardownTimeout: 3000,
|
||||
dangerouslyIgnoreUnhandledErrors: true, // LadybugDB N-API destructor segfaults on fork exit — not a test failure
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/cli/index.ts', // CLI entry point (commander wiring)
|
||||
'src/server/**', // HTTP server (requires network)
|
||||
'src/core/wiki/**', // Wiki generation (requires LLM)
|
||||
],
|
||||
// Auto-ratchet: vitest bumps thresholds when coverage exceeds them.
|
||||
// CI will fail if a PR drops below these floors.
|
||||
thresholds: {
|
||||
statements: 26,
|
||||
branches: 23,
|
||||
functions: 28,
|
||||
lines: 27,
|
||||
autoUpdate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit hook: format staged files + typecheck.
|
||||
# Tests run in CI (ci-tests.yml), not here.
|
||||
# Skip with: git commit --no-verify
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
# 1. Format staged files with prettier via lint-staged
|
||||
echo "pre-commit: formatting staged files..."
|
||||
"$ROOT/node_modules/.bin/lint-staged" || exit 1
|
||||
|
||||
# 2. Typecheck changed packages
|
||||
WEB_CHANGED=$(git diff --cached --name-only -- 'gitnexus-web/' | head -1)
|
||||
CLI_CHANGED=$(git diff --cached --name-only -- 'gitnexus/' | head -1)
|
||||
|
||||
if [ -n "$WEB_CHANGED" ]; then
|
||||
echo "pre-commit: typechecking gitnexus-web (tsc -b)..."
|
||||
cd "$ROOT/gitnexus-web" && ./node_modules/.bin/tsc -b --noEmit || exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$CLI_CHANGED" ]; then
|
||||
echo "pre-commit: typechecking gitnexus..."
|
||||
cd "$ROOT/gitnexus" && ./node_modules/.bin/tsc --noEmit || exit 1
|
||||
fi
|
||||
|
||||
echo "pre-commit: all checks passed"
|
||||
@@ -1,17 +0,0 @@
|
||||
dist/
|
||||
coverage/
|
||||
gitnexus/vendor/
|
||||
gitnexus/test/fixtures/
|
||||
gitnexus-web/test/fixtures/
|
||||
gitnexus-web/playwright-report/
|
||||
gitnexus-web/test-results/
|
||||
*.d.ts
|
||||
*.snap
|
||||
*.wasm
|
||||
*.md
|
||||
.gitnexus/
|
||||
.vercel/
|
||||
.claude-flow/
|
||||
.swarm/
|
||||
assets/
|
||||
repomix-output*
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "./gitnexus-web/src/index.css"
|
||||
}
|
||||
@@ -1,97 +1,56 @@
|
||||
<!-- version: 1.7.0 -->
|
||||
<!-- Last updated: 2026-04-23 -->
|
||||
|
||||
Last reviewed: 2026-04-23
|
||||
|
||||
**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. |
|
||||
|
||||
## Model Configuration
|
||||
|
||||
- **Primary:** Use a named model (e.g. Claude Sonnet 4.x). Avoid `Auto` or unversioned `latest` when reproducibility matters.
|
||||
- **Notes:** The GitNexus CLI indexer does not call an LLM.
|
||||
|
||||
## 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.
|
||||
|
||||
## Claude Code hooks
|
||||
|
||||
**PreToolUse** hooks can block tools (e.g. `git_commit`) until checks pass. Adapt to this repo: `cd gitnexus && npm test` before commit.
|
||||
|
||||
## Context budget
|
||||
|
||||
Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.md](CONTRIBUTING.md)**. If always-on rules grow, split into **`.cursor/rules/*.mdc`** (globs). **Cursor:** project-wide rules in `.cursor/index.mdc`. **Claude Code:** load `STANDARDS.md` only when needed.
|
||||
|
||||
## Reference docs
|
||||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
|
||||
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
|
||||
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
|
||||
- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below.
|
||||
|
||||
## PR Swarm Review (cross-CLI)
|
||||
|
||||
To run a production-readiness review of a GitNexus pull request from **any** AI CLI, follow
|
||||
the canonical, CLI-neutral spec **[`pr-swarm-review/orchestration.md`](pr-swarm-review/orchestration.md)**
|
||||
(seven read-only review personas under `pr-swarm-review/personas/`). It defines two
|
||||
execution modes with the same output contract: **Swarm mode** (parallel subagents, e.g.
|
||||
Claude Code) and **Solo mode** (one agent runs all lanes sequentially — Codex, Gemini,
|
||||
Cursor, Copilot, or any agent reading this file). Per-CLI entrypoints are thin wrappers
|
||||
listed in [`pr-swarm-review/README.md`](pr-swarm-review/README.md); edit review logic only
|
||||
in the canonical files, never in the wrappers. The review is read-only — it never edits,
|
||||
commits, or posts.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 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** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (1650 symbols, 4291 relationships, 125 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
@@ -102,77 +61,18 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
| Work in the Ingestion area (239 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
|
||||
| Work in the Extractors area (135 symbols) | `.claude/skills/generated/extractors/SKILL.md` |
|
||||
| Work in the Components area (112 symbols) | `.claude/skills/generated/components/SKILL.md` |
|
||||
| Work in the Lbug area (96 symbols) | `.claude/skills/generated/lbug/SKILL.md` |
|
||||
| Work in the Group area (94 symbols) | `.claude/skills/generated/group/SKILL.md` |
|
||||
| Work in the Cli area (92 symbols) | `.claude/skills/generated/cli/SKILL.md` |
|
||||
| Work in the Configs area (92 symbols) | `.claude/skills/generated/configs/SKILL.md` |
|
||||
| Work in the Type-extractors area (90 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` |
|
||||
| Work in the Hooks area (88 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
|
||||
| Work in the Unit area (80 symbols) | `.claude/skills/generated/unit/SKILL.md` |
|
||||
| Work in the Cpp area (73 symbols) | `.claude/skills/generated/cpp/SKILL.md` |
|
||||
| Work in the Scope-resolution area (72 symbols) | `.claude/skills/generated/scope-resolution/SKILL.md` |
|
||||
| Work in the Server area (66 symbols) | `.claude/skills/generated/server/SKILL.md` |
|
||||
| Work in the Local area (61 symbols) | `.claude/skills/generated/local/SKILL.md` |
|
||||
| Work in the Wiki area (60 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
|
||||
| Work in the Workers area (57 symbols) | `.claude/skills/generated/workers/SKILL.md` |
|
||||
| Work in the Embeddings area (56 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
|
||||
| Work in the Typescript area (53 symbols) | `.claude/skills/generated/typescript/SKILL.md` |
|
||||
| Work in the Storage area (51 symbols) | `.claude/skills/generated/storage/SKILL.md` |
|
||||
| Work in the Php area (48 symbols) | `.claude/skills/generated/php/SKILL.md` |
|
||||
- Re-index: `npx gitnexus analyze`
|
||||
- Check freshness: `npx gitnexus status`
|
||||
- Generate docs: `npx gitnexus wiki`
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
## Repo reference
|
||||
|
||||
### 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). |
|
||||
|
||||
### Running services
|
||||
|
||||
```bash
|
||||
cd gitnexus && npm run dev # CLI: tsx watch mode
|
||||
cd gitnexus-web && npm run dev # Web UI: Vite on port 5173
|
||||
npx gitnexus serve # HTTP API on port 4747 (from any indexed repo)
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
**Pre-commit hook** (`.husky/pre-commit`): formatting (prettier via lint-staged) + typecheck for staged packages. Tests do **not** run in pre-commit — CI only.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift, builds tree-sitter-proto). Native bindings need `python3`, `make`, `g++`.
|
||||
- `tree-sitter-kotlin` and `tree-sitter-swift` are optional — install warnings expected.
|
||||
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.
|
||||
|
||||
-419
@@ -1,419 +0,0 @@
|
||||
# Architecture — GitNexus
|
||||
|
||||
Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. |
|
||||
| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. |
|
||||
| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). |
|
||||
| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. |
|
||||
| `eval/` | Evaluation harnesses for benchmarking tool usage. |
|
||||
| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). |
|
||||
|
||||
## End-to-end flow: index → graph → tools
|
||||
|
||||
1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 14 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery.
|
||||
|
||||
2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches).
|
||||
|
||||
3. **Query layer** — three interfaces to the same backend:
|
||||
- **MCP (stdio):** `mcp.ts` → `LocalBackend` → tools (`tools.ts`) + resources (`resources.ts`)
|
||||
- **HTTP bridge:** `serve.ts` → Express (`api.ts`, `mcp-http.ts`) for web UI
|
||||
- **CLI direct:** `gitnexus query|context|impact|cypher` in `tool.ts`
|
||||
|
||||
4. **Staleness** — `staleness.ts` compares indexed `lastCommit` to `HEAD`, surfaces hints.
|
||||
|
||||
## MCP tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `list_repos` | Discover indexed repos |
|
||||
| `query` | Hybrid BM25 + vector search over the graph |
|
||||
| `cypher` | Ad hoc Cypher against the schema |
|
||||
| `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 |
|
||||
| `api_impact` | Pre-change impact report for an API route handler |
|
||||
| `route_map` | API route → handler → consumer mappings |
|
||||
| `tool_map` | MCP/RPC tool definitions and handlers |
|
||||
| `shape_check` | Response shape vs consumer property access mismatches |
|
||||
| `group_list` | List repo groups or details for one group |
|
||||
| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph |
|
||||
|
||||
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
|
||||
|
||||
| Resource URI | Purpose |
|
||||
|--------------|---------|
|
||||
| `gitnexus://group/{name}/contracts` | Contract Registry (provider/consumer rows + cross-links) |
|
||||
| `gitnexus://group/{name}/status` | Per-member index + Contract Registry staleness |
|
||||
|
||||
## Where to change what
|
||||
|
||||
| Concern | Start in |
|
||||
|---------|----------|
|
||||
| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) |
|
||||
| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` |
|
||||
| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) |
|
||||
| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` |
|
||||
| Cross-repo groups (sync, contracts, `@<group>` routing) | `src/core/group/` (`service.ts`, `cross-impact.ts`, `sync.ts`, `bridge-db.ts`) |
|
||||
| Search ranking | `src/core/search/` (BM25, hybrid fusion) |
|
||||
| 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` |
|
||||
| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) |
|
||||
| Type extraction | `src/core/ingestion/type-extractors/` |
|
||||
| Worker pool | `src/core/ingestion/workers/` |
|
||||
| Web UI | `gitnexus-web/src/` |
|
||||
| CI | `.github/workflows/*.yml`, `.github/actions/` |
|
||||
|
||||
> Paths above are relative to `gitnexus/` unless they start with `gitnexus-web/` or `.github/`.
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Phase DAG
|
||||
|
||||
14 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output.
|
||||
|
||||
```
|
||||
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
→ crossFile → scopeResolution → pruneLocalSymbols → mro → communities → processes
|
||||
```
|
||||
|
||||
| Phase | File | Deps | Output |
|
||||
|-------|------|------|--------|
|
||||
| `scan` | `scan.ts` | (root) | File paths + sizes |
|
||||
| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` |
|
||||
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
|
||||
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
|
||||
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
|
||||
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) |
|
||||
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
|
||||
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
|
||||
| `scopeResolution` | `scope-resolution/pipeline/phase.ts` | `parse`, `crossFile`, `structure` | Binding/reference + inheritance edges; disposes BindingAccumulator |
|
||||
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
|
||||
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
|
||||
**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `types.ts`, `runner.ts`, `index.ts`.
|
||||
|
||||
### DAG runner
|
||||
|
||||
`runner.ts` — static phase graph, no plugins, compile-time type safety.
|
||||
|
||||
1. **Validation** — Kahn's topological sort. Rejects on: duplicate names, missing deps, cycles (DFS traces the concrete cycle path, e.g., `A -> B -> C -> A`, plus count of transitively blocked dependents).
|
||||
|
||||
2. **Execution** — sequential in topological order. Each phase receives:
|
||||
- `ctx: PipelineContext` — shared mutable `KnowledgeGraph`, `repoPath`, progress callback, options
|
||||
- `deps: ReadonlyMap<string, PhaseResult>` — **declared deps only** (runner filters the results map to prevent hidden coupling)
|
||||
|
||||
3. **Error handling** — wraps phase errors with the phase name, emits terminal `error` progress event, swallows progress handler errors to preserve the original cause.
|
||||
|
||||
4. **Timing** — per-phase `durationMs` in `PhaseResult`, dev-mode console logging.
|
||||
|
||||
**Design patterns:**
|
||||
- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output.
|
||||
- **Typed phase access** — `getPhaseOutput<T>(deps, 'name')` for type-safe upstream results.
|
||||
- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership.
|
||||
- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests); `pruneLocalSymbols` still runs (it is graph cleanup, not analysis). `skipWorkers` is no longer a sequential escape hatch — it (like `--workers 0` / `GITNEXUS_WORKER_POOL_SIZE=0`) is rejected with an actionable error, since the worker pool is the sole parse path (§ Chunked parse-and-resolve).
|
||||
- **Local-symbol pruning** — `pruneLocalSymbols` removes inert block-local value symbols after scope resolution has consumed them. Opt out per-call with `PipelineOptions.keepLocalValueSymbols` or globally with the `GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS` env var.
|
||||
|
||||
### How to add a new phase
|
||||
|
||||
1. Create `pipeline-phases/my-phase.ts` with a `PipelinePhase<MyOutput>` (name, deps, execute)
|
||||
2. Export from `pipeline-phases/index.ts`
|
||||
3. Add to `buildPhaseList()` in `pipeline.ts`
|
||||
|
||||
```typescript
|
||||
import type { PipelinePhase, PhaseResult } from './types.js';
|
||||
import { getPhaseOutput } from './types.js';
|
||||
import type { ParseOutput } from './parse.js';
|
||||
|
||||
export interface MyPhaseOutput { /* ... */ }
|
||||
|
||||
export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
||||
name: 'myPhase',
|
||||
deps: ['parse'],
|
||||
async execute(ctx, deps) {
|
||||
const { allPaths } = getPhaseOutput<ParseOutput>(deps, 'parse');
|
||||
// ... write to ctx.graph ...
|
||||
return { /* typed output */ };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
`ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact the scope-resolution pipeline consumes. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
|
||||
|
||||
The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` for `Scope`-valued lookups (`classScopeByDefId`, `moduleScopeByFile`) that `SemanticModel` structurally cannot hold. No symbol-indexed duplicates exist outside `SemanticModel`.
|
||||
|
||||
**Write / read phase contract.** The model is mutable during three ordered phases and read-only afterward:
|
||||
|
||||
```
|
||||
Phase 1: 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.
|
||||
```
|
||||
|
||||
`runScopeResolution` narrows `MutableSemanticModel` → `SemanticModel` at the phase boundary so downstream passes physically cannot mutate the model even accidentally.
|
||||
|
||||
**Reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose parse-time extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose extractor already carries `ownerId` (C#).
|
||||
|
||||
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'`.
|
||||
|
||||
References: `semantic-model.ts` file-head (full write/read contract); `contract/scope-resolver.ts` Contract Invariant I9 (scope-resolution-side rule).
|
||||
|
||||
---
|
||||
|
||||
## 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.)
|
||||
|
||||
### Pipeline stages
|
||||
|
||||
```
|
||||
ParsedFile[] (extractParsedFile per file)
|
||||
│ finalizeScopeModel (+ provider hooks)
|
||||
▼
|
||||
ScopeResolutionIndexes
|
||||
│ resolveReferenceSites (via MethodRegistry.lookup)
|
||||
▼
|
||||
ReferenceIndex
|
||||
│ emitReceiverBoundCalls ── FIRST
|
||||
│ emitFreeCallFallback ── THEN
|
||||
│ emitReferencesViaLookup ── LAST (uses handledSites)
|
||||
│ emitImportEdges
|
||||
▼
|
||||
KnowledgeGraph (IMPORTS / CALLS / ACCESSES / INHERITS / USES)
|
||||
```
|
||||
|
||||
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.)
|
||||
|
||||
### `ScopeResolver` contract
|
||||
|
||||
Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`.
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `languageProvider` | Base `LanguageProvider` (tree-sitter query, `emitScopeCaptures`, import/binding interpreters, hooks) |
|
||||
| `populateOwners(parsed)` | Fill deferred `ownerId` fields on method defs (captures can't always know the owning class at parse time) |
|
||||
| `buildMro(graph, parsed, nodeLookup)` | Produce `mroByClassDefId: Map<DefId, DefId[]>` — C3, Ruby-mixin, or first-wins per language |
|
||||
| `resolveImportTarget(target, fromFile, allFiles)` | `(rawImportPath, sourceFile) → targetFilePath` (PEP-328 for Python, etc.) |
|
||||
| `mergeBindings(existing, incoming, scopeId)` | Shadowing / LEGB precedence |
|
||||
| `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 |
|
||||
| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field |
|
||||
| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) |
|
||||
| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) |
|
||||
| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — 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` |
|
||||
| `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 |
|
||||
|
||||
### Per-language registration
|
||||
|
||||
1. Implement `ScopeResolver` in `languages/<lang>/scope-resolver.ts`.
|
||||
2. Add entry to `SCOPE_RESOLVERS` in `scope-resolution/pipeline/registry.ts`.
|
||||
|
||||
CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
|
||||
### Code references
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `scope-resolution/contract/scope-resolver.ts` | `ScopeResolver` interface + shared types |
|
||||
| `scope-resolution/pipeline/run.ts` | Generic orchestrator |
|
||||
| `scope-resolution/pipeline/phase.ts` | Pipeline-phase wrapper (deps: `parse`, `structure`) |
|
||||
| `scope-resolution/pipeline/registry.ts` | `SCOPE_RESOLVERS` map |
|
||||
| `scope-resolution/passes/*.ts` | Reference-resolution passes (receiver-bound, free-call fallback, compound-receiver, MRO, cross-file return-type propagation) |
|
||||
| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges |
|
||||
| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets |
|
||||
| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index |
|
||||
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) |
|
||||
|
||||
### Performance notes
|
||||
|
||||
- **Cross-phase Tree cache**: the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) lets a scope-resolution per-language hook (`emitScopeCaptures`) reuse a tree instead of re-parsing. Workers leave it empty — Trees can't cross MessageChannels — so in normal (worker-pool) runs scope-resolution does NOT rely on it: workers serialize each file's `ParsedFile` (+ capture side-channel) and stream them in, so scope-resolution consumes the pre-extracted artifact rather than re-parsing on the main thread (§ Chunked parse-and-resolve). `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning.
|
||||
- **Typed relationship iteration**: heritage + MRO walk only the EXTENDS / IMPLEMENTS / HAS_METHOD edges via `iterRelationshipsByType`, not the full relationship map.
|
||||
- **Workspace-resolution-index**: O(1) `findOwnedMember` / `findExportedDef` / `classScopeByDefId` built once per run.
|
||||
- **SCC-ordered cross-file return-type propagation** (PR #1050): `propagateImportedReturnTypes` walks `indexes.sccs` in reverse-topological order (leaves first), so multi-hop alias chains like `models.User → service.user → app.user` collapse to the terminal class in a single linear pass. Within each importer, the source module's `typeBindings` is chain-followed BEFORE mirroring (so we mirror terminal types, not intermediate refs), and the importer's own `typeBindings` is chain-followed AFTER mirroring (so local `const x = importedFn()` resolves before downstream importers run). Cyclic SCCs reach a partial fixpoint within a single pass without iterating to convergence — see the `ts-circular` cross-file-binding fixture which only asserts pipeline-no-throw. PROF output (`PROF_SCOPE_RESOLUTION=1`) splits `finalize` from `propagate` so quadratic regressions in the chain-follow surface independently.
|
||||
|
||||
---
|
||||
|
||||
## Language-agnostic graph feeding
|
||||
|
||||
16 languages → single unified graph. Four abstraction layers:
|
||||
|
||||
```
|
||||
Unified Graph Schema (44 node types, 21 relationship types)
|
||||
↑
|
||||
Scope-Resolution Pipeline (registry lookup + 3-tier import resolution + MRO)
|
||||
↑
|
||||
Language Providers (import semantics, type config, export checker, MRO strategy)
|
||||
↑
|
||||
Tree-Sitter Queries (per-language S-expressions, unified capture tags)
|
||||
```
|
||||
|
||||
### Language providers
|
||||
|
||||
Each language implements `LanguageProvider` (`language-provider.ts`). Key fields:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `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` |
|
||||
|
||||
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
|
||||
### Unified capture tags
|
||||
|
||||
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@reference.inherits`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
|
||||
|
||||
### 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`).
|
||||
|
||||
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 |
|
||||
|
||||
### Chunked parse-and-resolve
|
||||
|
||||
`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk:
|
||||
1. Worker pool dispatches files (the sole parse path — there is no sequential fallback; `skipWorkers`, `--workers 0`, and `GITNEXUS_WORKER_POOL_SIZE=0` are rejected with an actionable error)
|
||||
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
|
||||
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
|
||||
4. Resolve imports
|
||||
5. Collect `BindingAccumulator` entries for cross-file propagation
|
||||
|
||||
Inheritance edges are emitted later, by the scope-resolution phase (`preEmitInheritanceEdges` + `emitHeritageEdges`), not during `parse`.
|
||||
|
||||
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
|
||||
|
||||
**Worker-serialized ParsedFiles (#2038).** To index very large repos (e.g. the Linux kernel) without OOM, the worker pool is the *sole* parse path and workers serialize each file's `ParsedFile` (plus its capture side-channel) in parallel, streaming them to scope-resolution through a disk-backed store. Scope-resolution consumes the pre-extracted artifact instead of re-parsing every file on the main thread — tree-sitter's native input buffers are not GC-reclaimable, so the former main-thread re-parse leaked native memory until the process died. Pool creation is lazy / cache-miss-gated, so a warm all-cache-hit run replays cached worker output without spawning a worker (hence `usedWorkerPool` can be false even when the repo has parseable files).
|
||||
|
||||
### Inheritance and MRO
|
||||
|
||||
Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy:
|
||||
- **`first-wins`** — Java, C#, C++, TS, Ruby, Go
|
||||
- **`c3`** — Python (C3 linearization)
|
||||
- **`ruby-mixin`** — Ruby (mixin-aware linearization)
|
||||
- **`none`** — single-inheritance languages
|
||||
|
||||
---
|
||||
|
||||
## Full analysis flow
|
||||
|
||||
`runFullAnalysis` in `run-analyze.ts` orchestrates everything around the pipeline:
|
||||
|
||||
```
|
||||
CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks)
|
||||
1. Early exit if lastCommit == HEAD (unless --force) [0%]
|
||||
2. Cache existing embeddings from prior index [0%]
|
||||
3. runPipelineFromRepo() → KnowledgeGraph [0-60%]
|
||||
4. Clean up legacy KuzuDB files [60%]
|
||||
5. initLbug() → loadGraphToLbug() via CSV streaming [60-85%]
|
||||
6. Create FTS indexes (File, Function, Class, Method...) [85-90%]
|
||||
7. Restore cached embeddings (batch insert) [88%]
|
||||
8. Generate new embeddings if --embeddings [90-98%]
|
||||
9. Save metadata + register repo + update .gitignore [98-100%]
|
||||
10. Generate AI context files (AGENTS.md, CLAUDE.md) [100%]
|
||||
```
|
||||
|
||||
**Options:** `--force` (rebuild regardless), `--embeddings` (opt-in, skipped if >50k nodes), `--skipGit`, `--noStats`.
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
<repo>/.gitnexus/
|
||||
├── lbug # LadybugDB database
|
||||
├── lbug.wal # Write-ahead log
|
||||
├── lbug.lock # Single-writer lock
|
||||
└── meta.json # lastCommit, indexedAt, stats
|
||||
|
||||
~/.gitnexus/
|
||||
└── registry.json # Global repo registry (MCP discovery)
|
||||
```
|
||||
|
||||
Managed by `repo-manager.ts`.
|
||||
|
||||
## LadybugDB schema
|
||||
|
||||
Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation` table.
|
||||
|
||||
**Node tables:** File, Folder, Function, Class, Interface, Method, Constructor, CodeElement, Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Template, Module, Community, Process, Route, Tool, Section, Embedding.
|
||||
|
||||
**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF.
|
||||
|
||||
## Embeddings and search
|
||||
|
||||
**Embeddings** (`src/core/embeddings/`): Snowflake arctic-embed-xs (384D). Embeddable: File, Function, Class, Method, Interface. Incremental via SHA1 content hash. Separate `Embedding` table.
|
||||
|
||||
**Search** (`src/core/search/`): Hybrid BM25 + semantic vector, merged via Reciprocal Rank Fusion (K=60).
|
||||
|
||||
## Known limitations
|
||||
|
||||
### Overloaded method resolution
|
||||
|
||||
Node IDs use arity suffix (`#<paramCount>`): `Method:file:Class.method#1` vs `#2`.
|
||||
|
||||
**Same-arity disambiguation:** type-hash suffix `~type1,type2` when collision detected and type annotations present. Languages without types (Python, Ruby, JS) use arity-only. TS/JS overload signatures excluded (collapse to implementation body). See #651.
|
||||
|
||||
**C++ const-qualified:** `$const` suffix after type-hash when non-const collision exists: `Method:file:Container.begin#0$const`.
|
||||
|
||||
**Generic/template types:** type-hash uses `rawType` (full AST text including generics): `~vector<int>` vs `~vector<std::string>`.
|
||||
|
||||
**ID stability:** collision-only tags mean IDs change when overloads are added. `save#1` becomes `save#1~int` when `save(String)` is added.
|
||||
|
||||
**Variadic matching:** confidence 0.7 when one side is variadic and the other has fixed count.
|
||||
|
||||
**METHOD_IMPLEMENTS confidence tiering:**
|
||||
|
||||
| Match quality | Confidence |
|
||||
|---|---|
|
||||
| Exact parameter types match | 1.0 |
|
||||
| Arity match, types unavailable | 1.0 |
|
||||
| Variadic vs fixed | 0.7 |
|
||||
| Insufficient info | 0.7 |
|
||||
|
||||
## Related docs
|
||||
|
||||
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance
|
||||
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery
|
||||
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents
|
||||
- [TESTING.md](TESTING.md) — how to run tests
|
||||
- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage
|
||||
@@ -2,63 +2,6 @@
|
||||
|
||||
All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- Migrated from KuzuDB to LadybugDB v0.15 (`@ladybugdb/core`, `@ladybugdb/wasm-core`)
|
||||
- Renamed all internal paths from `kuzu` to `lbug` (storage: `.gitnexus/kuzu` → `.gitnexus/lbug`)
|
||||
- Added automatic cleanup of stale KuzuDB index files
|
||||
- LadybugDB v0.15 requires explicit VECTOR extension loading for semantic search
|
||||
|
||||
## [1.5.3] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- **TypeScript/JavaScript MethodExtractor config** — shared extraction config covering abstract methods, visibility modifiers, async/override keywords, decorators, rest/optional/destructured parameters, and return types (#588) — @compound-ai
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Azure OpenAI compatibility** — use `max_completion_tokens` instead of deprecated `max_tokens` (newer models reject `max_tokens`); skip `temperature` for Azure provider (some models reject non-default values) (#618)
|
||||
- **Simplified Azure interactive setup** — 3 prompts (endpoint, deployment, key) instead of 7 (#618)
|
||||
- **Wiki HTML viewer script injection** — escape `</script>` in embedded JSON so LLM-generated markdown no longer breaks the viewer (#618)
|
||||
- Ensure import rewrites survive npm publish lifecycle
|
||||
|
||||
## [1.4.0] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Language-aware symbol resolution engine** with 3-tier resolver: exact FQN → scope-walk → guarded fuzzy fallback that refuses ambiguous matches (#238) — @magyargergo
|
||||
- **Method Resolution Order (MRO)** with 5 language-specific strategies: C++ leftmost-base, C#/Java class-over-interface, Python C3 linearization, Rust qualified syntax, default BFS (#238) — @magyargergo
|
||||
- **Constructor & struct literal resolution** across all languages — `new Foo()`, `User{...}`, C# primary constructors, target-typed new (#238) — @magyargergo
|
||||
- **Receiver-constrained resolution** using per-file TypeEnv — disambiguates `user.save()` vs `repo.save()` via `ownerId` matching (#238) — @magyargergo
|
||||
- **Heritage & ownership edges** — HAS_METHOD, OVERRIDES, Go struct embedding, Swift extension heritage, method signatures (`parameterCount`, `returnType`) (#238) — @magyargergo
|
||||
- **Language-specific resolver directory** (`resolvers/`) — extracted JVM, Go, C#, PHP, Rust resolvers from monolithic import-processor (#238) — @magyargergo
|
||||
- **Type extractor directory** (`type-extractors/`) — per-language type binding extraction with `Record<SupportedLanguages, Handler>` + `satisfies` dispatch (#238) — @magyargergo
|
||||
- **Export detection dispatch table** — compile-time exhaustive `Record` + `satisfies` pattern replacing switch/if chains (#238) — @magyargergo
|
||||
- **Language config module** (`language-config.ts`) — centralized tsconfig, go.mod, composer.json, .csproj, Swift package config loaders (#238) — @magyargergo
|
||||
- **Optional skill generation** via `npx gitnexus analyze --skills` — generates AI agent skills from KuzuDB knowledge graph (#171) — @zander-raycraft
|
||||
- **First-class C# support** — sibling-based modifier scanning, record/delegate/property/field/event declaration types (#163, #170, #178 via #237) — @Alice523, @benny-yamagata, @jnMetaCode
|
||||
- **C/C++ support fixes** — `.h` → C++ mapping, static-linkage export detection, qualified/parenthesized declarators, 48 entry point patterns (#163, #227 via #237) — @Alice523, @bitgineer
|
||||
- **Rust support fixes** — sibling-based `visibility_modifier` scanning for `pub` detection (#227 via #237) — @bitgineer
|
||||
- **Adaptive tree-sitter buffer sizing** — `Math.min(Math.max(contentLength * 2, 512KB), 32MB)` (#216 via #237) — @JasonOA888
|
||||
- **Call expression matching** in tree-sitter queries (#234 via #237) — @ex-nihilo-jg
|
||||
- **DeepSeek model configurations** (#217) — @JasonOA888
|
||||
- 282+ new unit tests, 178 integration resolver tests across 9 languages, 53 test files, 1146 total tests passing
|
||||
|
||||
### Fixed
|
||||
|
||||
- Skip unavailable native Swift parsers in sequential ingestion (#188) — @Gujiassh
|
||||
- Heritage heuristic language-gated — no longer applies class/interface rules to wrong languages (#238) — @magyargergo
|
||||
- C# `base_list` distinguishes EXTENDS vs IMPLEMENTS via symbol table + `I[A-Z]` heuristic (#238) — @magyargergo
|
||||
- Go `qualified_type` (`models.User`) correctly unwrapped in TypeEnv (#238) — @magyargergo
|
||||
- Global tier no longer blocks resolution when kind/arity filtering can narrow to 1 candidate (#238) — @magyargergo
|
||||
|
||||
### Changed
|
||||
|
||||
- `import-processor.ts` reduced from 1412 → 711 lines (50% reduction) via resolver and config extraction (#238) — @magyargergo
|
||||
- `type-env.ts` reduced from 635 → ~125 lines via type-extractor extraction (#238) — @magyargergo
|
||||
- CI/CD workflows hardened with security fixes and fork PR support (#222, #225) — @magyargergo
|
||||
|
||||
## [1.3.11] - 2026-03-08
|
||||
|
||||
### Security
|
||||
|
||||
@@ -1,79 +1,56 @@
|
||||
<!-- version: 1.3.0 -->
|
||||
<!--
|
||||
Metadata: version, last reviewed, scope, model policy, reference docs, changelog.
|
||||
Last updated: 2026-03-22
|
||||
-->
|
||||
|
||||
Last reviewed: 2026-04-13
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
Follow **AGENTS.md** for the canonical rules; this file adds Claude Code–specific deltas. Cursor-specific notes live only in `AGENTS.md`.
|
||||
|
||||
## Scope
|
||||
|
||||
See the **Scope** table in [AGENTS.md](AGENTS.md) for read/write/execute/off-limits boundaries. Cursor-specific workflow notes also live only in AGENTS.md.
|
||||
|
||||
## Model Configuration
|
||||
|
||||
- **Primary:** Pin per **Claude Code** / Anthropic org policy (explicit model id). Do not rely on an unversioned `latest` alias for governed workflows.
|
||||
- **Fallback:** As configured in Claude Code (organization default or user override).
|
||||
- **Notes:** The GitNexus CLI analyzer does not call an LLM.
|
||||
|
||||
## Execution Sequence (complex tasks)
|
||||
|
||||
Same discipline as [AGENTS.md](AGENTS.md): before large multi-step work, state which **AGENTS.md** / **GUARDRAILS.md** rules apply, current **Scope**, and planned validation commands (`npm test`, `tsc`, etc.). When pausing, summarize progress in the chat or a **local** scratch file (do not add `HANDOFF.md` to the repo), then `/clear` and resume with that summary.
|
||||
|
||||
## Claude Code hooks
|
||||
|
||||
Prefer **PreToolUse** hooks for hard gates (e.g. tests before `git_commit`). Adapt hook commands to `gitnexus/` npm scripts.
|
||||
|
||||
## 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
|
||||
|
||||
- **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md).
|
||||
- **Call & inheritance resolution:** See ARCHITECTURE.md § Scope-Resolution Pipeline. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` / `ScopeResolver` hooks instead (see AGENTS.md). (The legacy call-resolution DAG was removed in #942.)
|
||||
- **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## GitNexus rules
|
||||
|
||||
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** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (1650 symbols, 4291 relationships, 125 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
@@ -84,35 +61,18 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
| Work in the Ingestion area (239 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
|
||||
| Work in the Extractors area (135 symbols) | `.claude/skills/generated/extractors/SKILL.md` |
|
||||
| Work in the Components area (112 symbols) | `.claude/skills/generated/components/SKILL.md` |
|
||||
| Work in the Lbug area (96 symbols) | `.claude/skills/generated/lbug/SKILL.md` |
|
||||
| Work in the Group area (94 symbols) | `.claude/skills/generated/group/SKILL.md` |
|
||||
| Work in the Cli area (92 symbols) | `.claude/skills/generated/cli/SKILL.md` |
|
||||
| Work in the Configs area (92 symbols) | `.claude/skills/generated/configs/SKILL.md` |
|
||||
| Work in the Type-extractors area (90 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` |
|
||||
| Work in the Hooks area (88 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
|
||||
| Work in the Unit area (80 symbols) | `.claude/skills/generated/unit/SKILL.md` |
|
||||
| Work in the Cpp area (73 symbols) | `.claude/skills/generated/cpp/SKILL.md` |
|
||||
| Work in the Scope-resolution area (72 symbols) | `.claude/skills/generated/scope-resolution/SKILL.md` |
|
||||
| Work in the Server area (66 symbols) | `.claude/skills/generated/server/SKILL.md` |
|
||||
| Work in the Local area (61 symbols) | `.claude/skills/generated/local/SKILL.md` |
|
||||
| Work in the Wiki area (60 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
|
||||
| Work in the Workers area (57 symbols) | `.claude/skills/generated/workers/SKILL.md` |
|
||||
| Work in the Embeddings area (56 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
|
||||
| Work in the Typescript area (53 symbols) | `.claude/skills/generated/typescript/SKILL.md` |
|
||||
| Work in the Storage area (51 symbols) | `.claude/skills/generated/storage/SKILL.md` |
|
||||
| Work in the Php area (48 symbols) | `.claude/skills/generated/php/SKILL.md` |
|
||||
- Re-index: `npx gitnexus analyze`
|
||||
- Check freshness: `npx gitnexus status`
|
||||
- Generate docs: `npx gitnexus wiki`
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
-249
@@ -1,249 +0,0 @@
|
||||
# Contributing to GitNexus
|
||||
|
||||
How to propose changes, run checks locally, and open pull requests.
|
||||
|
||||
## License
|
||||
|
||||
This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0/). By contributing, you agree your contributions are licensed under the same terms unless stated otherwise.
|
||||
|
||||
## Where to discuss
|
||||
|
||||
- **Issues & feature ideas:** use [GitHub Issues](https://github.com/abhigyanpatwari/GitNexus/issues) for the upstream repo, or your fork’s tracker if you work from a fork.
|
||||
- **Community:** see the Discord link in the root [README.md](README.md).
|
||||
|
||||
## Development setup
|
||||
|
||||
**Prerequisites:** Node.js — `gitnexus/` requires `>=22.0.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
|
||||
|
||||
1. Clone the repository.
|
||||
2. **CLI / MCP package:** `cd gitnexus && npm install && npm run build`
|
||||
3. **Web UI (if needed):** `cd gitnexus-web && npm install`
|
||||
4. Run tests as described in [TESTING.md](TESTING.md).
|
||||
|
||||
### Containerized development (optional)
|
||||
|
||||
If you prefer an isolated environment with Claude Code, OpenAI Codex CLI, and Cursor CLI pre-installed, open the repo in VS Code with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) and run **Dev Containers: Reopen in Container**. See [`.devcontainer/README.md`](.devcontainer/README.md) for first-time auth flows and Windows WSL2 setup.
|
||||
|
||||
## Branch and pull requests
|
||||
|
||||
- Use short-lived branches off the default branch of the repo you are targeting.
|
||||
- **PR titles MUST follow the conventional-commit format** — `pr-labeler.yml` enforces this on every PR and auto-applies the matching label so release notes group the change correctly.
|
||||
- **PR description:** what changed, why, how to verify (commands), and any risk or rollback notes.
|
||||
|
||||
### Pull request titles
|
||||
|
||||
Format: `<type>[(scope)][!]: <subject>`
|
||||
|
||||
Allowed types and the release-notes section each one lands in (defined in `.github/release.yml`):
|
||||
|
||||
| Type | Label applied | Release-notes section |
|
||||
| ------------------ | --------------- | ------------------------------------------------------------ |
|
||||
| `feat` | `enhancement` | 🚀 Features |
|
||||
| `fix` | `bug` | 🐛 Bug Fixes |
|
||||
| `perf` | `performance` | 🏎️ Performance |
|
||||
| `refactor` | `refactor` | 🔄 Refactoring |
|
||||
| `test` | `test` | 🧪 Tests |
|
||||
| `ci` | `ci` | 👷 CI/CD |
|
||||
| `build` / `deps` | `dependencies` | 📦 Dependencies |
|
||||
| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) |
|
||||
| `chore` / `revert` | `chore` | (excluded from release notes) |
|
||||
|
||||
Append `!` to the type (e.g. `feat(api)!: drop /v1 endpoint`) or include `BREAKING CHANGE:` in the PR body to flag a breaking change — the labeler then adds the `breaking` label and the 💥 Breaking Changes section is rendered first.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
feat(web): add smart chat scroll
|
||||
fix(extractors): resolve silent contract mis-resolution
|
||||
perf: avoid O(n²) traversal in heritage walker
|
||||
chore(deps): bump vitest to 3.0.0
|
||||
ci: standardize workflow concurrency
|
||||
```
|
||||
|
||||
Commits within a PR may use any style — only the **merged PR title** shows up in release notes, so that's the one the convention applies to.
|
||||
|
||||
## Before you open a PR
|
||||
|
||||
- [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`).
|
||||
- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`.
|
||||
- [ ] No secrets, tokens, or machine-specific paths committed.
|
||||
- [ ] Documentation updated if behavior or public CLI/MCP contract changes.
|
||||
- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — formatting via lint-staged + typecheck for staged packages; tests run in CI only).
|
||||
|
||||
## Code review
|
||||
|
||||
Maintainers may request changes for correctness, tests, performance, or consistency with existing patterns. Keeping diffs focused makes review faster.
|
||||
|
||||
## GitHub Actions — Concurrency Convention
|
||||
|
||||
Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:` block using this convention:
|
||||
|
||||
- **Group key** starts with `${{ github.workflow }}` so no two workflows can collide on the same group name. The discriminator that follows is chosen per event shape:
|
||||
- Branch/tag scope: `${{ github.workflow }}-${{ github.ref }}`
|
||||
- Per-PR scope (for `issue_comment`, `pull_request_review*`, `pull_request` meta events): `${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}`
|
||||
- `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.
|
||||
- **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:**
|
||||
|
||||
| Event | `cancel-in-progress` | Why |
|
||||
| ---------------------------------------- | -------------------- | -------------------------------- |
|
||||
| `pull_request` CI run | `true` | New push supersedes old run |
|
||||
| `push` to `main` | `false` | Every main commit gets validated |
|
||||
| Tag push (`v*` publish) | `false` | Never cancel mid-publish |
|
||||
| `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish |
|
||||
| `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional |
|
||||
| `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race |
|
||||
| Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR |
|
||||
| PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins |
|
||||
| Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes |
|
||||
|
||||
- For workflows that serve multiple events at once (e.g. `ci.yml` handles `pull_request`, `push`, and `workflow_call`), make `cancel-in-progress` event-aware:
|
||||
|
||||
```yaml
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
```
|
||||
|
||||
- When adding a new workflow, copy the concurrency block from an existing workflow of the same event shape.
|
||||
|
||||
## CI automation contracts
|
||||
|
||||
Two workflows produce machine-readable signals on every PR. Coding agents and humans alike can rely on the names and shapes below — change them with intent.
|
||||
|
||||
### `gitnexus/autofix`
|
||||
|
||||
`pr-autofix.yml` (untrusted) + `pr-autofix-publish.yml` (trusted) run `prettier --write` and `eslint --fix` against the PR head and surface a single ChatOps button on the PR. Three signals are emitted:
|
||||
|
||||
| Surface | Where | Notes |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Sticky PR comment | Top-level comment with the HTML marker `<!-- gitnexus:pr-autofix-summary -->` and heading `## :sparkles: PR Autofix`. Only posted when there is something to fix; clean PRs stay silent. | Edit-in-place via marker; one comment per PR. |
|
||||
| Fenced JSON block | Inside the sticky, fenced as `gitnexus-autofix`. Schema `gitnexus.pr-autofix/v2` with fields `state` (`fixes-available`), `pr_number`, `head_sha`, `changed_lines`, `run_id`, and `apply_command` (literal `/autofix`). | Parseable signal — preferred over regexing prose. v1 fields preserved as a superset. |
|
||||
| Check Run | Stable name `gitnexus/autofix` on the PR head SHA. Conclusion: `success` (clean) or `neutral` (`fixes-available`). The neutral title is `Autofix available — comment /autofix to apply`. | Surfaced under PR Checks; readable via `gh pr checks <pr>`. |
|
||||
|
||||
To detect outcome from an agent: `gh pr checks <pr> --json name,conclusion,output | jq '.[] | select(.name == "gitnexus/autofix")'`.
|
||||
|
||||
Forks are supported. The untrusted half runs fork code with `permissions: {}` and ships the diff as an artifact; the trusted publish job consumes only the diff (data, not code) and posts the comment + check run.
|
||||
|
||||
#### Applying autofix
|
||||
|
||||
Comment `/autofix` on the PR (whole-line, no arguments). The `pr-autofix-apply.yml` workflow:
|
||||
|
||||
1. Validates the comment body matches `^/autofix\s*$` exactly. Quoted or inline mentions are silently ignored.
|
||||
2. Validates the commenter has `admin`, `write`, or `maintain` permission on the repo, OR is the PR author. Other commenters get a 👎 reaction and a refusal reply.
|
||||
3. Locates the most recent successful `pr-autofix.yml` run for the PR's current head SHA, downloads its `autofix` artifact, applies the patch, and pushes a `chore(autofix): ...` commit back to the PR head branch.
|
||||
4. Reacts ✅ on success, 👎 on stale-patch / push-failure, and posts a short reply with the apply-run URL in either case.
|
||||
|
||||
The apply workflow runs from the default branch's copy of the file regardless of where the comment originates — that's the trust anchor. There is no diff-size cap (the apply workflow uses `git apply` + push, not the GitHub review-comment API).
|
||||
|
||||
For fork PRs, the push succeeds only when the contributor has **Allow edits by maintainers** enabled on the PR (the default). When they have disabled it, the workflow fails loud with a 👎 reaction and an explanation comment.
|
||||
|
||||
Re-invoking `/autofix` after a successful apply is a safe no-op — the workflow detects the already-applied state via `git apply --check --reverse` and reacts ✅ without pushing.
|
||||
|
||||
**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.
|
||||
|
||||
## AI-assisted contributions
|
||||
|
||||
If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes.
|
||||
|
||||
## Releases
|
||||
|
||||
One workflow ships `gitnexus` to npm — `.github/workflows/publish.yml`. It
|
||||
routes between two modes based on the triggering event:
|
||||
|
||||
- **Stable mode** — triggered by pushing any `v<X.Y.Z>` tag (no `-rc.*`
|
||||
suffix; RC tags are excluded at trigger via a negative glob). Publishes to
|
||||
the `latest` dist-tag with a changelog-backed GitHub release. Maintainers
|
||||
are expected to tag from `main` as a convention; the workflow itself does
|
||||
not enforce branch reachability. No Docker build (RC-only). Before cutting a
|
||||
stable release, keep `gitnexus/package.json`,
|
||||
`gitnexus-claude-plugin/.claude-plugin/plugin.json`,
|
||||
`.claude-plugin/marketplace.json`, and the matching `CHANGELOG.md` entry in
|
||||
lockstep — the always-on `gitnexus` unit suite now fails if those manifest
|
||||
versions drift.
|
||||
- **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
|
||||
`X.Y.Z-rc.N` and a GitHub prerelease, where:
|
||||
- `X.Y.Z` is selected automatically. On push (and on dispatch with
|
||||
`bump: auto`, the default) the workflow **continues the active rc cycle**:
|
||||
if the registry already has `X.Y.Z-rc.*` versions with `X.Y.Z` > current
|
||||
`latest`, it reuses the highest such base; otherwise it patch-bumps
|
||||
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`.
|
||||
- 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
|
||||
`docker.io/akonlabs/gitnexus:1.7.0-rc.1`). The images are signed
|
||||
with Cosign; the OIDC identity is `docker.yml@refs/heads/main` (the
|
||||
caller's ref — see README.md § Docker for the verify command).
|
||||
|
||||
Idempotency: the workflow pushes an `rc/<HEAD_SHA>` marker tag and a
|
||||
`v<RC>` release tag **atomically, before** calling `npm publish`. The
|
||||
RC guard refuses to re-run once the marker exists, so a post-publish
|
||||
failure will not mint a duplicate rc for the same commit. The `v<RC>`
|
||||
tag points at a detached release commit whose `package.json` matches
|
||||
the npm tarball exactly (traceable releases). The RC tag is excluded
|
||||
from this workflow's `push: tags:` filter, so it does **not** re-trigger
|
||||
publishing — preventing the double-publish failure mode tracked in #1609.
|
||||
Recovery after a partial failure: the workflow's `if: failure()` cleanup
|
||||
step in the `publish` job auto-deletes the v-tag and marker on most
|
||||
post-publish failures, so the typical retry is just:
|
||||
|
||||
```bash
|
||||
gh workflow run publish.yml --ref main -f force=true
|
||||
# or push a new commit to main, which will cut a fresh RC
|
||||
```
|
||||
|
||||
If auto-cleanup didn't run (e.g. the cleanup step itself failed, or the
|
||||
failure happened in the route/rc-guard phase before the marker was
|
||||
pushed), manual cleanup is:
|
||||
|
||||
```bash
|
||||
git push --delete origin rc/<HEAD_SHA> v<RC>
|
||||
# then redispatch with force: true
|
||||
```
|
||||
|
||||
**Release-PR-skip subject pattern.** The rc-guard job recognizes a
|
||||
squash-merged release commit by matching the commit subject against
|
||||
`^chore: release vX.Y.Z` (optionally followed by ` (#NNNN)` for the
|
||||
squash-merge PR-number suffix). Match is case-insensitive — `Chore: Release v1.2.3`
|
||||
works too. PRs that should suppress the RC build must either use this
|
||||
subject shape, or carry the `release` label so the label-based fallback
|
||||
fires. Other release-style subjects (`chore(release): v1.2.3`,
|
||||
`release: v1.2.3`) will NOT trigger the skip — please name the release
|
||||
PR exactly `chore: release vX.Y.Z` to keep the dedup deterministic.
|
||||
|
||||
**Docker-only partial failure:** if `publish` succeeds (npm tarball + tags
|
||||
are live) but the `docker` job subsequently fails (e.g. GHCR flakiness),
|
||||
the npm RC is already published and the `rc/<HEAD_SHA>` marker is in place.
|
||||
Recovery without cutting a new RC:
|
||||
|
||||
```bash
|
||||
# Re-run only the failed docker job from the original workflow run:
|
||||
gh run rerun <run-id> --failed
|
||||
```
|
||||
|
||||
Find the run ID via `gh run list --workflow=publish.yml --branch main`.
|
||||
`docker.yml` intentionally has no `workflow_dispatch` trigger (images are
|
||||
tag-driven by design), so the gh-run-rerun path is the supported recovery.
|
||||
|
||||
**GitHub Release transient failure** (npm publish succeeded, Release step
|
||||
failed): the npm artifact is live but no GitHub Release page exists.
|
||||
Recover by either re-running the failed job (`gh run rerun <run-id> --failed`),
|
||||
or creating the Release manually:
|
||||
|
||||
```bash
|
||||
gh release create v<RC> --prerelease --generate-notes # RC
|
||||
gh release create v<X.Y.Z> --notes-file gitnexus/CHANGELOG.md # stable
|
||||
```
|
||||
|
||||
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags:
|
||||
|
||||
```bash
|
||||
npm view gitnexus dist-tags
|
||||
```
|
||||
@@ -1,209 +0,0 @@
|
||||
# Definition of Done — GitNexus
|
||||
|
||||
Last reviewed: 2026-04-23 · Version: 2.0.0
|
||||
|
||||
This document defines the repo-wide completion bar for production-ready changes in GitNexus. It is the stable baseline. Implementation prompts, agent behavior, and review workflows may add task-specific checks, but they must never weaken this bar.
|
||||
|
||||
Use it together with:
|
||||
|
||||
- `AGENTS.md` — agent-facing rules of engagement
|
||||
- `GUARDRAILS.md` — hard safety constraints
|
||||
- `CONTRIBUTING.md` — contributor workflow
|
||||
- `TESTING.md` — test strategy and coverage expectations
|
||||
- `ARCHITECTURE.md` — pipeline boundaries, Call-Resolution DAG, LanguageProvider contract
|
||||
|
||||
## 1. Scope and Intent
|
||||
|
||||
A change is **Done** when it is correct, safely integrated, appropriately tested, operationally sound, and a net improvement to the codebase — not merely "the code compiles and a test passes."
|
||||
|
||||
This DoD applies to:
|
||||
|
||||
- CLI, MCP, and HTTP-bridge behavior in `gitnexus/`
|
||||
- Browser UI in `gitnexus-web/`
|
||||
- Shared contracts in `gitnexus-shared/`
|
||||
- CI workflows, release pipelines, and repo-level docs
|
||||
|
||||
Out of scope: full agent personas, step-by-step implementation prompts, verbose review formatting rules, repo walkthroughs already covered elsewhere, temporary task-specific acceptance criteria. Those belong in prompts, PR templates, or other repo docs.
|
||||
|
||||
## 2. Core Definition of Done
|
||||
|
||||
Every change must satisfy **every relevant item** below. If an item does not apply, say so explicitly in the PR description.
|
||||
|
||||
### 2.1 Correctness and Completeness
|
||||
|
||||
- [ ] The requested behavior is implemented end-to-end in the **real runtime path** for the affected surface — no dead code, partial wiring, test-only shims, or "works in isolation but not in production" seams.
|
||||
- [ ] Edge cases relevant to the changed surface are handled or explicitly documented as out of scope.
|
||||
- [ ] Error handling is proportionate: inputs at system boundaries (user input, external APIs, filesystem, process spawn) are validated; internal, framework-guaranteed paths are trusted.
|
||||
- [ ] The change produces the same result on re-run (idempotent where expected) and does not rely on accidental ordering.
|
||||
|
||||
### 2.2 Architecture and Placement
|
||||
|
||||
- [ ] The change is placed in the correct package and layer:
|
||||
- `gitnexus/` for CLI, MCP, HTTP bridge, ingestion, graph, and runtime logic
|
||||
- `gitnexus-web/` for browser UI (thin client — no WASM workers, all queries via HTTP API)
|
||||
- `gitnexus-shared/` for shared contracts, types, and constants
|
||||
- [ ] Pipeline and architecture boundaries remain explicit. Shared ingestion code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks (see `AGENTS.md` and `ARCHITECTURE.md` § Call-Resolution DAG).
|
||||
- [ ] No hidden cross-phase coupling; no leaking of language-specific logic into shared infrastructure without a documented architectural reason.
|
||||
- [ ] Runtime and graph behavior are consistent — the real source of truth is fixed at the source, not symptom-patched in a downstream layer.
|
||||
- [ ] Direct imports from `gitnexus-shared` are used. No barrel re-exports introduced to paper over drift between packages.
|
||||
|
||||
### 2.3 Design and Readability
|
||||
|
||||
- [ ] The implementation is the **smallest correct solution** for the requirement. No speculative abstraction, unnecessary indirection, clever but hard-to-follow control flow, or unrelated cleanup.
|
||||
- [ ] Naming, control flow, ownership, and extension points are clear enough that the next contributor can extend the code without archaeology.
|
||||
- [ ] Comments are minimal and useful — they explain intent, invariants, contracts, or non-obvious constraints. No stale comments, placeholder comments, narrated code, commented-out code, or "what" comments where a good name would do.
|
||||
- [ ] No copy-paste duplication created for convenience; no premature deduplication of three similar lines.
|
||||
|
||||
### 2.4 Contracts and Compatibility
|
||||
|
||||
- [ ] Existing contracts (types in `gitnexus-shared/`, CLI flags, MCP tools/resources, HTTP routes, graph node/edge shapes, persisted IDs) are preserved unless the task explicitly requires a contract change.
|
||||
- [ ] Any contract change is intentional, explicit, and reflected in **every direct consumer** in the same change, with types aligned end-to-end.
|
||||
- [ ] Persisted data changes (graph schema, IDs, embeddings) are backward-compatible or accompanied by a documented migration / reindex path.
|
||||
- [ ] If user-visible behavior, public usage, CLI help, or README examples change, the relevant docs, examples, help text, or migration notes are updated in the same change.
|
||||
|
||||
### 2.5 Security
|
||||
|
||||
- [ ] No new injection surfaces (command, path, SQL/Cypher-style, prompt) introduced on paths that consume untrusted input.
|
||||
- [ ] No secrets, tokens, or credentials committed to the repo, to logs, or to error messages.
|
||||
- [ ] Filesystem access honors the repo-scope and indexed-repo boundaries documented in `AGENTS.md` and `GUARDRAILS.md`.
|
||||
- [ ] Third-party dependencies added or bumped are justified, from reputable sources, and do not regress the supply-chain posture.
|
||||
|
||||
### 2.6 Performance and Resource Use
|
||||
|
||||
- [ ] No repeated avoidable work, unnecessary scans, unnecessary round-trips, unbounded caches, or obvious hot-path regressions.
|
||||
- [ ] Tree-sitter buffer sizing follows the adaptive 512KB–32MB convention (`getTreeSitterBufferSize`) — do not hard-code new buffer sizes.
|
||||
- [ ] Memory and handle lifecycles are explicit: database handles (LadybugDB) close cleanly, no dangling process watchers, no leaked tree-sitter parsers.
|
||||
- [ ] Long-running or large-graph paths remain bounded or are measurably streamed; degradation on large real repos is considered, not assumed benign.
|
||||
|
||||
### 2.7 Tests
|
||||
|
||||
- [ ] Tests cover the **real changed path** — they would fail if behavior, wiring, or contracts were broken, not only if a mock were misconfigured.
|
||||
- [ ] Integration tests hit a real database where the production path does; do not introduce mocks that hide migration or schema drift.
|
||||
- [ ] Assertions are meaningful. Use `toBe` / `toEqual` for exact expectations; avoid `toBeGreaterThanOrEqual` and other bounds-only assertions that mask regressions.
|
||||
- [ ] Fixtures are realistic enough for the risk of the change — a one-file fixture is not sufficient for a pipeline-wide behavior change.
|
||||
- [ ] New tests are deterministic and do not depend on network, clock, or host-specific paths without explicit isolation.
|
||||
|
||||
### 2.8 Observability and Operability
|
||||
|
||||
- [ ] Errors surfaced to users or callers are actionable: they name what failed, what input was involved (without leaking secrets), and how to recover where possible.
|
||||
- [ ] Logging is proportionate — no noisy debug logs left in hot paths, no silent catches that swallow diagnostics.
|
||||
- [ ] CLI exit codes and MCP tool responses are correct for each outcome (success, user error, internal error).
|
||||
- [ ] Progress reporting (`PipelineProgress` and similar shared contracts) remains accurate after the change.
|
||||
|
||||
### 2.9 Reversibility and Risk
|
||||
|
||||
- [ ] The change has a clear rollback story: revert is safe, or migration is accompanied by a documented rollback / reindex procedure.
|
||||
- [ ] Residual risks, compatibility impacts, and operational concerns are either resolved or **clearly stated** in the PR description.
|
||||
- [ ] Destructive or hard-to-reverse operations (graph rebuild, schema change, `git` state manipulation) are opt-in or guarded.
|
||||
|
||||
## 3. Agent-Assisted Workflow Guardrails
|
||||
|
||||
When the change is produced with or reviewed by an AI agent, the following additional gates apply:
|
||||
|
||||
- [ ] **Scope match.** The final diff matches the intended symbols, files, and processes — no speculative refactors, unrelated formatting churn, or collateral edits outside the task scope.
|
||||
- [ ] **Evidence-based edits.** Claims about repo state are verified against the current code, not trusted from memory or stale documentation.
|
||||
- [ ] **Impact analysis.** Where GitNexus graph tooling is available and relevant, impact of non-trivial symbol, contract, or runtime-path changes is checked **before** editing.
|
||||
- [ ] **Embeddings preserved.** If an indexed repo already has embeddings and re-analysis is required, embeddings are preserved — not accidentally dropped by a destructive reindex.
|
||||
- [ ] **No false-done.** "Done" is claimed only after the Validation Baseline below has been run or any gap is explicitly named. Green tests on an unrelated path do not constitute validation.
|
||||
- [ ] **Five-axis self-review** before handing off: correctness, readability, architecture, security, performance.
|
||||
|
||||
## 4. Validation Baseline
|
||||
|
||||
Run the commands relevant to the touched area. If something cannot be run in the current environment, state it explicitly in the handoff.
|
||||
|
||||
### 4.1 Build ordering
|
||||
|
||||
- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action for this — local runs must match).
|
||||
|
||||
### 4.2 If `gitnexus/` changed
|
||||
|
||||
- [ ] `cd gitnexus && npx tsc --noEmit`
|
||||
- [ ] `cd gitnexus && npm test`
|
||||
- [ ] `cd gitnexus && npx prettier --check .` for files in the diff (pre-commit runs the affected-tests subset; do not expand scope)
|
||||
|
||||
### 4.3 If `gitnexus-web/` changed
|
||||
|
||||
- [ ] `cd gitnexus-web && npx tsc -b --noEmit`
|
||||
- [ ] `cd gitnexus-web && npm test`
|
||||
- [ ] `cd gitnexus-web && npm run test:e2e` when browser flows or user-facing UI behavior changed
|
||||
|
||||
### 4.4 If `gitnexus-shared/` changed
|
||||
|
||||
- [ ] Shared package builds cleanly (`npm run build` in `gitnexus-shared/`)
|
||||
- [ ] Dependent packages still typecheck and test after the shared change — verify both CLI and web consumers together
|
||||
|
||||
### 4.5 If CI workflows or release pipelines changed
|
||||
|
||||
- [ ] The workflow passes a dry-run or triggered run before merge; concurrency (`cancel-in-progress`) and the `setup-gitnexus` action remain wired correctly.
|
||||
- [ ] `CHANGELOG.md` is **not** edited here — it is owned by the release process.
|
||||
|
||||
## 5. Review Gates
|
||||
|
||||
A reviewer (human or agent) should be able to answer **yes** to each of the following before approving:
|
||||
|
||||
1. **Correctness** — Does the change do what it claims on the real runtime path?
|
||||
2. **Readability** — Will the next contributor understand this in six months without asking?
|
||||
3. **Architecture** — Is it in the right package, layer, and phase? Are boundaries respected?
|
||||
4. **Security** — No new injection, leak, or trust-boundary violation?
|
||||
5. **Performance** — No obvious regression on realistic inputs?
|
||||
6. **Tests** — Would a regression in the changed behavior fail loudly?
|
||||
7. **Scope** — Does the diff match the intended change, with no unrelated churn?
|
||||
|
||||
## 6. "Not Done" Signals
|
||||
|
||||
A change is **not** Done if any of the following is true, even if CI is green:
|
||||
|
||||
- The runtime path is not actually exercised by the tests.
|
||||
- A contract drifted between `gitnexus/`, `gitnexus-web/`, and `gitnexus-shared/` and only one side was updated.
|
||||
- A language-specific concern leaked into shared ingestion code.
|
||||
- The diff contains unrelated reformatting, refactors, or cleanup beyond the stated task.
|
||||
- Logs, comments, or TODOs were added as placeholders for work not done.
|
||||
- The change depends on a manual step that is not documented.
|
||||
- `CHANGELOG.md` was edited during PR work.
|
||||
- Pre-commit, prettier, or typecheck was bypassed without explicit justification.
|
||||
|
||||
## 7. Task-Specific DoD Template
|
||||
|
||||
Use this in implementation and review prompts. Keep it short and tailor it to the actual change:
|
||||
|
||||
```md
|
||||
# Definition of Done for this implementation
|
||||
|
||||
- [ ] Runtime wiring is complete for the affected path.
|
||||
- [ ] Requested behavior is correct and relevant contracts are preserved or explicitly updated.
|
||||
- [ ] The design stays scoped, readable, and proportionate to the task.
|
||||
- [ ] Tests prove the changed behavior and catch broken wiring.
|
||||
- [ ] Required validation for touched packages has been run, or any gap is explicitly noted.
|
||||
- [ ] Repo boundaries, security, performance, and operational safety are respected.
|
||||
- [ ] The diff contains only the intended change — no unrelated churn.
|
||||
```
|
||||
|
||||
## 8. How to Use This File in Claude Review
|
||||
|
||||
Reference this file as the repo-wide completion bar. Add a task-specific review instruction such as:
|
||||
|
||||
```md
|
||||
Review this change against `DoD.md` and the repo docs (`AGENTS.md`, `GUARDRAILS.md`,
|
||||
`CONTRIBUTING.md`, `TESTING.md`, `ARCHITECTURE.md`). Treat `DoD.md` as the minimum
|
||||
bar for production readiness. Flag anything that is partially wired, contract-unsafe,
|
||||
under-tested, architecturally misplaced, scope-creeping, or harder to maintain than
|
||||
necessary. Apply the five-axis review gate: correctness, readability, architecture,
|
||||
security, performance.
|
||||
```
|
||||
|
||||
## 9. Evolution
|
||||
|
||||
This DoD is living. Revisit it when:
|
||||
|
||||
- A class of incident slips past it (add a gate).
|
||||
- A gate becomes consistently ceremonial without catching issues (remove or merge it).
|
||||
- The architecture evolves in a way that changes what "done" means (update placement, validation, or contracts sections).
|
||||
|
||||
Track material updates in the changelog below. Keep the file tight — if it grows past a single read-in-one-sitting, something has drifted into the wrong place.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Change |
|
||||
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-04-23 | 2.0.0 | Restructured into numbered sections; added Security, Observability, Reversibility, Agent-Assisted Guardrails, Review Gates, Not-Done Signals; expanded validation baseline (shared-first build, prettier, CI workflow checks). |
|
||||
| 2026-04-13 | 1.0.0 | Initial repo-wide Definition of Done. |
|
||||
@@ -1,80 +0,0 @@
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETPLATFORM
|
||||
# Pinned npm version used to replace the bundled npm in the upstream Node
|
||||
# image. Bumping requires a coordinated update in Dockerfile.web and
|
||||
# gitnexus/Dockerfile.test so all images bootstrap the same npm.
|
||||
ARG NPM_VERSION=11.14.1
|
||||
|
||||
# -- Builder -----------------------------------------------------------
|
||||
# Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for
|
||||
# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain.
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
|
||||
ARG NPM_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
|
||||
|
||||
# Toolchain for node-gyp / native builds.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Build gitnexus-shared first - gitnexus depends on it as a workspace.
|
||||
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
|
||||
RUN npm ci --prefix gitnexus-shared
|
||||
COPY gitnexus-shared ./gitnexus-shared
|
||||
RUN rm -f gitnexus-shared/tsconfig.tsbuildinfo
|
||||
RUN npm run build --prefix gitnexus-shared
|
||||
|
||||
# Copy the full gitnexus package before installing - `npm ci` triggers
|
||||
# `postinstall` (patches tree-sitter-swift, builds the vendored
|
||||
# tree-sitter-proto) and `prepare` (compiles TypeScript via scripts/build.js),
|
||||
# both of which need the source tree.
|
||||
COPY gitnexus ./gitnexus
|
||||
RUN npm ci --prefix gitnexus
|
||||
|
||||
# Drop dev dependencies for a smaller runtime layer.
|
||||
RUN npm prune --omit=dev --prefix gitnexus
|
||||
|
||||
# -- Runtime -----------------------------------------------------------
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
|
||||
# curl for the healthcheck; git for cloning; ca-certificates for TLS verification.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Pre-create the data directory and hand it to the unprivileged `node` user
|
||||
# so the bind-mounted volume is writable without root.
|
||||
RUN mkdir -p /data/gitnexus && chown -R node:node /data
|
||||
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/dist ./gitnexus/dist
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/node_modules ./gitnexus/node_modules
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/package.json
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/scripts/install-duckdb-extension.mjs ./gitnexus/scripts/install-duckdb-extension.mjs
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
|
||||
|
||||
# Expose the `gitnexus` binary on PATH so the documented Docker workflow
|
||||
# (`docker compose exec gitnexus-server gitnexus index /workspace/<repo>`)
|
||||
# works without users having to invoke `node /app/gitnexus/dist/cli/index.js`.
|
||||
# `npm prune --omit=dev` in the builder stage strips `node_modules/.bin/`
|
||||
# entries, so the `gitnexus` bin declared in package.json (`dist/cli/index.js`,
|
||||
# which already carries `#!/usr/bin/env node` and 755 perms) is otherwise
|
||||
# unreachable from $PATH.
|
||||
RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus
|
||||
|
||||
USER node
|
||||
|
||||
# The web UI defaults to http://localhost:4747 - keep that contract.
|
||||
ENV GITNEXUS_HOME=/data/gitnexus \
|
||||
NODE_ENV=production \
|
||||
PORT=4747
|
||||
|
||||
EXPOSE 4747
|
||||
|
||||
# Bind to 0.0.0.0 so the server is reachable from the host's mapped port.
|
||||
CMD ["node", "gitnexus/dist/cli/index.js", "serve", "--host", "0.0.0.0", "--port", "4747"]
|
||||
@@ -1,48 +0,0 @@
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETPLATFORM
|
||||
# Pinned npm version — keep in sync with Dockerfile.cli and
|
||||
# gitnexus/Dockerfile.test.
|
||||
ARG NPM_VERSION=11.14.1
|
||||
|
||||
# node:22-bookworm-slim
|
||||
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
|
||||
ARG NPM_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
|
||||
|
||||
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
|
||||
RUN npm ci --prefix gitnexus-shared
|
||||
|
||||
COPY gitnexus-shared ./gitnexus-shared
|
||||
RUN npm run build --prefix gitnexus-shared
|
||||
|
||||
COPY gitnexus/package.json ./gitnexus/
|
||||
|
||||
COPY gitnexus-web/package.json gitnexus-web/package-lock.json ./gitnexus-web/
|
||||
RUN npm ci --prefix gitnexus-web
|
||||
|
||||
COPY gitnexus-web ./gitnexus-web
|
||||
RUN npm run build --prefix gitnexus-web
|
||||
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/gitnexus-web/dist ./dist
|
||||
COPY docker-server.mjs ./docker-server.mjs
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 4173
|
||||
|
||||
CMD ["node", "docker-server.mjs"]
|
||||
@@ -1,91 +0,0 @@
|
||||
# Guardrails — GitNexus
|
||||
|
||||
Rules for **human contributors** and **AI agents**. Complements `AGENTS.md` (workflows) and `CONTRIBUTING.md` (PR process).
|
||||
|
||||
## Scope (least privilege)
|
||||
|
||||
- **Read:** Source, tests, docs, public config as needed.
|
||||
- **Write:** Only files required for the fix or feature; no unrelated formatting or refactors.
|
||||
- **Execute:** Tests, typecheck, documented CLI commands. No destructive commands on user data without approval.
|
||||
- **Off-limits:** Other people's machines, production deployments you don't own, credentials you lack permission to use.
|
||||
|
||||
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.
|
||||
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 `.gitnexus/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).
|
||||
|
||||
---
|
||||
|
||||
## Signs (recurring failure patterns)
|
||||
|
||||
Format: **Trigger → Instruction → Reason**. Append new Signs when the same mistake repeats.
|
||||
|
||||
### Stale graph after edits
|
||||
|
||||
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
|
||||
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB.
|
||||
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
|
||||
|
||||
### Index seems corrupt or "incremental" is misbehaving
|
||||
|
||||
- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash.
|
||||
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
|
||||
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
|
||||
|
||||
### Embeddings vanished after analyze
|
||||
|
||||
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.
|
||||
- **Do:** Re-run `npx gitnexus analyze --embeddings` to regenerate. Check the analyze log for a `Warning: could not load cached embeddings` line — if present, the cache restore failed (corrupt DB / schema mismatch) and the rebuild had nothing to preserve. If you intentionally passed `--drop-embeddings`, this is expected.
|
||||
- **Why:** Plain `analyze` preserves prior vectors by re-inserting them after the rebuild; the only ways to end up at zero are an explicit `--drop-embeddings`, a cache-load failure (now logged), or a model/dimension change that invalidates the cache.
|
||||
|
||||
### MCP lists no repos
|
||||
|
||||
- **Trigger:** MCP stderr says no indexed repos.
|
||||
- **Do:** `npx gitnexus analyze` in the target repo; verify `npx gitnexus list` shows it.
|
||||
- **Why:** MCP discovers repos via `~/.gitnexus/registry.json`, populated by analyze.
|
||||
|
||||
### Wrong repo in multi-repo setups
|
||||
|
||||
- **Trigger:** Query/impact results belong to another project.
|
||||
- **Do:** Call `list_repos`, then pass `repo` on subsequent tools.
|
||||
- **Why:** Default target is ambiguous when multiple repos are registered.
|
||||
|
||||
### LadybugDB lock / "database busy"
|
||||
|
||||
- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run.
|
||||
- **Do:** Stop overlapping processes (one writer at a time). Retry analyze or restart MCP.
|
||||
- **Why:** Embedded DB expects single-process ownership.
|
||||
|
||||
---
|
||||
|
||||
## Publishing & supply chain
|
||||
|
||||
- **npm:** Do not publish from unreviewed automation. Bump version intentionally; tag releases to match `package.json`.
|
||||
- **Dependencies:** Minimal, auditable `package.json` changes; run tests and CI after lockfile updates.
|
||||
- **License:** PolyForm Noncommercial 1.0.0 — do not relicense without maintainer approval.
|
||||
|
||||
---
|
||||
|
||||
## Escalation
|
||||
|
||||
Stop and ask a **human maintainer** when:
|
||||
|
||||
- Impact analysis shows HIGH/CRITICAL risk and the task still requires the change.
|
||||
- You need to alter CI, release, or security-sensitive config.
|
||||
- Requirements conflict (e.g. "speed up analyze" vs "must keep all embeddings on huge repo").
|
||||
- You are unsure whether data loss is acceptable (`clean`, forced migrations, schema changes).
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — components and data flow
|
||||
- [RUNBOOK.md](RUNBOOK.md) — commands for recovery
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — PR and commit expectations
|
||||
@@ -1,71 +0,0 @@
|
||||
# Migration Guide
|
||||
|
||||
## `impact` tool may now return `{ status: 'ambiguous' }` (PR #888, issue #470)
|
||||
|
||||
Before this change the `impact` MCP tool silently picked the first match
|
||||
when the `target` name hit multiple symbols (Class → Interface → Function
|
||||
→ Method → Constructor priority UNION). This often produced analysis for
|
||||
the wrong symbol with no signal back to the caller.
|
||||
|
||||
After this change, when the resolver finds more than one viable match
|
||||
and the caller supplied none of `target_uid` / `file_path` / `kind`,
|
||||
`impact` returns a disambiguation response shaped like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ambiguous",
|
||||
"message": "Found N symbols matching '<target>'. Use target_uid, file_path, or kind to disambiguate.",
|
||||
"target": { "name": "<target>" },
|
||||
"direction": "upstream",
|
||||
"impactedCount": 0,
|
||||
"risk": "UNKNOWN",
|
||||
"candidates": [
|
||||
{ "uid": "...", "name": "...", "kind": "Function", "filePath": "...", "line": 42, "score": 0.76 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Do I need to migrate?
|
||||
|
||||
**Probably not, but check for assumptions.** Callers that unconditionally
|
||||
read `result.byDepth` / `result.summary` / `result.affected_processes`
|
||||
without first checking `result.status` will now see `undefined` in the
|
||||
ambiguous case. The fix is to branch on `result.status === 'ambiguous'`
|
||||
first and follow up with `target_uid` (preferred) or `file_path` / `kind`.
|
||||
|
||||
The `context` tool's ambiguous response is a strict superset of the
|
||||
existing shape — every candidate gains a `score` field, no existing field
|
||||
has changed. No migration required for `context` callers.
|
||||
|
||||
### What happens on re-index?
|
||||
|
||||
Nothing — this is an MCP-surface change only. The graph schema, indexer,
|
||||
and stored data are untouched.
|
||||
|
||||
---
|
||||
|
||||
## OVERRIDES → METHOD_OVERRIDES (PR #642)
|
||||
|
||||
The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for
|
||||
consistency with the new `METHOD_IMPLEMENTS` edge type.
|
||||
|
||||
### Do I need to migrate?
|
||||
|
||||
**No.** Backward compatibility is handled automatically at runtime:
|
||||
|
||||
- `local-backend.ts` dual-reads both `OVERRIDES` and `METHOD_OVERRIDES` in all
|
||||
impact-analysis and context queries. Existing stored graphs with `OVERRIDES`
|
||||
edges continue to return correct results without any manual intervention.
|
||||
- The `REL_TYPES` array in `schema-constants.ts` includes both names so Cypher
|
||||
queries that reference either will work.
|
||||
|
||||
### What happens on re-index?
|
||||
|
||||
Running `npx gitnexus analyze` on a repository produces `METHOD_OVERRIDES`
|
||||
edges going forward. The old `OVERRIDES` edges are replaced as part of the
|
||||
normal full re-index.
|
||||
|
||||
### When will the legacy alias be removed?
|
||||
|
||||
The `OVERRIDES` compat alias will remain until a future major version. Removal
|
||||
will be announced in this file and in the changelog before it happens.
|
||||
@@ -1,6 +1,5 @@
|
||||
# GitNexus
|
||||
|
||||
**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -10,7 +9,7 @@
|
||||
|
||||
<h2>Join the official Discord to discuss ideas, issues etc!</h2>
|
||||
|
||||
<a href="https://discord.gg/MgJrmsqr62">
|
||||
<a href="https://discord.gg/AAsRVT6fGb">
|
||||
<img src="https://img.shields.io/discord/1477255801545429032?color=5865F2&logo=discord&logoColor=white" alt="Discord"/>
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/gitnexus">
|
||||
@@ -19,14 +18,6 @@
|
||||
<a href="https://polyformproject.org/licenses/noncommercial/1.0.0/">
|
||||
<img src="https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg" alt="License: PolyForm Noncommercial"/>
|
||||
</a>
|
||||
<a href="https://securityscorecards.dev/viewer/?uri=github.com/abhigyanpatwari/GitNexus">
|
||||
<img src="https://api.securityscorecards.dev/projects/github.com/abhigyanpatwari/GitNexus/badge" alt="OpenSSF Scorecard"/>
|
||||
</a>
|
||||
<a href="https://github.com/abhigyanpatwari/GitNexus/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/abhigyanpatwari/GitNexus/actions/workflows/ci.yml/badge.svg" alt="CI Workflows"/>
|
||||
</a>
|
||||
|
||||
<p><strong>Enterprise (SaaS & Self-hosted)</strong> - <a href="https://akonlabs.com">akonlabs.com</a></p>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -34,11 +25,16 @@
|
||||
|
||||
Indexes any codebase into a knowledge graph — every dependency, call chain, cluster, and execution flow — then exposes it through smart tools so AI agents never miss code.
|
||||
|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
|
||||
> _Like DeepWiki, but deeper._ DeepWiki helps you _understand_ code. GitNexus lets you _analyze_ it — because a knowledge graph tracks every relationship, not just descriptions.
|
||||
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Antigravity, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models.
|
||||
|
||||
> *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions.
|
||||
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models.
|
||||
|
||||
---
|
||||
|
||||
@@ -46,54 +42,23 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
|
||||
[](https://www.star-history.com/#abhigyanpatwari/GitNexus&type=date&legend=top-left)
|
||||
|
||||
|
||||
## Two Ways to Use GitNexus
|
||||
|
||||
| | **CLI + MCP** | **Web UI** |
|
||||
| ----------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Antigravity, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
|
||||
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
|
||||
| **Privacy** | Everything local, no network | Everything in-browser, no server |
|
||||
| | **CLI + MCP** | **Web UI** |
|
||||
| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install —[gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | KuzuDB native (fast, persistent) | KuzuDB WASM (in-memory, per session) |
|
||||
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
|
||||
| **Privacy** | Everything local, no network | Everything in-browser, no server |
|
||||
|
||||
> **Bridge mode:** `gitnexus serve` connects the two — the web UI auto-detects the local server and can browse all your CLI-indexed repos without re-uploading or re-indexing.
|
||||
|
||||
---
|
||||
|
||||
## Enterprise
|
||||
|
||||
GitNexus is available as an **enterprise offering** - either as a fully managed **SaaS** or a **self-hosted** deployment. Also available for **commercial use** of the OSS version with proper licensing.
|
||||
|
||||
Enterprise includes:
|
||||
|
||||
- **PR Review** - automated blast radius analysis on pull requests
|
||||
- **Auto-updating Code Wiki** - always up-to-date documentation (Code Wiki is also available in OSS)
|
||||
- **Auto-reindexing** - knowledge graph stays fresh automatically
|
||||
- **Multi-repo support** - unified graph across repositories
|
||||
- **OCaml support** - additional language coverage
|
||||
- **Priority feature/language support** - request new languages or features
|
||||
|
||||
**Upcoming:**
|
||||
|
||||
- Auto regression forensics
|
||||
- End-to-end test generation
|
||||
|
||||
👉 Learn more at [akonlabs.com](https://akonlabs.com)
|
||||
|
||||
💬 For commercial licensing or enterprise inquiries, ping us on [Discord](https://discord.gg/AAsRVT6fGb) or drop an email at founders@akonlabs.com
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — packages, index → graph → MCP flow, where to change code
|
||||
- [RUNBOOK.md](RUNBOOK.md) — analyze, embeddings, stale index, MCP recovery, CI snippets
|
||||
- [GUARDRAILS.md](GUARDRAILS.md) — safety rules and operational “Signs” for contributors and agents
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — license, setup, commits, and pull requests
|
||||
- [TESTING.md](TESTING.md) — test commands for `gitnexus` and `gitnexus-web`
|
||||
|
||||
## CLI + MCP (recommended)
|
||||
|
||||
The CLI indexes your repository and runs an MCP server that gives AI agents deep codebase awareness.
|
||||
@@ -107,67 +72,35 @@ npx gitnexus analyze
|
||||
|
||||
That's it. This indexes the codebase, installs agent skills, registers Claude Code hooks, and creates `AGENTS.md` / `CLAUDE.md` context files — all in one command.
|
||||
|
||||
> **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).
|
||||
|
||||
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
|
||||
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
|
||||
|
||||
### MCP Setup
|
||||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
|
||||
|
||||
### Editor Support
|
||||
|
||||
| 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 | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| --------------------- | --- | ------ | -------------------- | -------------- |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits.
|
||||
|
||||
<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.
|
||||
### Community Integrations
|
||||
|
||||
## Community Integrations
|
||||
|
||||
Built by the community — not officially maintained, but worth checking out.
|
||||
|
||||
| Project | Author | Description |
|
||||
| ----------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | [@tintinweb](https://github.com/tintinweb) | GitNexus plugin for [pi](https://pi.dev) — `pi install npm:pi-gitnexus` |
|
||||
| [gitnexus-stable-ops](https://github.com/ShunsukeHayashi/gitnexus-stable-ops) | [@ShunsukeHayashi](https://github.com/ShunsukeHayashi) | Stable ops & deployment workflows (Miyabi ecosystem) |
|
||||
|
||||
> Have a project built on GitNexus? Open a PR to add it here!
|
||||
| Agent | Install | Source |
|
||||
|-------|---------|--------|
|
||||
| [pi](https://pi.dev) | `pi install npm:pi-gitnexus` | [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) |
|
||||
|
||||
If you prefer manual configuration:
|
||||
|
||||
> **Recommended for fastest startup:** install gitnexus globally (`npm i -g gitnexus`) and run `gitnexus setup` — this writes an absolute-path MCP config that bypasses `npx` entirely. The pinned-`npx` snippets below are a quickstart fallback; on a cold cache the `npx` install can exceed Claude Code's `MCP_TIMEOUT` default (~30s).
|
||||
|
||||
**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):
|
||||
|
||||
```bash
|
||||
codex mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
```
|
||||
|
||||
**Cursor** (`~/.cursor/mcp.json` — global, works for all projects):
|
||||
@@ -183,11 +116,11 @@ codex mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
}
|
||||
```
|
||||
|
||||
**Antigravity** (Google) — `~/.gemini/antigravity/mcp_config.json`:
|
||||
**OpenCode** (`~/.config/opencode/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcp": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
@@ -196,47 +129,13 @@ codex mcp add gitnexus -- npx -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/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"gitnexus": {
|
||||
"type": "local",
|
||||
"command": ["gitnexus", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Codex** (`~/.codex/config.toml` for system scope, or `.codex/config.toml` for project scope):
|
||||
|
||||
```toml
|
||||
[mcp_servers.gitnexus]
|
||||
command = "npx"
|
||||
args = ["-y", "gitnexus@latest", "mcp"]
|
||||
```
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --force # Force full re-index
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --skip-skills # Skip installing .claude/skills/gitnexus/ skill files
|
||||
gitnexus analyze --default-branch develop # Branch used in the generated regression-compare example (base_ref)
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16, auto-sized to the repo). 0 is rejected — there is no sequential mode.
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
gitnexus list # List all indexed repositories
|
||||
@@ -246,121 +145,28 @@ gitnexus clean --all --force # Delete all indexes
|
||||
gitnexus wiki [path] # Generate repository wiki from knowledge graph
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
|
||||
gitnexus publish # Notify the understand-quickly registry (opt-in, see below)
|
||||
|
||||
# Repository groups (multi-repo / monorepo service tracking)
|
||||
gitnexus group create <name> # Create a repository group
|
||||
gitnexus group add <group> <groupPath> <registryName> # Add a repo to a group. <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 from a group 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
|
||||
```
|
||||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
#### Embeddings node limit
|
||||
|
||||
`gitnexus analyze --embeddings` generates semantic search vectors with a default 50,000-node safety cap to protect memory on large repositories. Override the cap when you know the host has enough memory for a larger graph, or disable it entirely for a one-off full embeddings run.
|
||||
|
||||
```bash
|
||||
# Generate embeddings with the default 50,000 node safety cap
|
||||
gitnexus analyze --embeddings
|
||||
|
||||
# Disable the safety cap entirely
|
||||
gitnexus analyze --embeddings 0
|
||||
|
||||
# Use a custom cap
|
||||
gitnexus analyze --embeddings 100000
|
||||
```
|
||||
|
||||
If embeddings are skipped on a large repository, the indexed graph likely exceeds the default safety cap. Re-run with `gitnexus analyze --embeddings 0` to remove the cap, or `gitnexus analyze --embeddings <n>` to choose a higher limit while still keeping memory bounded.
|
||||
|
||||
#### Project config (`.gitnexusrc`)
|
||||
|
||||
Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `analyze` options per project, instead of re-passing the same flags every run. It is read from the resolved repo root (not `.gitnexus/`, which is gitignored index storage). **CLI flags always override `.gitnexusrc`.**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Default branch used in the generated regression-compare example (base_ref).
|
||||
// Use this so a project on `develop`/`master` doesn't get "main" rewritten
|
||||
// over its fix on every analyze. (Alias: "branch".)
|
||||
"defaultBranch": "develop",
|
||||
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
|
||||
"skipSkills": true, // don't install .claude/skills/gitnexus/
|
||||
"embeddings": true, // generate embeddings by default
|
||||
"workerTimeout": 60
|
||||
}
|
||||
```
|
||||
|
||||
A nested `analyze` block is also accepted (and overrides flat keys for the same option):
|
||||
|
||||
```json
|
||||
{ "analyze": { "defaultBranch": "develop", "skipSkills": true } }
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`.
|
||||
- `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection.
|
||||
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
|
||||
- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
|
||||
|
||||
#### Environment variables
|
||||
|
||||
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.
|
||||
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `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_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` at install time. | Installing on a host without a C++ toolchain or where Swift prebuilds don't match; you're willing to skip Dart/Proto/Swift parsing. |
|
||||
|
||||
#### 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.
|
||||
|
||||
### What Your AI Agent Gets
|
||||
|
||||
**16 tools** exposed via MCP (11 per-repo + 5 group):
|
||||
**7 tools** exposed via MCP:
|
||||
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ----------------- | ---------------------------------------------------------------- | ------------ |
|
||||
| `list_repos` | Discover all indexed repositories | — |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
|
||||
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
|
||||
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
|
||||
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
|
||||
| `cypher` | Raw Cypher graph queries | Optional |
|
||||
| `group_list` | List configured repository groups | — |
|
||||
| `group_sync` | Extract contracts and match across repos/services | — |
|
||||
| `group_contracts` | Inspect extracted contracts and cross-links | — |
|
||||
| `group_query` | Search execution flows across all repos in a group | — |
|
||||
| `group_status` | Check staleness of repos in a group | — |
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ------------------ | ----------------------------------------------------------------- | -------------- |
|
||||
| `list_repos` | Discover all indexed repositories | — |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
|
||||
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
|
||||
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
|
||||
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
|
||||
| `cypher` | Raw Cypher graph queries | Optional |
|
||||
|
||||
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
|
||||
|
||||
**Resources** for instant context:
|
||||
|
||||
| Resource | Purpose |
|
||||
| --------------------------------------- | ---------------------------------------------------- |
|
||||
| Resource | Purpose |
|
||||
| ----------------------------------------- | ---------------------------------------------------- |
|
||||
| `gitnexus://repos` | List all indexed repositories (read this first) |
|
||||
| `gitnexus://repo/{name}/context` | Codebase stats, staleness check, and available tools |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional clusters with cohesion scores |
|
||||
@@ -371,9 +177,9 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
|
||||
|
||||
**2 MCP prompts** for guided workflows:
|
||||
|
||||
| Prompt | What It Does |
|
||||
| --------------- | ------------------------------------------------------------------------- |
|
||||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| Prompt | What It Does |
|
||||
| ----------------- | ------------------------------------------------------------------------- |
|
||||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams |
|
||||
|
||||
**4 agent skills** installed to `.claude/skills/` automatically:
|
||||
@@ -383,10 +189,6 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
|
||||
- **Impact Analysis** — Analyze blast radius before changes
|
||||
- **Refactoring** — Plan safe refactors using dependency mapping
|
||||
|
||||
**Repo-specific skills** generated with `--skills`:
|
||||
|
||||
When you run `gitnexus analyze --skills`, GitNexus detects the functional areas of your codebase (via Leiden community detection) and generates a `SKILL.md` file for each one under `.claude/skills/generated/`. Each skill describes a module's key files, entry points, execution flows, and cross-area connections — so your AI agent gets targeted context for the exact area of code you're working in. Skills are regenerated on each `--skills` run to stay current with the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Repo MCP Architecture
|
||||
@@ -415,8 +217,8 @@ flowchart TD
|
||||
Server["server.ts"]
|
||||
Backend["LocalBackend"]
|
||||
Pool["Connection Pool"]
|
||||
ConnA["LadybugDB conn A"]
|
||||
ConnB["LadybugDB conn B"]
|
||||
ConnA["KuzuDB conn A"]
|
||||
ConnB["KuzuDB conn B"]
|
||||
end
|
||||
|
||||
Setup -->|"writes global MCP config"| CursorConfig["~/.cursor/mcp.json"]
|
||||
@@ -433,189 +235,28 @@ flowchart TD
|
||||
ConnB -->|"queries"| RepoB
|
||||
```
|
||||
|
||||
**How it works:** Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything.
|
||||
**How it works:** Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. KuzuDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything.
|
||||
|
||||
---
|
||||
|
||||
## Web UI (browser-based)
|
||||
|
||||
A client-side graph explorer and AI chat — your code never leaves your machine.
|
||||
A fully client-side graph explorer and AI chat. No server, no install — your code never leaves the browser.
|
||||
|
||||
**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — run `npx gitnexus@latest serve` locally and the page auto-connects to your local backend.
|
||||
**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — drag & drop a ZIP and start exploring.
|
||||
|
||||
<img width="2550" height="1343" alt="gitnexus_img" src="https://github.com/user-attachments/assets/cc5d637d-e0e5-48e6-93ff-5bcfdb929285" />
|
||||
|
||||
Or run the frontend locally:
|
||||
Or run locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/abhigyanpatwari/gitnexus.git
|
||||
cd gitnexus/gitnexus-shared && npm install && npm run build
|
||||
cd ../gitnexus-web && npm install
|
||||
cd gitnexus/gitnexus-web
|
||||
npm install
|
||||
npm run dev
|
||||
# Then in another terminal, start the backend the frontend connects to:
|
||||
npx gitnexus@latest serve
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`. Each image is published to both **GitHub Container Registry** (GHCR) and **Docker Hub** — same build, same digest, same Cosign signature — so pick whichever registry you prefer:
|
||||
|
||||
| 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` |
|
||||
|
||||
> **Heads-up — image rename.** Earlier releases published the web UI under
|
||||
> `ghcr.io/abhigyanpatwari/gitnexus`. Starting with the introduction of the
|
||||
> bundled backend, that slug now hosts the CLI/server image and the UI moved
|
||||
> to `ghcr.io/abhigyanpatwari/gitnexus-web`. The previous tags remain
|
||||
> available for pulling, but new versions are only published under the new
|
||||
> slugs. Update your `docker run` / compose files accordingly (or just adopt
|
||||
> the bundled compose).
|
||||
|
||||
### One-command setup
|
||||
|
||||
```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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Direct `docker run`
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Versioning & supply-chain protection
|
||||
|
||||
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
|
||||
|
||||
For Kubernetes deployments, 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/
|
||||
|
||||
### 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`.
|
||||
- [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.
|
||||
|
||||
The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos.
|
||||
The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, KuzuDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos.
|
||||
|
||||
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
|
||||
|
||||
@@ -623,7 +264,7 @@ The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAs
|
||||
|
||||
## The Problem GitNexus Solves
|
||||
|
||||
Tools like **Cursor**, **Claude Code**, **Codex**, **Cline**, **Roo Code**, and **Windsurf** are powerful — but they don't truly know your codebase structure.
|
||||
Tools like **Cursor**, **Claude Code**, **Cline**, **Roo Code**, and **Windsurf** are powerful — but they don't truly know your codebase structure.
|
||||
|
||||
**What happens:**
|
||||
|
||||
@@ -672,31 +313,14 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
||||
|
||||
1. **Structure** — Walks the file tree and maps folder/file relationships
|
||||
2. **Parsing** — Extracts functions, classes, methods, and interfaces using Tree-sitter ASTs
|
||||
3. **Resolution** — Resolves imports, function calls, heritage, constructor inference, and `self`/`this` receiver types across files with language-aware logic
|
||||
3. **Resolution** — Resolves imports and function calls across files with language-aware logic
|
||||
4. **Clustering** — Groups related symbols into functional communities
|
||||
5. **Processes** — Traces execution flows from entry points through call chains
|
||||
6. **Search** — Builds hybrid search indexes for fast retrieval
|
||||
|
||||
### Supported Languages
|
||||
|
||||
| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points |
|
||||
| ---------- | ------- | -------------- | ------- | -------- | ---------------- | --------------------- | ------ | ---------- | ------------ |
|
||||
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| JavaScript | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
|
||||
| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
|
||||
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
|
||||
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
|
||||
TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust, PHP, Swift
|
||||
|
||||
---
|
||||
|
||||
@@ -718,15 +342,7 @@ UPSTREAM (what depends on this):
|
||||
authRouter [IMPORTS] -> src/routes/auth.ts
|
||||
```
|
||||
|
||||
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`, `limit` (max symbols per depth, default 100), `offset` (pagination start per depth), `summaryOnly` (counts and risk only, omits symbol list)
|
||||
|
||||
**Disambiguation** — when several symbols share the target name, `impact` returns a ranked `ambiguous` candidate list instead of guessing. Narrow it with `target_uid` (exact, zero-ambiguity), `file_path`, or `kind` (`Function`, `Class`, `Method`, …). From the CLI these are `--uid`, `--file`, and `--kind`, matching `gitnexus context`:
|
||||
|
||||
```bash
|
||||
gitnexus impact get_embeddings # → ambiguous: lists ranked candidates
|
||||
gitnexus impact get_embeddings --file src/embed.py # → resolves to the one in that file
|
||||
gitnexus impact get_embeddings --uid "Function:src/embed.py:get_embeddings" # exact
|
||||
```
|
||||
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`
|
||||
|
||||
### Process-Grouped Search
|
||||
|
||||
@@ -831,14 +447,6 @@ gitnexus wiki --base-url https://api.anthropic.com/v1
|
||||
|
||||
# Force full regeneration
|
||||
gitnexus wiki --force
|
||||
|
||||
|
||||
# Increase the timeout or retries for large codebase or slow LLM providers
|
||||
gitnexus wiki --timeout <seconds> # LLM request timeout in seconds (default: disabled)
|
||||
gitnexus wiki --retries <n> # Max LLM retry attempts per request (default: 3)
|
||||
|
||||
# Change the language generation for wiki
|
||||
gitnexus wiki --lang <lang> # Output language for generated documentation (e.g. english, chinese, spanish, japanese)
|
||||
```
|
||||
|
||||
The wiki generator reads the indexed graph structure, groups files into modules via LLM, generates per-module documentation pages, and creates an overview page — all with cross-references to the knowledge graph.
|
||||
@@ -847,16 +455,16 @@ The wiki generator reads the indexed graph structure, groups files into modules
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | CLI | Web |
|
||||
| ------------------- | ------------------------------------- | --------------------------------------- |
|
||||
| Layer | CLI | Web |
|
||||
| ------------------------- | ------------------------------------- | --------------------------------------- |
|
||||
| **Runtime** | Node.js (native) | Browser (WASM) |
|
||||
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
|
||||
| **Database** | LadybugDB native | LadybugDB WASM |
|
||||
| **Database** | KuzuDB native | KuzuDB WASM |
|
||||
| **Embeddings** | HuggingFace transformers.js (GPU/CPU) | transformers.js (WebGPU/WASM) |
|
||||
| **Search** | BM25 + semantic + RRF | BM25 + semantic + RRF |
|
||||
| **Agent Interface** | MCP (stdio) | LangChain ReAct agent |
|
||||
| **Visualization** | — | Sigma.js + Graphology (WebGL) |
|
||||
| **Frontend** | — | React 18, TypeScript, Vite, Tailwind v4 |
|
||||
| **Visualization** | — | Sigma.js + Graphology (WebGL) |
|
||||
| **Frontend** | — | React 18, TypeScript, Vite, Tailwind v4 |
|
||||
| **Clustering** | Graphology | Graphology |
|
||||
| **Concurrency** | Worker threads + async | Web Workers + Comlink |
|
||||
|
||||
@@ -872,12 +480,11 @@ The wiki generator reads the indexed graph structure, groups files into modules
|
||||
|
||||
### Recently Completed
|
||||
|
||||
- [x] Constructor-Inferred Type Resolution, `self`/`this` Receiver Mapping
|
||||
- [x] Wiki Generation, Multi-File Rename, Git-Diff Impact Analysis
|
||||
- [x] Process-Grouped Search, 360-Degree Context, Claude Code Hooks
|
||||
- [x] Multi-Repo MCP, Zero-Config Setup, 14 Language Support
|
||||
- [x] Community Detection, Process Detection, Confidence Scoring
|
||||
- [x] Hybrid Search, Vector Index
|
||||
- [X] Wiki Generation, Multi-File Rename, Git-Diff Impact Analysis
|
||||
- [X] Process-Grouped Search, 360-Degree Context, Claude Code Hooks
|
||||
- [X] Multi-Repo MCP, Zero-Config Setup, 11 Language Support
|
||||
- [X] Community Detection, Process Detection, Confidence Scoring
|
||||
- [X] Hybrid Search, Vector Index
|
||||
|
||||
---
|
||||
|
||||
@@ -892,7 +499,7 @@ The wiki generator reads the indexed graph structure, groups files into modules
|
||||
## Acknowledgments
|
||||
|
||||
- [Tree-sitter](https://tree-sitter.github.io/) — AST parsing
|
||||
- [LadybugDB](https://ladybugdb.com/) — Embedded graph database with vector support (formerly KuzuDB)
|
||||
- [KuzuDB](https://kuzudb.com/) — Embedded graph database with vector support
|
||||
- [Sigma.js](https://www.sigmajs.org/) — WebGL graph rendering
|
||||
- [transformers.js](https://huggingface.co/docs/transformers.js) — Browser ML
|
||||
- [Graphology](https://graphology.github.io/) — Graph data structures
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user