diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs
index 894ea29..e633cd3 100644
--- a/.dependency-cruiser.cjs
+++ b/.dependency-cruiser.cjs
@@ -12,6 +12,46 @@
* recorded here, not an accident that compiles.
*/
+const { readdirSync, readFileSync, existsSync } = require("node:fs");
+const path = require("node:path");
+
+/**
+ * What "published entry point" MEANS, read from the packages themselves.
+ *
+ * Both deep-import rules below used to hardcode `src/index.ts`. That was a proxy for the real
+ * rule and it was accurate only while every package had exactly one entry — the moment one
+ * needed a second, the guard reported a violation for something the package had deliberately
+ * published, and the tempting fix is to bolt the new path into a regex by hand.
+ *
+ * `exports` is already the declaration of what a package publishes; Node enforces it at
+ * resolution time. Reading it here makes the guard agree with the manifest by construction,
+ * so a package still controls exactly what apps may import — the boundary is unchanged, only
+ * the place it is written down. Same reasoning as `ALLOWED` above: state it once, mechanically.
+ *
+ * A package with no `exports` map falls back to `src/index.ts`, which is what it would have
+ * been held to before.
+ */
+function publishedEntryPoints() {
+ const root = path.join(__dirname, "packages");
+ const out = [];
+ for (const name of readdirSync(root)) {
+ const manifest = path.join(root, name, "package.json");
+ if (!existsSync(manifest)) continue;
+ const map = JSON.parse(readFileSync(manifest, "utf8")).exports ?? { ".": "./src/index.ts" };
+ for (const target of Object.values(map)) {
+ // Only string targets. A conditional export object would need resolving per condition,
+ // and no package here uses one — if that changes this must grow, not silently pass.
+ if (typeof target === "string") out.push(`packages/${name}/${target.replace(/^\.\//, "")}`);
+ }
+ }
+ return out;
+}
+
+/** The entry points as one anchored alternation, for `pathNot`. */
+const ENTRY_POINT_RE = `^(${publishedEntryPoints()
+ .map((p) => p.replace(/\./g, "\\."))
+ .join("|")})$`;
+
/** @type {Record} */
const ALLOWED = {
// Bottom of the stack: pure types, zero deps, zero I/O (LLD §2).
@@ -195,7 +235,7 @@ module.exports = {
{
name: "no-deep-import-across-packages",
comment:
- "src/index.ts is a package's ONLY public surface (LLD §1.1). Reaching " +
+ "A package's `exports` map is its ONLY public surface (LLD §1.1). Reaching " +
"past it freezes another package's internals into your contract, which " +
"is exactly what this refactor exists to undo. Relative imports inside " +
"a package are unaffected — the $1 back-reference exempts self.",
@@ -203,20 +243,21 @@ module.exports = {
from: { path: "^packages/([^/]+)/" },
to: {
path: "^packages/[^/]+/src/",
- pathNot: ["^packages/$1/", "^packages/[^/]+/src/index\\.ts$"],
+ pathNot: ["^packages/$1/", ENTRY_POINT_RE],
},
},
{
name: "no-deep-import-from-app",
comment:
- "An app consumes a package through its published entry point only " +
- "(LLD §1.1).",
+ "An app consumes a package through an entry point that package DECLARES " +
+ "in its `exports` map (LLD §1.1). Publishing a second one is a deliberate " +
+ "act recorded in the manifest, not a path an app may reach for.",
severity: "error",
from: { path: "^apps/" },
to: {
path: "^packages/[^/]+/src/",
- pathNot: "^packages/[^/]+/src/index\\.ts$",
+ pathNot: ENTRY_POINT_RE,
},
},
@@ -269,6 +310,28 @@ module.exports = {
// `cloneRepo` produces one. What `indexer.ts` does is bulk read-only
// enumeration of an already-validated root. It writes nothing.
"^packages/analysis/src/indexer\\.ts$",
+ // `advisories.ts`, 2026-08-09. Same shape and the same argument as `indexer.ts`
+ // above, restated rather than pointed at, because a waiver nobody can re-derive is
+ // the kind that outlives its reason:
+ //
+ // It READS ONLY — `existsSync`, `readFileSync`, `readdirSync`, `statSync`, and
+ // nothing else. Checked by grep, not assumed: no write, no mkdir, no unlink. The
+ // failure this rule exists to prevent is a WRITE that escapes a root, and there is
+ // no write here to escape with.
+ //
+ // The root is validated before it arrives — `vcs.resolveLocalDir` or `cloneRepo`
+ // produced it — so this is bulk read-only enumeration of a directory the boundary
+ // already accepted, exactly as `indexer.ts` is.
+ //
+ // It cannot route through `fsx` for the reason `indexer.ts` cannot: the
+ // WorkspaceHandle is async by design (§10.1) and this scan is synchronous
+ // throughout, so the conversion changes the pipeline's execution shape rather than
+ // moving a call.
+ //
+ // It bounds what it reads — walk depth, manifest count, lockfile bytes — and skips
+ // symlinks via `withFileTypes`, so a crafted repository cannot use it to read
+ // outside the tree or to stall an index.
+ "^packages/analysis/src/advisories\\.ts$",
],
},
to: { path: "^(node:)?fs(/promises)?$", dependencyTypes: ["core"] },
@@ -330,11 +393,11 @@ module.exports = {
to: { circular: true },
},
- // `no-orphans` is deliberately NOT enabled. It fired on nine modules that
+ // `no-orphans` is deliberately NOT enabled. It fired on eight modules that
// are all genuinely imported (urlSafety, localAccess, basicAuth, colors,
- // layout, editorLang, anthropicKeyCheck, GithubMark, postcss.config) —
- // false positives caused by unresolved path aliases, plus config files that
- // are legitimately never imported. A warn-level rule that cries wolf nine
+ // layout, editorLang, GithubMark, postcss.config) — false positives caused
+ // by unresolved path aliases, plus config files that
+ // are legitimately never imported. A warn-level rule that cries wolf eight
// times teaches everyone to ignore the tool, which costs more than the
// dead code it would find. Unreferenced-export detection is knip's job
// (LLD §13.1 already uses it to find dead shims).
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 88ca91a..5bc9ff2 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -33,7 +33,7 @@ rather than by convention.
Browser (Next.js client pages, apps/web/src/app/*)
│ fetch
▼
-API routes (apps/web/src/app/api/*/route.ts) ← 29 routes, thin HTTP glue
+API routes (apps/web/src/app/api/*/route.ts) ← 30 routes, thin HTTP glue
│
▼
apps/web/src/lib/* web-only concerns: session, authz, store, agents, editor, timeline
diff --git a/README.md b/README.md
index af7ee05..06ffb94 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ flowchart TB
UI["Dashboard · Report tabs · Built-in Editor"]
end
- subgraph API["API routes — src/app/api/* (14 routes, thin HTTP glue)"]
+ subgraph API["API routes — src/app/api/* (30 routes, thin HTTP glue)"]
IDX["/api/index"]
REPOS["/api/repos · /api/repos/:id"]
INTEL["/api/repos/:id/intel"]
@@ -157,6 +157,61 @@ verified, `1` for a gate that rejected the patch.
`--rule ` narrows to one rule, `--file ` to one file, `--json` for machine output.
+### CI — fail a change on findings, not on a score
+
+A score threshold fails a pull request for debt its author did not write. `codegraph ci` gates on
+**unaccepted findings at or above a confidence tier**, each carrying one line of evidence you can
+check without opening the file. The exit code is the verdict.
+
+```bash
+node apps/cli/bin.mjs ci . --fail-on high --sarif codegraph.sarif --json codegraph-summary.json
+```
+
+```
+Health 77/100 /path/to/repo
+Findings 200 active · 0 accepted
+Tiers high 73 · medium 10 · low 117
+
+Top rules
+ 99 security/detect-non-literal-fs-filename low
+ 36 hardcoded-local-url high
+ 19 hardcoded-secret high
+
+Gate --fail-on high
+ ✗ apps/web/tests/security.test.ts:222 hardcoded-secret high
+ assigned to `PASSWORD`, 6 chars, generated-looking
+
+FAIL — 73 unaccepted finding(s) at or above high confidence.
+```
+
+Adopting it on a codebase that already has findings does not require a thousand-line PR:
+
+```bash
+node apps/cli/bin.mjs baseline . # 3 entries accepting 7 finding(s)
+node apps/cli/bin.mjs ci . # exit 0 — the gate now fires on what you add next
+```
+
+A baseline entry is *rule + file*, deliberately not line — a line-keyed baseline expires on the
+next commit that adds an import. Accepted findings are **still reported**: they stay in the
+summary, they stay in the SARIF marked `suppressions: external` (code scanning shows them as
+dismissed), and they stay out of the Health Score. A baseline that makes findings vanish is an
+allowlist nobody reviews. `codegraph-ignore — reason` on the offending line is the
+per-finding escape hatch.
+
+#### In GitHub Actions
+
+The gate is a CLI, so a workflow is three lines around `codegraph ci`:
+
+```yaml
+- run: npx codegraph ci . --fail-on high --sarif codegraph.sarif
+- uses: github/codeql-action/upload-sarif@v3
+ with: { sarif_file: codegraph.sarif }
+```
+
+A packaged composite action lives on `feat/codegraph-action` — it is not merged, because it
+runs green locally and produced no artifacts on a hosted runner, and a gate whose own CI cannot
+be reproduced is not one to hand anybody else.
+
## Installation & deployment
| Mode | Command | Notes |
@@ -198,12 +253,12 @@ quietly, and a README is the last place that should happen.
| What | Result | Source |
|---|---|---|
-| **Symbol graph extraction** (`expressjs/express@a371447`) | 174 symbols across 159 files plus 113 synthetic module nodes; **299 resolved call edges**, up from 38. Symbols with no inbound edge: **72 of 174 (41%)**, down from 155 (89%). Two defects caused that: call-site attribution required a named enclosing function, discarding 46% of already-resolved calls, and the extractor recorded only `CallExpression`, so rendering a component or passing a callback produced no reference. Precision is measured separately against the TypeScript checker as ground truth — **98.4%** over 2,159 calls where both resolvers answered. Of the symbols still unreferenced on *this* repository, the compiler finds a real call site for only **2%**: the rest are exports, entry points and dynamically-dispatched handlers, not missing edges | `npm run bench` |
-| **Agent swarm** (`expressjs/express@a371447`) | 66 findings across 6 active specialists (P0:24 · P1:2 · P2:40 · P3:0); Health Score 89, *simulated* **89 → 90** if P0+P1 are fixed. The projection re-runs the real scorer over the issues that would remain, so it simulates the shipped model rather than estimating — but it is a simulation, not a measurement. The measured result is the row below | `npm run bench` |
-| **Verified remediation** (`expressjs/express@a371447`) | 31 fixes across 27 files; Health Score **89 → 96** and issues **62 → 31**, both from an actual re-index of the fixed tree rather than a projection. Verification level **`partial`** — syntax and re-analysis passed, types and tests skipped (express ships no `tsconfig.json`, and gate 3 runs only under `--verify`). Valid, applyable unified git diff | `npm run bench` |
+| **Symbol graph extraction** (`expressjs/express@a371447`) | 174 symbols across 159 files plus 112 synthetic module nodes; **301 resolved call edges**, up from 38 — 5 of them self-edges, which the graph used to mis-attribute to module scope. Symbols with no inbound edge: **73 of 174 (42%)**, down from 155 (89%). Two defects caused that: call-site attribution required a named enclosing function, discarding 46% of already-resolved calls, and the extractor recorded only `CallExpression`, so rendering a component or passing a callback produced no reference. Precision is measured separately against the TypeScript checker as ground truth — **98.4%** over 2,159 calls where both resolvers answered. Of the symbols still unreferenced on *this* repository, the compiler finds a real call site for only **2%**: the rest are exports, entry points and framework callbacks, which is the honest ceiling for a resolver with no runtime information | `npm run bench` |
+| **Agent swarm** (`expressjs/express@a371447`) | 54 findings after the critic dedupes, across **7 of 7 active specialists** (P0:21 · P1:1 · P2:24 · P3:8); Health Score 89, *simulated* **89 → 90** if P0+P1 are fixed. The architecture specialist reported 0 here until the recursion fix above — express's 5 self-recursive functions were real call cycles the graph could not see. The projection re-runs the real scorer over the issues that would remain, so it simulates the shipped model rather than estimating — but it is a simulation, not a measurement | `npm run bench` |
+| **Batch remediation** (`expressjs/express@a371447`) | **0 edits.** The batch fixer ships exactly one codemod — `annotate-empty-catch` — and express contains no empty catch block, so there is nothing for it to patch and it says so rather than manufacturing a diff. Two earlier codemods (`remove-debug-output`, `remove-todo-marker`) were deleted after one deleted a CLI script's only output line; the bar in `remediate-engine/src/types.ts` is that a fix cannot change behaviour AND must remove an issue the scorer counts, and nothing else has cleared it yet. This row published "31 fixes, 89 → 96" for a while after those removals — a stale number is exactly what `npm run bench` exists to catch, and it only catches it when someone runs it | `npm run bench` |
| **Graph-RAG context generation** | Query *"render a view template"* → 5 seeds, 11 slices, ~647 tokens, structured prompt | [`apps/web/CODE_INTELLIGENCE.md`](./apps/web/CODE_INTELLIGENCE.md) |
| **Memory ceiling under Render's real constraints** | Full pipeline survives indexing `octocat/Hello-World` **and** `expressjs/express` end-to-end inside a container capped at `--memory=512m --cpus=0.5` — the exact config that OOM-killed the server before the fix in [`docs/postmortems/2026-07-10-tree-sitter-oom.md`](./docs/postmortems/2026-07-10-tree-sitter-oom.md) | CI `docker-smoke-test` job, runs on every push |
-| **Test suite** | **94 test files** in the workspace (security, indexer, scoring, pillars, coverage, dependencies, codeintel, graph scope, wheel-zoom policy, anonymous-indexing consent, incremental indexing, executor, verify gates, orchestrator, specialists, migrations, tenant-isolation, workspace containment, timeline hash validation, clone redirect refusal, CLI, README claims, and more). 1,286 cases as of 2026-08-08 — the file count is asserted by a test, the case count is a point-in-time figure that moves with every commit | `npm run test` |
+| **Test suite** | **120 test files** in the workspace (security, indexer, scoring, pillars, coverage, dependencies, codeintel, graph scope, wheel-zoom policy, anonymous-indexing consent, incremental indexing, executor, verify gates, orchestrator, specialists, migrations, tenant-isolation, workspace containment, timeline hash validation, clone redirect refusal, ask recall, dashboard triage, CLI, README claims, and more). 2,206 cases as of 2026-08-09 — the file count is asserted by a test, the case count is a point-in-time figure that moves with every commit | `npm run test` |
| **Security posture (self-audited, tracked openly)** | Baseline **3/10 → 9.1/10**. Phases 0–3 hardening (SSRF guard, local-access gate, security headers, auth gate, cross-tenant isolation fix), then Phase 7 closed **17 of 27** findings from a follow-up deep audit that surfaced **99 issues (5 critical)** across the full stack. Remaining items are tracked, not hidden — plus an independent pen-test pass that verified every control live and fixed a rate-limit `X-Forwarded-For` bypass | [`docs/PROGRESS_TRACKER.md`](./docs/PROGRESS_TRACKER.md), [`docs/AUDIT_2026-07-12.md`](./docs/AUDIT_2026-07-12.md) |
## Comparison with existing tools
@@ -232,6 +287,22 @@ Tracked live in [`docs/IMPROVEMENT_PLAN.md`](./docs/IMPROVEMENT_PLAN.md) (the pl
- [x] **Phase 0.6 — Multi-tenant isolation** *(pulled forward, was live-severity)*: per-repo ownership, cross-tenant data leak closed
- [~] **Phase 4 — Close the agent loop** *(partly shipped)*: the **explicit confirmation gate exists** — every remote mutation now requires `PublishConsent { confirmed: true }`, and no route constructs one, so nothing publishes as shipped. What remains is the endpoint that takes that consent and performs the branch → commit → push → PR, plus the audit trail
- [ ] **Phase 5 — Scale & domains** *(stretch)*: a second Tree-sitter language extractor (Python) for AST-grade precision beyond regex, runtime/observability domain (OTel ingestion)
+- [x] **Phase 6 — Code-intelligence breadth**: ownership (developer → commit → file → symbol, reviewer recommendation, stale/orphaned areas), APIs as first-class graph entities with endpoint → service → sink flow tracing, inter-procedural taint, dependency advisories (OSV, opt-in), unused-dependency and replacement-impact analysis, PR intelligence over a real diff, and a cross-repository organisational graph — see the table below for what each does and does not know
+
+### What the intelligence layer actually knows
+
+Each row is reachable from a route and covered by tests. The **limits** column is the point: every analysis here reports what it could not determine instead of defaulting to a clean answer, because "we did not look" and "there is nothing there" are different claims.
+
+| Capability | Route | Limits it states about itself |
+|---|---|---|
+| **Ownership** — authors, per-file shares, bus factor, stale/orphaned areas, symbol-level attribution | `/api/repos/:id/ownership?op=summary\|file\|reviewers\|familiarity` | Shares are of COMMITS, not lines. Symbol attribution intersects historical hunks with CURRENT spans, so a symbol that moved is matched where it is now |
+| **Reviewer recommendation** — ownership × recency × co-change | same route, `op=reviewers` | Excludes anyone inactive in the window; each recommendation carries the evidence it was derived from. No history → no recommendation, not a guess |
+| **API entities + data flow** — endpoints as graph nodes, endpoint → service → database/fs/network/process flows | `/api/repos/:id/intel?op=endpoints\|flows\|api-impact\|unauth-paths` | `authenticated` is three-valued: `null` means the handler could not be resolved, and only `false` (resolved, no guard) reaches `unauth-paths` |
+| **Inter-procedural taint** — untrusted values followed argument-index to parameter-index across calls | `/api/repos/:id/intel?op=taint` | Cannot see aliasing, collections, dynamic dispatch or unresolved cross-module calls. Confidence is derived from edge resolution and chain length. Defended paths are reported as `sanitized`, not dropped |
+| **Dependency advisories** — OSV lookup | `/api/repos/:id/dependencies?op=advisories` | **Off by default** (`CG_ENABLE_ADVISORY_LOOKUP`): indexing runs on strangers' repos. Status is `checked` / `unavailable` / `disabled`; only `checked` licenses a conclusion. npm only |
+| **Unused / replaceable dependencies** | `/api/repos/:id/dependencies?op=unused\|impact` | Candidates with a confidence and a caveat, never verdicts. Types-only packages, script-invoked CLIs, config-loaded plugins and peer deps are excluded or downgraded by name |
+| **PR intelligence** — changed symbols, affected endpoints and DB models, blast radius, relevant tests, reviewers, risk | `/api/repos/:id/pr?base=&head=` | Joined against the LAST index, not `head`. Risk is a weighted sum whose every term is published with its evidence — it ranks, it does not predict |
+| **Organisational graph** — cross-repo dependencies, shared libraries, repo cycles, contributors | `/api/org` | An edge is drawn only when one repo's manifest declares the name another depends on. Repos that cannot contribute are listed in `excluded` with a reason |
### Known issues / security status
This project audits itself and publishes the results rather than hiding them. A comprehensive follow-up audit ([`docs/AUDIT_2026-07-12.md`](./docs/AUDIT_2026-07-12.md)) found **99 issues (5 critical, 24 high)** beyond what Phases 0–3 already fixed — including a confused-deputy token-relay path in the fix executor and two symlink-escape vectors. **Phase 7 has since closed all 5 criticals and 17 of 27 security findings** (symlink-escape fixes, credential redaction, job-ownership checks, OAuth open-redirect guard, session expiry, rate limiting, and more — each with regression tests), and an independent pen-test pass verified the controls live. The remaining items are testing-debt or deliberate product/infra tradeoffs, all **tracked in the open** ([`docs/PROGRESS_TRACKER.md`](./docs/PROGRESS_TRACKER.md)), not silently patched over. If you're evaluating this for anything beyond local/trusted-host use, read that audit first.
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 36501b5..4fcec31 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -13,6 +13,7 @@
},
"dependencies": {
"@codegraph/analysis": "*",
+ "@codegraph/analysis-model": "*",
"@codegraph/config": "*",
"@codegraph/core-domain": "*",
"@codegraph/observability": "*",
diff --git a/apps/cli/src/ci.ts b/apps/cli/src/ci.ts
new file mode 100644
index 0000000..fe1afb3
--- /dev/null
+++ b/apps/cli/src/ci.ts
@@ -0,0 +1,249 @@
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { indexRepo, parseBaseline } from "@codegraph/analysis";
+import {
+ activeIssues,
+ applyBaseline,
+ buildBaseline,
+ confidenceTier,
+ findingKey,
+ gateFindings,
+ ruleIdOf,
+ tallyByRule,
+ toSarif,
+ type Baseline,
+ type ConfidenceTier,
+ type Issue,
+ type RuleTally,
+} from "@codegraph/analysis-model";
+
+/**
+ * `codegraph ci` / `codegraph baseline` — the gate a pull request runs (review C3).
+ *
+ * The product had a Health Score, a SARIF exporter and a baseline format, and no way to put
+ * any of them in front of a change before it merged. Everything here is assembly of parts
+ * that already exist; the only decisions it makes are the two a CI story has to make.
+ *
+ * DECISION 1 — the exit code is the verdict, and it is derived from `gateFindings`, not from
+ * the score. A score threshold fails a PR for debt the author did not write; a tier gate fails
+ * it for findings the author can see, check against `evidence`, and either fix or accept.
+ *
+ * DECISION 2 — accepted findings are REPORTED, never dropped. They stay in the SARIF (marked
+ * suppressed), they are counted in the summary, and they are out of the score. A baseline that
+ * makes findings vanish is an allowlist nobody reviews.
+ */
+
+/** Mirrors `@codegraph/analysis`'s `BASELINE_FILE`, which its package index does not export. */
+export const BASELINE_FILE = ".codegraph-baseline.json";
+
+/** How many gating findings the JSON summary carries. */
+const GATING_LIMIT = 50;
+
+export interface CiOptions {
+ readonly repo: string;
+ readonly failOn: ConfidenceTier;
+ /** Baseline file, absolute or relative to the repository root. */
+ readonly baseline: string;
+ readonly sarif?: string;
+ readonly json?: string;
+}
+
+export interface CiFinding {
+ rule: string;
+ file: string;
+ line: number;
+ title: string;
+ tier: ConfidenceTier;
+ severity: number;
+ confidence?: number;
+ evidence?: string;
+}
+
+export interface CiSummary {
+ /** Health Score of the analysed tree — reported, deliberately not gated on. */
+ score: number;
+ repo: string;
+ failOn: ConfidenceTier;
+ passed: boolean;
+ /** Every finding reported, accepted ones included. */
+ total: number;
+ /** Findings that count: reported minus accepted. */
+ active: number;
+ /** Accepted by a baseline entry or an inline `codegraph-ignore`. */
+ accepted: number;
+ /** The subset of `accepted` that the baseline file is responsible for. */
+ acceptedByBaseline: number;
+ /** The baseline actually read, or null when the repository has none. */
+ baselineFile: string | null;
+ /** Active findings per confidence tier; accepted ones are in `accepted`, not here. */
+ tiers: Record;
+ rules: RuleTally[];
+ gatingCount: number;
+ /** The worst `GATING_LIMIT` gating findings — a PR comment shows five of these. */
+ gating: CiFinding[];
+ sarif: string | null;
+}
+
+/**
+ * Read a baseline from an explicit PATH.
+ *
+ * The pipeline reads the repository root and hardcodes the filename, which cannot answer
+ * `--baseline ci/accepted.json`; this supplies the path. What counts as a VALID baseline is
+ * not re-decided here — `parseBaseline` is the one parser, so the gate and the pipeline can
+ * never disagree about which findings a project has accepted.
+ */
+function readBaselineAt(file: string): Baseline | null {
+ if (!existsSync(file)) return null;
+ try {
+ return parseBaseline(readFileSync(file, "utf8"));
+ } catch {
+ // Unreadable (permissions, a directory, a race with a writer) — same answer as malformed.
+ return null;
+ }
+}
+
+/**
+ * Index once and answer every question the gate asks from that one result.
+ *
+ * `indexRepo` runs against the working tree in place — no copy, unlike `runFix`, because
+ * nothing here writes to the repository and CI has already checked out exactly the commit
+ * under test. Uncommitted changes are analysed, which is what a developer running this by
+ * hand before pushing wants.
+ */
+export async function runCi(opts: CiOptions): Promise {
+ const repo = path.resolve(opts.repo);
+ // `--baseline` names a repository-level file, so a relative path resolves against the repo.
+ const file = path.isAbsolute(opts.baseline) ? opts.baseline : path.join(repo, opts.baseline);
+ const baseline = readBaselineAt(file);
+
+ const result = await indexRepo(repo);
+
+ // `indexRepo` already applied `/.codegraph-baseline.json`, which is the default and the
+ // overwhelmingly common case. Re-applying it here is a no-op (the same keys, the same flag);
+ // applying a NON-default `--baseline` here is the only way it can be honoured at all, since
+ // the pipeline takes no parameter for it.
+ const issues = applyBaseline(result.issues, baseline);
+
+ const accepted = issues.filter((i) => i.suppressed);
+ const acceptedKeys = new Set(baseline?.accepted ?? []);
+ const gating = gateFindings(issues, opts.failOn);
+
+ const tiers: Record = { high: 0, medium: 0, low: 0 };
+ for (const issue of activeIssues(issues)) tiers[confidenceTier(issue)]++;
+
+ const summary: CiSummary = {
+ score: result.score,
+ repo,
+ failOn: opts.failOn,
+ passed: gating.length === 0,
+ total: issues.length,
+ active: issues.length - accepted.length,
+ accepted: accepted.length,
+ acceptedByBaseline: accepted.filter((i) => acceptedKeys.has(findingKey(i))).length,
+ baselineFile: baseline ? path.relative(repo, file) || path.basename(file) : null,
+ tiers,
+ // Tallied over ALL findings, accepted included: "this rule fires 40 times and you accepted
+ // 38 of them" is the sentence that tells a team the rule is miscalibrated, and dropping the
+ // accepted ones first hides it.
+ rules: tallyByRule(issues),
+ gatingCount: gating.length,
+ gating: [...gating].sort(worstFirst).slice(0, GATING_LIMIT).map(toFinding),
+ sarif: opts.sarif ?? null,
+ };
+
+ if (opts.sarif) {
+ // Every finding, accepted ones included and marked `suppressions: external` — code scanning
+ // then shows them as dismissed rather than as never having existed.
+ writeFileSync(
+ path.resolve(opts.sarif),
+ `${JSON.stringify(toSarif(issues, { endTimeUtc: new Date().toISOString() }), null, 2)}\n`
+ );
+ }
+ if (opts.json) {
+ writeFileSync(path.resolve(opts.json), `${JSON.stringify(summary, null, 2)}\n`);
+ }
+
+ return summary;
+}
+
+/** Severity first, then confidence: the five a reviewer reads first must be the worst five. */
+function worstFirst(a: Issue, b: Issue): number {
+ return (
+ b.severity - a.severity ||
+ (b.confidence ?? 0) - (a.confidence ?? 0) ||
+ a.file.localeCompare(b.file) ||
+ a.line - b.line
+ );
+}
+
+function toFinding(issue: Issue): CiFinding {
+ return {
+ rule: ruleIdOf(issue),
+ file: issue.file,
+ line: issue.line,
+ title: issue.title,
+ tier: confidenceTier(issue),
+ severity: issue.severity,
+ ...(issue.confidence === undefined ? {} : { confidence: issue.confidence }),
+ ...(issue.evidence === undefined ? {} : { evidence: issue.evidence }),
+ };
+}
+
+export interface BaselineOutcome {
+ /** Absolute path written. */
+ file: string;
+ /**
+ * Entries in the written baseline — NOT the number of findings accepted.
+ *
+ * `findingKey` is rule+file, so one entry can accept a dozen findings. Reporting the entry
+ * count as "findings accepted" understates the blast radius of the file being written, which
+ * is the one number the person adopting the gate is deciding on.
+ */
+ entries: number;
+ /** Findings the written baseline accepts — what `ci` will then report as accepted. */
+ covered: number;
+ /** Entries this run added on top of what the file already held. */
+ added: number;
+ score: number;
+}
+
+/**
+ * `codegraph baseline` — adopt the gate on an existing codebase without a thousand-line PR.
+ *
+ * MERGED with the file already on disk rather than overwriting it. `indexRepo` applies the
+ * existing baseline before this sees the findings, so everything previously accepted comes back
+ * SUPPRESSED and is therefore absent from `activeIssues` — writing that set alone would drop
+ * every prior entry and re-open every finding the team had already signed off. Running the
+ * command twice must not undo the first run.
+ *
+ * A stale entry (the finding was fixed) matches nothing and costs nothing; a dropped entry
+ * breaks the build. The asymmetry decides it.
+ */
+export async function runBaseline(opts: {
+ readonly repo: string;
+ readonly baseline: string;
+}): Promise {
+ const repo = path.resolve(opts.repo);
+ const file = path.isAbsolute(opts.baseline) ? opts.baseline : path.join(repo, opts.baseline);
+ const prior = readBaselineAt(file);
+
+ const result = await indexRepo(repo);
+ const fresh = buildBaseline(activeIssues(applyBaseline(result.issues, prior)));
+ const merged = [...new Set([...(prior?.accepted ?? []), ...fresh.accepted])].sort();
+
+ const written: Baseline = { ...fresh, accepted: merged };
+ writeFileSync(file, `${JSON.stringify(written, null, 2)}\n`);
+
+ // Counted over the raw findings rather than by re-applying: `applyBaseline` also preserves
+ // findings already suppressed by an inline `codegraph-ignore`, and those are not this file's
+ // doing. Same predicate as `runCi`'s `acceptedByBaseline`, so the two numbers agree.
+ const keys = new Set(merged);
+
+ return {
+ file,
+ entries: merged.length,
+ covered: result.issues.filter((i) => keys.has(findingKey(i))).length,
+ added: merged.length - (prior?.accepted.length ?? 0),
+ score: result.score,
+ };
+}
diff --git a/apps/cli/src/fix.ts b/apps/cli/src/fix.ts
index f47335c..974625a 100644
--- a/apps/cli/src/fix.ts
+++ b/apps/cli/src/fix.ts
@@ -12,7 +12,7 @@ import {
parseCheck,
type FileChange,
} from "@codegraph/remediate-engine";
-import { createSandbox, detectTestRunner, hasTypeConfig } from "@codegraph/sandbox";
+import { createSandbox, detectTestRunner, hasTypeConfig, typescriptCompiler } from "@codegraph/sandbox";
import {
buildRecord,
describeRecord,
@@ -135,7 +135,7 @@ export async function runFix(opts: FixOptions): Promise {
gates.push(
await syntaxGate(candidate, sandbox, parseCheck, async (abs) => readFileSync(abs, "utf8"))
);
- gates.push(await typesGate(sandbox, () => hasTypeConfig(work)));
+ gates.push(await typesGate(sandbox, () => hasTypeConfig(work), typescriptCompiler));
gates.push(
await testsGate(sandbox, {
// Both true, and that is the entire point of the CLI. On the developer's own machine
diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts
index ab4a734..cf47c3f 100644
--- a/apps/cli/src/main.ts
+++ b/apps/cli/src/main.ts
@@ -1,40 +1,71 @@
+import { statSync } from "node:fs";
+import path from "node:path";
+import type { ConfidenceTier } from "@codegraph/analysis-model";
+import { runBaseline, runCi, BASELINE_FILE, type CiSummary } from "./ci";
import { runFix } from "./fix";
-import { color } from "./ui/ansi";
+import { color, padEnd, truncate } from "./ui/ansi";
const { bold, dim, green, red, yellow } = color;
/**
* `codegraph` — the CLI entry point (LLD §1, `terminal/` → `apps/cli`).
*
- * Argument parsing is hand-rolled. The surface is one command with five flags, and a parser
+ * Argument parsing is hand-rolled. The surface is three commands and nine flags, and a parser
* dependency for that is more supply chain than the feature is worth in a project whose pitch
* includes "no API key, one container".
+ *
+ * `--json` is the one flag whose arity depends on the command: a boolean for `fix` (dump the
+ * outcome to stdout), a path for `ci` (the file the PR comment is built from). Resolved by
+ * reading the command first, which means options must FOLLOW the command — as every line of
+ * the usage above shows. `codegraph --json out.json ci` fails loudly with
+ * `unknown command "out.json"` rather than doing something surprising.
*/
const USAGE = `${bold("codegraph")} — a codebase workbench
${bold("USAGE")}
+ codegraph ci [path] [options]
+ codegraph baseline [path] [options]
codegraph fix [path] [options]
-${bold("WHY THIS EXISTS")}
+${bold("ci")} — fail a change on findings, not on a score
+ A score threshold fails a pull request for debt its author did not write. This gates on
+ UNACCEPTED findings at or above a confidence tier, each carrying one line of evidence you
+ can check without opening the file. Exit ${red("1")} when the gate fires, ${green("0")} when it does not.
+
+ --fail-on high | medium | low (default high)
+ --baseline accepted findings (default ${BASELINE_FILE})
+ --sarif write a SARIF 2.1.0 log — GitHub code scanning reads this
+ --json write the summary as JSON (what the PR comment is built from)
+
+${bold("baseline")} — adopt the gate on a codebase that already has findings
+ Accepts everything present today, so the gate starts green and fires on what you add next.
+ Accepted findings are still reported and still excluded from the Health Score — the file is
+ an audit trail, not an allowlist that hides things.
+
+ --baseline file to write (default ${BASELINE_FILE})
+
+${bold("fix")} — remediation verified where you already are
Gate 3 of verification runs your repository's own test suite, which needs an isolated
container. The hosted demo cannot provide one, so it reports ${yellow("verified: partial")}.
Here, on your machine, with your toolchain — it reports ${green("verified: full")}.
Your source is never modified. You get a diff.
-${bold("OPTIONS")}
--verify run your test suite as gate 3 (this is the point)
--rule only fix findings of this rule
--file only fix this file (repo-relative)
--test-timeout seconds before gate 3 is SIGKILLed (default 300)
--json machine-readable output
+
+${bold("OPTIONS")}
-h, --help this
${bold("EXAMPLES")}
+ codegraph ci . --fail-on high --sarif codegraph.sarif
+ codegraph baseline .
codegraph fix . --verify
codegraph fix ~/src/app --rule legacy/leftover-debug-output --verify
- codegraph fix . --verify --json | jq .record.level
`;
interface Parsed {
@@ -43,11 +74,19 @@ interface Parsed {
readonly verify: boolean;
readonly rule?: string;
readonly file?: string;
+ /** `fix`: dump the outcome to stdout. */
readonly json: boolean;
+ /** `ci`: write the summary here. */
+ readonly jsonPath?: string;
+ readonly sarif?: string;
+ readonly failOn: ConfidenceTier;
+ readonly baseline: string;
readonly testTimeout: number;
readonly help: boolean;
}
+const TIERS: Record = { high: "high", medium: "medium", low: "low" };
+
export function parseArgs(argv: readonly string[]): Parsed {
let command: string | null = null;
let target = ".";
@@ -56,6 +95,10 @@ export function parseArgs(argv: readonly string[]): Parsed {
let help = false;
let rule: string | undefined;
let file: string | undefined;
+ let jsonPath: string | undefined;
+ let sarif: string | undefined;
+ let failOn: ConfidenceTier = "high";
+ let baseline = BASELINE_FILE;
let testTimeout = 300;
let sawPositional = false;
@@ -83,7 +126,22 @@ export function parseArgs(argv: readonly string[]): Parsed {
} else if (a === "--verify") {
verify = true;
} else if (a === "--json") {
- json = true;
+ // Arity by command — see the note at the top of this file.
+ if (command === "ci") jsonPath = valueFor("--json", ++i);
+ else json = true;
+ } else if (a === "--sarif") {
+ sarif = valueFor("--sarif", ++i);
+ } else if (a === "--baseline") {
+ baseline = valueFor("--baseline", ++i);
+ } else if (a === "--fail-on") {
+ const raw = valueFor("--fail-on", ++i);
+ // Rejected rather than defaulted: `--fail-on hgih` quietly gating on `high` is fine, and
+ // `--fail-on critical` quietly gating on `high` when the author meant "stricter" is not.
+ const tier = TIERS[raw];
+ if (tier === undefined) {
+ throw new Error(`--fail-on expects high, medium or low, got "${raw}"`);
+ }
+ failOn = tier;
} else if (a === "--rule") {
rule = valueFor("--rule", ++i);
} else if (a === "--file") {
@@ -114,10 +172,14 @@ export function parseArgs(argv: readonly string[]): Parsed {
path: target,
verify,
json,
+ failOn,
+ baseline,
testTimeout,
help,
...(rule !== undefined ? { rule } : {}),
...(file !== undefined ? { file } : {}),
+ ...(jsonPath !== undefined ? { jsonPath } : {}),
+ ...(sarif !== undefined ? { sarif } : {}),
};
}
@@ -135,12 +197,50 @@ export async function main(argv: readonly string[]): Promise {
return args.command === null && !args.help ? 2 : 0;
}
- if (args.command !== "fix") {
+ if (args.command !== "fix" && args.command !== "ci" && args.command !== "baseline") {
process.stderr.write(`${red("error")}: unknown command "${args.command}". Try --help.\n`);
return 2;
}
+ // A gate that green-lights a typo is worse than no gate. `codegraph ci ./aps/web` indexed a
+ // directory that does not exist, found nothing, scored 100 and exited 0 — the build went
+ // green on a path that was never analysed. Exit 2, because a bad argument is a usage error
+ // and not a verdict on any code.
+ if (!statSync(path.resolve(args.path), { throwIfNoEntry: false })?.isDirectory()) {
+ process.stderr.write(`${red("error")}: "${args.path}" is not a directory.\n`);
+ return 2;
+ }
+
try {
+ if (args.command === "ci") {
+ const summary = await runCi({
+ repo: args.path,
+ failOn: args.failOn,
+ baseline: args.baseline,
+ ...(args.sarif !== undefined ? { sarif: args.sarif } : {}),
+ ...(args.jsonPath !== undefined ? { json: args.jsonPath } : {}),
+ });
+ process.stdout.write(renderCi(summary));
+ // EXIT CODE IS THE VERDICT. Everything else this command prints is advisory; the number
+ // the CI runner reads is this one, and it comes from `gateFindings` alone.
+ return summary.passed ? 0 : 1;
+ }
+
+ if (args.command === "baseline") {
+ const out = await runBaseline({ repo: args.path, baseline: args.baseline });
+ // Entries AND findings. A baseline entry is rule+file, so "3 entries" routinely accepts
+ // twenty findings; printing only the entry count told the reader they were signing off on
+ // far less than they were.
+ process.stdout.write(
+ `${bold("Wrote")} ${out.file}\n` +
+ `${out.entries} entr${out.entries === 1 ? "y" : "ies"} accepting ${out.covered} finding(s)` +
+ `${out.added === out.entries ? "" : ` (${out.added} new)`}.\n` +
+ `${bold("Health")} ${out.score}/100 as analysed, before these were accepted.\n` +
+ `${dim("Still reported, still out of the Health Score. The gate now fires on what you add next.")}\n`
+ );
+ return 0;
+ }
+
const out = await runFix({
repo: args.path,
verify: args.verify,
@@ -169,6 +269,52 @@ export async function main(argv: readonly string[]): Promise {
}
}
+/**
+ * The summary a developer reads in a terminal and a reviewer reads in CI logs.
+ *
+ * Ordered by what changes a decision: the verdict's inputs first, then the rules costing the
+ * most, then the individual findings that are actually failing the build. The Health Score is
+ * printed and NOT the verdict — it is context for the number, not the number.
+ */
+function renderCi(s: CiSummary): string {
+ const tier = (t: string, n: number) => (n === 0 ? dim(`${t} ${n}`) : `${t} ${bold(n)}`);
+ const lines = [
+ `${bold("Health")} ${s.score}/100 ${dim(s.repo)}`,
+ `${bold("Findings")} ${s.active} active · ${s.accepted} accepted${
+ s.baselineFile ? dim(` (${s.baselineFile}: ${s.acceptedByBaseline})`) : ""
+ }`,
+ `${bold("Tiers")} ${tier("high", s.tiers.high)} · ${tier("medium", s.tiers.medium)} · ${tier("low", s.tiers.low)}`,
+ ];
+
+ if (s.rules.length > 0) {
+ lines.push("", bold("Top rules"));
+ for (const r of s.rules.slice(0, 5)) {
+ const acc = r.suppressed > 0 ? dim(` ${r.suppressed} accepted`) : "";
+ lines.push(` ${padEnd(String(r.count), 4)}${padEnd(truncate(r.rule, 44), 46)}${dim(r.tier)}${acc}`);
+ }
+ }
+
+ lines.push("", `${bold("Gate")} ${dim(`--fail-on ${s.failOn}`)}`);
+ if (s.passed) {
+ lines.push(` ${green("✓")} no unaccepted findings at or above ${s.failOn} confidence.`);
+ } else {
+ // Five, not all of them: a wall of findings is a wall nobody reads, and the full set is in
+ // the SARIF and the JSON summary for anyone who wants it.
+ for (const f of s.gating.slice(0, 5)) {
+ lines.push(` ${red("✗")} ${bold(`${f.file}:${f.line}`)} ${f.rule} ${dim(f.tier)}`);
+ lines.push(` ${dim(f.evidence ?? f.title)}`);
+ }
+ if (s.gatingCount > 5) lines.push(dim(` … and ${s.gatingCount - 5} more`));
+ lines.push(
+ "",
+ `${red("FAIL")} — ${s.gatingCount} unaccepted finding(s) at or above ${s.failOn} confidence.`,
+ dim("Fix them, add `codegraph-ignore` with a reason, or run `codegraph baseline` to accept today's.")
+ );
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
function render(out: Awaited>): string {
const lines: string[] = [];
if (out.editCount === 0) {
diff --git a/apps/cli/tests/ci.test.ts b/apps/cli/tests/ci.test.ts
new file mode 100644
index 0000000..098d7e7
--- /dev/null
+++ b/apps/cli/tests/ci.test.ts
@@ -0,0 +1,270 @@
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { BASELINE_FILE, runBaseline } from "../src/ci";
+import { main, parseArgs } from "../src/main";
+
+/**
+ * `codegraph ci` / `codegraph baseline` — the gate a pull request runs.
+ *
+ * Everything here goes through `main()` rather than `runCi()`, because the contract a CI runner
+ * depends on is the EXIT CODE and nothing else. A summary that says FAIL while `main` returns 0
+ * is a green build on a rejected change, and only an end-to-end assertion catches that.
+ */
+
+const trees: string[] = [];
+afterEach(() => {
+ for (const t of trees.splice(0)) rmSync(t, { recursive: true, force: true });
+ vi.restoreAllMocks();
+});
+
+/**
+ * A repo with findings at three tiers, and two instances of ONE rule in ONE file.
+ *
+ * That duplication is load-bearing: `findingKey` is rule+file, so those two findings collapse
+ * to a single baseline entry. Any fixture with one finding per file would let a baseline that
+ * counts entries and one that counts findings agree, and the difference is what the adopter is
+ * actually deciding on.
+ */
+function repo(): string {
+ const root = mkdtempSync(path.join(tmpdir(), "cg-ci-test-"));
+ trees.push(root);
+ mkdirSync(path.join(root, "src"));
+ writeFileSync(
+ path.join(root, "src/db.js"),
+ [
+ 'import fs from "node:fs";',
+ "",
+ "export function find(db, name) {",
+ // codegraph-ignore sql-concatenation — a fixture string, not a query this repo runs
+ ' return db.query("SELECT * FROM users WHERE name = " + name);',
+ "}",
+ "",
+ "export function audit(db, id) {",
+ // Same rule, same file -> one baseline entry, two findings. That is the point of it.
+ // codegraph-ignore sql-concatenation — a fixture string, not a query this repo runs
+ ' return db.query("SELECT * FROM audit WHERE id = " + id);',
+ "}",
+ "",
+ "export function load(p) {",
+ // low: security/detect-non-literal-fs-filename
+ ' return fs.readFileSync(p, "utf8");',
+ "}",
+ "",
+ ].join("\n")
+ );
+ writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fx", version: "1.0.0" }));
+ return root;
+}
+
+/** Run the CLI exactly as `bin.mjs` does and keep what it printed. */
+async function run(...argv: string[]): Promise<{ code: number; out: string; err: string }> {
+ let out = "";
+ let err = "";
+ vi.spyOn(process.stdout, "write").mockImplementation((c) => ((out += String(c)), true));
+ vi.spyOn(process.stderr, "write").mockImplementation((c) => ((err += String(c)), true));
+ try {
+ return { code: await main(argv), out, err };
+ } finally {
+ vi.restoreAllMocks();
+ }
+}
+
+describe("parseArgs — the ci surface", () => {
+ it("takes a path for --json after `ci` and a boolean after `fix`", () => {
+ // The one flag whose arity depends on the command. Getting this wrong means `ci --json f`
+ // swallows `f` as the positional path and analyses a directory that does not exist.
+ expect(parseArgs(["ci", "--json", "out.json"]).jsonPath).toBe("out.json");
+ expect(parseArgs(["ci", "--json", "out.json"]).path).toBe(".");
+ expect(parseArgs(["fix", "--json"]).json).toBe(true);
+ expect(parseArgs(["fix", "--json"]).jsonPath).toBeUndefined();
+ });
+
+ it("defaults the gate to high and the baseline to the conventional filename", () => {
+ const a = parseArgs(["ci"]);
+ expect(a.failOn).toBe("high");
+ expect(a.baseline).toBe(BASELINE_FILE);
+ });
+
+ it("rejects an unknown tier rather than quietly gating on high", () => {
+ // `--fail-on critical` meant "stricter". Defaulting it to `high` would be a weaker gate
+ // than the author asked for, silently.
+ expect(() => parseArgs(["ci", "--fail-on", "critical"])).toThrow(/high, medium or low/);
+ expect(() => parseArgs(["ci", "--fail-on"])).toThrow(/expects a value/);
+ });
+});
+
+describe("the exit code is the verdict", () => {
+ it("exits 1 on unaccepted high-confidence findings and names them", async () => {
+ const root = repo();
+ const { code, out } = await run("ci", root);
+
+ expect(code).toBe(1);
+ expect(out).toContain("FAIL");
+ // Score, tiers and top rules are the summary's contract, not decoration.
+ expect(out).toMatch(/Health\s+\d{1,3}\/100/);
+ expect(out).toMatch(/Tiers\s+high 2 /);
+ expect(out).toContain("Top rules");
+ expect(out).toContain("sql-concatenation");
+ // Evidence, so a reviewer can falsify the finding without opening the file.
+ expect(out).toContain("SELECT * FROM users WHERE name = ");
+ });
+
+ it("gates strictly more at a lower tier", async () => {
+ const root = repo();
+ const high = await run("ci", root);
+ const low = await run("ci", root, "--fail-on", "low");
+
+ expect(low.code).toBe(1);
+ const count = (s: string) => Number(/FAIL — (\d+) unaccepted/.exec(s)?.[1]);
+ expect(count(low.out)).toBeGreaterThan(count(high.out));
+ expect(low.out).toContain("--fail-on low");
+ });
+
+ it("exits 2 on a bad invocation — not 1, which a runner reads as a failed gate", async () => {
+ const { code, err } = await run("ci", "--fail-on", "critical");
+ expect(code).toBe(2);
+ expect(err).toContain("error");
+ });
+
+ it("refuses a path that is not a directory instead of passing on nothing", async () => {
+ // The real bug: `ci ./aps/web` indexed a directory that does not exist, found no findings,
+ // scored 100 and exited 0. A green build on a path that was never analysed.
+ const { code, err } = await run("ci", path.join(tmpdir(), "cg-definitely-not-here"));
+ expect(code).toBe(2);
+ expect(err).toContain("is not a directory");
+ });
+
+ it("exits 0 once a baseline accepts today's findings, and reports what it accepted", async () => {
+ const root = repo();
+ expect((await run("ci", root)).code).toBe(1);
+
+ const wrote = await run("baseline", root);
+ expect(wrote.code).toBe(0);
+ expect(existsSync(path.join(root, BASELINE_FILE))).toBe(true);
+
+ const { code, out } = await run("ci", root);
+ expect(code).toBe(0);
+ expect(out).toContain("no unaccepted findings");
+ // Accepted findings are REPORTED, never dropped: the count and the file are both stated.
+ expect(out).toMatch(/Findings\s+0 active · \d+ accepted \(\.codegraph-baseline\.json: \d+\)/);
+ expect(out).toContain("accepted");
+ });
+
+ it("counts findings accepted, not baseline entries", async () => {
+ // The bug this pins: `findingKey` is rule+file, so the two sql-concatenation findings in
+ // src/db.js are ONE entry. Reporting "1 finding accepted" understated the adoption by half.
+ const root = repo();
+ const out = await runBaseline({ repo: root, baseline: BASELINE_FILE });
+
+ const written: { accepted: string[] } = JSON.parse(
+ readFileSync(path.join(root, BASELINE_FILE), "utf8")
+ );
+ expect(written.accepted).toContain("sql-concatenation::src/db.js");
+ expect(out.entries).toBe(written.accepted.length);
+ expect(out.covered).toBeGreaterThan(out.entries);
+ });
+
+ it("merges into an existing baseline instead of replacing it", async () => {
+ // Running it twice must not re-open what the first run accepted: `indexRepo` applies the
+ // file on disk before this sees the findings, so the fresh set alone would be empty.
+ const root = repo();
+ const first = await runBaseline({ repo: root, baseline: BASELINE_FILE });
+ const second = await runBaseline({ repo: root, baseline: BASELINE_FILE });
+
+ expect(second.entries).toBe(first.entries);
+ expect(second.added).toBe(0);
+ });
+
+ it("honours a non-default --baseline path", async () => {
+ // `indexRepo` only ever applies `/.codegraph-baseline.json`; a custom path is honoured
+ // by `runCi` alone, so it is the only thing keeping `--baseline` from being decorative.
+ const root = repo();
+ mkdirSync(path.join(root, "ci"));
+ await run("baseline", root, "--baseline", "ci/accepted.json");
+
+ expect(existsSync(path.join(root, "ci/accepted.json"))).toBe(true);
+ // The default filename was never written, so a plain `ci` must still fail.
+ expect(existsSync(path.join(root, BASELINE_FILE))).toBe(false);
+ expect((await run("ci", root)).code).toBe(1);
+ expect((await run("ci", root, "--baseline", "ci/accepted.json")).code).toBe(0);
+ });
+});
+
+describe("the SARIF log GitHub code scanning reads", () => {
+ interface Sarif {
+ $schema: string;
+ version: string;
+ runs: {
+ tool: { driver: { name: string; rules: { id: string; shortDescription: { text: string } }[] } };
+ results: {
+ ruleId: string;
+ level: string;
+ locations: { physicalLocation: { artifactLocation: { uri: string }; region: { startLine: number } } }[];
+ properties: { evidence?: string };
+ suppressions?: { kind: string }[];
+ }[];
+ invocations: { executionSuccessful: boolean }[];
+ }[];
+ }
+
+ const read = (f: string): Sarif => JSON.parse(readFileSync(f, "utf8")) as Sarif;
+
+ it("is a well-formed 2.1.0 log whose every ruleId resolves", async () => {
+ const root = repo();
+ const log = path.join(root, "out.sarif");
+ await run("ci", root, "--fail-on", "low", "--sarif", log);
+ const sarif = read(log);
+
+ expect(sarif.version).toBe("2.1.0");
+ expect(sarif.$schema).toContain("sarif-2.1.0");
+ expect(sarif.runs).toHaveLength(1);
+ const run0 = sarif.runs[0]!;
+ expect(run0.tool.driver.name).toBe("CodeGraph");
+ expect(run0.results.length).toBeGreaterThan(0);
+ expect(run0.invocations[0]?.executionSuccessful).toBe(true);
+
+ // A dangling ruleId is the failure GitHub rejects the upload on, and it is invisible in
+ // any test that only counts results.
+ const declared = new Set(run0.tool.driver.rules.map((r) => r.id));
+ expect(declared.size).toBe(run0.tool.driver.rules.length);
+ for (const r of run0.results) expect(declared.has(r.ruleId)).toBe(true);
+ });
+
+ it("uses the stable rule id, not a slug of the title", async () => {
+ // Titles are prose and get reworded; a ruleId derived from one silently re-opens every
+ // alert a team had triaged. `Issue.rule` is the identity the baseline keys on too.
+ const root = repo();
+ const log = path.join(root, "out.sarif");
+ await run("ci", root, "--fail-on", "low", "--sarif", log);
+ const run0 = read(log).runs[0]!;
+
+ const sql = run0.results.find((r) => r.ruleId === "sql-concatenation");
+ expect(sql).toBeDefined();
+ expect(sql!.locations[0]!.physicalLocation.artifactLocation.uri).toBe("src/db.js");
+ expect(sql!.properties.evidence).toContain("SELECT");
+ // The declared rule keeps the human title; the id stays machine-stable.
+ const rule = run0.tool.driver.rules.find((r) => r.id === "sql-concatenation");
+ expect(rule?.shortDescription.text).not.toBe("sql-concatenation");
+ });
+
+ it("marks accepted findings suppressed rather than dropping them", async () => {
+ const root = repo();
+ const before = path.join(root, "before.sarif");
+ const after = path.join(root, "after.sarif");
+
+ await run("ci", root, "--fail-on", "low", "--sarif", before);
+ expect(read(before).runs[0]!.results.some((r) => r.suppressions)).toBe(false);
+
+ await run("baseline", root);
+ await run("ci", root, "--fail-on", "low", "--sarif", after);
+ const run0 = read(after).runs[0]!;
+
+ // Same number of results as before the baseline — a baseline that made alerts vanish is an
+ // allowlist nobody reviews. They are marked, and `external` is what makes code scanning
+ // show them as dismissed.
+ expect(run0.results).toHaveLength(read(before).runs[0]!.results.length);
+ expect(run0.results.every((r) => r.suppressions?.[0]?.kind === "external")).toBe(true);
+ });
+});
diff --git a/apps/cli/tests/cli.test.ts b/apps/cli/tests/cli.test.ts
index 2a1a6fb..fe7e205 100644
--- a/apps/cli/tests/cli.test.ts
+++ b/apps/cli/tests/cli.test.ts
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parseArgs } from "../src/main";
-import { runFix } from "../src/fix";
+import { buildDiff, runFix } from "../src/fix";
/**
* `codegraph fix` — the command that makes `verified: full` reachable (SPIKES.md §2).
@@ -28,6 +28,8 @@ function fixture(opts: { testExits?: number } = {}): string {
path.join(root, "src/app.js"),
[
"export function add(a, b) {",
+ // Reported, deliberately NOT auto-fixed: the debug-output codemod was withdrawn after
+ // it deleted a script's intended print(). It must survive every run below.
' console.log("debug", a, b);',
" return a + b;",
"}",
@@ -38,9 +40,15 @@ function fixture(opts: { testExits?: number } = {}): string {
" } catch (e) {}",
"}",
"",
- "// TODO: remove before release",
+ "// TODO: remove before release — also reported, also not auto-fixed",
'export const VERSION = "1.0.0";',
"",
+ "export function alsoRisky() {",
+ " try {",
+ ' return JSON.parse("[]");',
+ " } catch (e) {}",
+ "}",
+ "",
].join("\n")
);
writeFileSync(
@@ -144,12 +152,16 @@ describe("runFix", () => {
verify: false,
json: false,
testTimeout: 60,
- rule: "legacy/todo-fixme-marker",
+ rule: "legacy/empty-catch-block",
});
- expect(out.editCount).toBe(1);
- expect(out.diff).toMatch(/TODO/);
- // The debug line is a different rule and must survive.
- expect(out.diff).not.toMatch(/console\.log/);
+ // Two empty catches in the fixture, both annotated by the one surviving fixer.
+ expect(out.editCount).toBe(2);
+ expect(out.diff).toMatch(/intentionally ignored/);
+ // Other reported classes have no fixer and must survive untouched. They may still appear
+ // as CONTEXT lines in the diff, so the assertion is on the changed lines only.
+ const changedLines = out.diff.split("\n").filter((l) => /^[+-]/.test(l) && !/^[+-]{3}/.test(l));
+ expect(changedLines.some((l) => l.includes("console.log"))).toBe(false);
+ expect(changedLines.some((l) => l.includes("TODO"))).toBe(false);
});
it("refuses a rule no provider handles instead of running everything", async () => {
@@ -190,8 +202,13 @@ describe("the emitted diff", () => {
).not.toThrow();
const after = readFileSync(path.join(root, "src/app.js"), "utf8");
- expect(after).not.toMatch(/console\.log/);
- expect(after).not.toMatch(/TODO/);
+ // What the run DID: both empty catches documented, nothing deleted.
+ expect(after.match(/intentionally ignored/g)?.length).toBe(2);
+ // What it deliberately did NOT do. These classes are reported and left to a human since
+ // the line-deleting codemods were withdrawn; a run that silently removed them again is
+ // the regression this pins.
+ expect(after).toMatch(/console\.log/);
+ expect(after).toMatch(/TODO/);
});
it("numbers the new side of each hunk correctly", async () => {
@@ -215,9 +232,11 @@ describe("the emitted diff", () => {
expect(h.newStart).toBe(h.oldStart - delta);
delta += h.oldCount - h.newCount;
}
- // The fixture deletes lines, so the shift must actually be exercised — otherwise this test
- // would pass on a diff where every delta is zero and prove nothing.
- expect(delta).toBeGreaterThan(0);
+ // Every surviving fixer REPLACES lines, so `delta` is zero here by construction. The
+ // offset arithmetic that a deleting edit exercises is pinned directly against `buildDiff`
+ // in "shifts later hunks by the lines removed before them" below — via a real fixer it
+ // would need a line-deleting codemod, and those were withdrawn for good reason.
+ expect(delta).toBe(0);
});
it("produces a multi-hunk diff for edits far apart in one file", async () => {
@@ -226,3 +245,54 @@ describe("the emitted diff", () => {
expect(out.diff.match(/^@@ /gm)?.length).toBeGreaterThanOrEqual(2);
});
});
+
+describe("buildDiff hunk offsets", () => {
+ /**
+ * The new-side offset arithmetic, pinned directly.
+ *
+ * It used to be exercised through `runFix` because a fixer DELETED lines, and deletion is
+ * what makes the second hunk's `+` start diverge from its `-` start. Those codemods were
+ * withdrawn (they deleted a script's intended output), so every surviving fixer replaces
+ * rather than removes and the property became unobservable end to end. It is not
+ * hypothetical: emitting the before-index on both sides produced a diff `git apply` rejected
+ * with "patch does not apply", twice, and neither failure was visible by reading the output.
+ */
+ const file = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`);
+
+ it("shifts later hunks by the lines removed before them", () => {
+ const diff = buildDiff(
+ new Map([
+ [
+ "src/a.ts",
+ {
+ before: [...file, ""],
+ // Line 5 (index 4) deleted; line 30 (index 29) replaced, far enough away to force
+ // a second hunk.
+ edits: new Map([
+ [4, null],
+ [29, "line 30 (annotated)"],
+ ]),
+ },
+ ],
+ ]),
+ );
+ const headers = [...diff.matchAll(/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@$/gm)].map((m) => ({
+ oldStart: Number(m[1]),
+ newStart: Number(m[3]),
+ }));
+ expect(headers).toHaveLength(2);
+ // First hunk: nothing removed before it, so both sides agree.
+ expect(headers[0]!.newStart).toBe(headers[0]!.oldStart);
+ // Second hunk: exactly one line was deleted earlier in the file.
+ expect(headers[1]!.newStart).toBe(headers[1]!.oldStart - 1);
+ });
+
+ it("never emits the phantom trailing line a newline-terminated file splits into", () => {
+ const diff = buildDiff(
+ new Map([["src/a.ts", { before: [...file, ""], edits: new Map([[39, null]]) }]]),
+ );
+ const header = /^@@ -(\d+),(\d+) \+(\d+),(\d+) @@$/m.exec(diff)!;
+ // The hunk may not claim more lines than the file actually has.
+ expect(Number(header[1]) + Number(header[2]) - 1).toBeLessThanOrEqual(file.length);
+ });
+});
diff --git a/apps/web/DEPLOY.md b/apps/web/DEPLOY.md
index a049a5d..41386ff 100644
--- a/apps/web/DEPLOY.md
+++ b/apps/web/DEPLOY.md
@@ -176,7 +176,8 @@ failures was reproducible locally in seconds, and none of them was reproducible
| `NEXT_PUBLIC_APP_URL` | website build + GitHub OAuth callback | `https://app.codegraph.dev` | Marketing "Start Indexing" target; also the base URL used to build the OAuth `redirect_uri` (falls back to the request's own origin if unset) |
| `PORT` | app runtime | `4000` | HTTP port |
| `HOSTNAME` | app runtime | `0.0.0.0` | Bind address (Docker) |
-| `CG_MAX_FILES` | app runtime | `4000` | Max files scanned per repo |
+| `CG_MAX_FILES` | app runtime | `4000` | Max files scanned per repo. Reaching it truncates the walk, and the report then says so — the Health Score is labelled a sample rather than presented as the repository's score. `microsoft/TypeScript` holds 39,334 analysable files, so raise this if you want a whole-repository score for a codebase that size. |
+| `CG_MAX_FILE_BYTES` | app runtime | `400000` | Per-file size ceiling; anything larger is counted in the coverage report as "over the size cap" and never read. Guards against minified bundles and vendored blobs. |
| `CG_CLONE_TIMEOUT_MS` | app runtime | `90000` | git clone timeout |
| `CG_DATA_DIR` | app runtime | `./data` | SQLite location |
| `CG_ALLOW_LOCAL_ACCESS` | app runtime | unset (= off in production) | Opt in to local-folder indexing + server-side folder browsing on a public deployment. Only set `true` on a trusted, single-operator host. |
diff --git a/apps/web/package.json b/apps/web/package.json
index 3cb11bf..658f763 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -12,7 +12,6 @@
"cg": "tsx terminal/cli.ts"
},
"dependencies": {
- "@anthropic-ai/claude-agent-sdk": "^0.3.211",
"@codegraph/analysis": "*",
"@codegraph/analysis-model": "*",
"@codegraph/config": "*",
@@ -26,7 +25,6 @@
"@codegraph/sandbox": "*",
"@codegraph/vcs": "*",
"@codegraph/verify": "*",
- "@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0",
"@types/d3-hierarchy": "^3.1.7",
"clsx": "^2.1.1",
@@ -40,7 +38,6 @@
"tailwind-merge": "^3.6.0",
"tree-sitter-wasms": "0.1.13",
"web-tree-sitter": "0.22.6",
- "zod": "^4.0.0",
"@codegraph/score-engine": "*"
},
"overrides": {
diff --git a/apps/web/src/app/api/fleet/route.ts b/apps/web/src/app/api/fleet/route.ts
index 993a73e..35acace 100644
--- a/apps/web/src/app/api/fleet/route.ts
+++ b/apps/web/src/app/api/fleet/route.ts
@@ -27,25 +27,33 @@ export const dynamic = "force-dynamic";
*/
/**
- * Repo name (lowercased) → repo id, keyed by both the full `owner/repo` name
- * and its bare last segment, because dependencies are declared as `express`
- * while repos are named `expressjs/express`.
+ * Package name -> the repository that publishes it.
*
- * Two passes, not one, so precedence is deterministic and does not depend on
- * row order: an exact full-name match always beats another repo's bare segment.
- * Between two repos with the same bare segment the first row wins, and the
- * query's `ORDER BY created_at DESC, id ASC` makes "first" stable.
+ * WHAT THIS USED TO KEY ON, AND WHY IT FOUND NOTHING
+ *
+ * It indexed repositories by DISPLAY NAME (`CodeGraph`, `sindresorhus/slugify`) and then
+ * matched other repositories' declared DEPENDENCY names (`@codegraph/analysis`, `react`)
+ * against it. Those are different namespaces, and they coincide only by luck: this monorepo is
+ * displayed as `CodeGraph` and publishes `@codegraph/analysis`. Measured here: 12 repositories,
+ * 0 edges — while `/api/org`, a second implementation keyed on manifest names, found 14 across
+ * the same set. The page said "dependency edges between indexed repositories" and drew none.
+ *
+ * Manifest-declared names come first because they are the real answer. The display name is
+ * kept as a fallback, since a repository whose manifests were never parsed can still be the
+ * obvious target for `gorilla/mux`, and losing that would trade one silent gap for another.
*/
function indexByName(repos: readonly FleetRepo[]): Map {
const byName = new Map();
+ const claim = (key: string, id: string) => {
+ const k = key.trim().toLowerCase();
+ // First claim wins, so a precise key is never overwritten by a looser one added later.
+ if (k && !byName.has(k)) byName.set(k, id);
+ };
+ for (const r of repos) for (const pkg of r.packageNames) claim(pkg, r.id);
+ for (const r of repos) claim(r.name, r.id);
for (const r of repos) {
const name = r.name.toLowerCase();
- if (!byName.has(name)) byName.set(name, r.id);
- }
- for (const r of repos) {
- const name = r.name.toLowerCase();
- const bare = name.slice(name.lastIndexOf("/") + 1);
- if (bare && !byName.has(bare)) byName.set(bare, r.id);
+ claim(name.slice(name.lastIndexOf("/") + 1), r.id);
}
return byName;
}
@@ -65,6 +73,12 @@ export async function GET(req: NextRequest) {
score: r.score,
sourceType: r.sourceType,
loc: r.loc,
+ // Movement since the previous index — what the fleet index below the graph ranks
+ // by. Computed in one query beside the rows, not per node.
+ drift: r.drift,
+ // The fleet index ranks these by score, so a score computed over a truncated walk has
+ // to say so where it is compared against whole-repository ones.
+ capHit: r.capHit,
});
// Per-source, so one repo listing the same package twice (or listing both
diff --git a/apps/web/src/app/api/github/repos/route.ts b/apps/web/src/app/api/github/repos/route.ts
index 5d4ecc1..7fc32aa 100644
--- a/apps/web/src/app/api/github/repos/route.ts
+++ b/apps/web/src/app/api/github/repos/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { fetchGithubRepos } from "@/lib/githubOAuth";
+import { logger } from "@codegraph/observability";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -15,6 +16,14 @@ export async function GET(req: NextRequest) {
const { repos, hasMore } = await fetchGithubRepos(session.accessToken, page);
return NextResponse.json({ repos, page, hasMore });
} catch (e) {
- return NextResponse.json({ error: e instanceof Error ? e.message : "Failed to list GitHub repos" }, { status: 502 });
+ // `fetchGithubRepos` throws its own controlled string, but a transport failure does not:
+ // undici surfaces `fetch failed` with a cause carrying host, port and TLS detail, and any
+ // future body-derived message would carry whatever GitHub said. Neither belongs on the
+ // wire, and the client can act on exactly one thing — retry or re-authenticate.
+ logger.warn("GitHub repo list failed", { error: e instanceof Error ? e.message : String(e) });
+ return NextResponse.json(
+ { error: "Could not list your GitHub repositories. Sign in again, or retry in a moment." },
+ { status: 502 },
+ );
}
}
diff --git a/apps/web/src/app/api/index/route.ts b/apps/web/src/app/api/index/route.ts
index 33d0ce0..597cb4c 100644
--- a/apps/web/src/app/api/index/route.ts
+++ b/apps/web/src/app/api/index/route.ts
@@ -14,6 +14,7 @@ import {
ANONYMOUS_INDEXING_DISABLED_MESSAGE,
ANONYMOUS_CONSENT_MESSAGE,
} from "@/lib/authz";
+import { getVisitorId, mintVisitorId, privateTrialsAvailable, setVisitorCookie } from "@/lib/visitor";
import { rateLimit, clientIp } from "@/lib/rateLimit";
import { logger } from "@codegraph/observability";
@@ -60,15 +61,41 @@ export async function POST(req: NextRequest) {
const localPath = (body.localPath || "").trim();
const session = getSession(req);
- // Before any work: a signed-out index lands in the shared public bucket, so it needs
- // either an account or an explicit acknowledgement. `requiresConsent` is what lets the
- // console tell the two refusals apart — one is answerable by the user, the other is
- // the operator's decision and only offers sign-in.
+ /**
+ * Who owns what this request creates, and whether an anonymous caller may create it at all.
+ *
+ * TWO CONTROLS, answering different questions, and both survive here.
+ *
+ * `anonymousIndexingAllowed()` is the OPERATOR's switch: a deployment can refuse signed-out
+ * indexing outright, and no acknowledgement from the caller overrides it.
+ *
+ * The visitor cookie is the TENANCY. Signed in → that account, private, as before. Signed
+ * out → the browser making the request, via a signed cookie, so a trial repository is
+ * private to whoever ran it instead of landing in a bucket every visitor can read, edit and
+ * delete. The shared bucket is still reachable and is now what `acknowledgePublic: true`
+ * MEANS — an explicit request to publish, rather than the only anonymous option.
+ *
+ * Consent is therefore demanded only when the result really will be world-readable: when
+ * the caller asks to publish, or when there is no `CG_SESSION_SECRET` to sign a visitor
+ * cookie with and the shared bucket is the only place left to put it. Asking for it on a
+ * run that lands somewhere private would be a warning about a thing that is not happening,
+ * which is how consent prompts get clicked through. `requiresConsent` still distinguishes
+ * the two refusals: one is answerable by the user, the other is the operator's decision and
+ * only offers sign-in.
+ *
+ * Without a session secret the old behaviour stands, deliberately: an unsigned owner id is
+ * one any visitor could claim by editing a cookie, which would be worse than the shared
+ * bucket precisely because it would look private.
+ */
+ const wantsPublic = body.acknowledgePublic === true;
+ const existingVisitor = getVisitorId(req);
+ const trialsAvailable = privateTrialsAvailable();
+
if (!session) {
if (!anonymousIndexingAllowed()) {
return NextResponse.json({ error: ANONYMOUS_INDEXING_DISABLED_MESSAGE }, { status: 401 });
}
- if (body.acknowledgePublic !== true) {
+ if (!trialsAvailable && !wantsPublic) {
return NextResponse.json(
{ error: ANONYMOUS_CONSENT_MESSAGE, requiresConsent: true },
{ status: 401 }
@@ -76,6 +103,15 @@ export async function POST(req: NextRequest) {
}
}
+ const visitorId =
+ session || wantsPublic || !trialsAvailable ? null : existingVisitor ?? mintVisitorId();
+ const ownerId = session?.userId ?? visitorId;
+
+ /** Issue the cookie on the way out, but only when this request minted a NEW identity. */
+ const withVisitorCookie = (res: NextResponse): NextResponse => {
+ if (visitorId !== null && existingVisitor === null) setVisitorCookie(res, visitorId, req);
+ return res;
+ };
try {
if (localPath) {
if (!localAccessAllowed()) {
@@ -89,7 +125,7 @@ export async function POST(req: NextRequest) {
if (!withinLocalAccessRoot(path.resolve(localPath))) {
return NextResponse.json({ error: LOCAL_ACCESS_ROOT_MESSAGE }, { status: 403 });
}
- return enqueued(createIndexJob(localPath, "local", undefined, session?.userId ?? null));
+ return withVisitorCookie(enqueued(createIndexJob(localPath, "local", undefined, ownerId)));
}
if (repoUrl) {
if (!/^https?:\/\/[\w.-]+\/.+/.test(repoUrl) || !isPublicHttpUrl(repoUrl)) {
@@ -98,7 +134,7 @@ export async function POST(req: NextRequest) {
{ status: 400 }
);
}
- return enqueued(createIndexJob(repoUrl, "git", session?.accessToken, session?.userId ?? null));
+ return withVisitorCookie(enqueued(createIndexJob(repoUrl, "git", session?.accessToken, ownerId)));
}
return NextResponse.json({ error: "Provide repoUrl or localPath" }, { status: 400 });
} catch (e) {
diff --git a/apps/web/src/app/api/jobs/[id]/events/route.ts b/apps/web/src/app/api/jobs/[id]/events/route.ts
index c34a941..6cbe773 100644
--- a/apps/web/src/app/api/jobs/[id]/events/route.ts
+++ b/apps/web/src/app/api/jobs/[id]/events/route.ts
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { logger } from "@codegraph/observability";
+import { config } from "@codegraph/config";
import { repoAccessDenied } from "@/lib/authz";
+import { clientIp } from "@/lib/rateLimit";
import { getJob } from "@/lib/store";
export const runtime = "nodejs";
@@ -30,6 +32,44 @@ const TICK_MS = 500;
* tab closed mid-index does not always deliver an abort promptly.
*/
const MAX_STREAM_MS = 15 * 60_000;
+/**
+ * Concurrently open streams, globally and per client.
+ *
+ * Each one holds a Node handle and a twice-a-second SQLite read for up to fifteen minutes,
+ * and nothing bounded how many a single client could open. Refusing past the ceiling is safe
+ * to do bluntly because the client already falls back to polling `/api/jobs/:id` when the
+ * stream is unavailable — SSE through a proxy was never guaranteed, so the fallback exists
+ * and is exercised.
+ *
+ * Module-level state, which is correct here and not a coincidence: the thing being counted is
+ * connections held open by THIS process, so a per-process counter is exactly the scope of the
+ * resource. Nothing to coordinate across instances.
+ */
+const openStreams = { total: 0, byIp: new Map() };
+
+function acquireStream(ip: string): boolean {
+ if (openStreams.total >= config.maxEventStreams) return false;
+ const forIp = openStreams.byIp.get(ip) ?? 0;
+ if (forIp >= config.maxEventStreamsPerIp) return false;
+ openStreams.total++;
+ openStreams.byIp.set(ip, forIp + 1);
+ return true;
+}
+
+function releaseStream(ip: string): void {
+ openStreams.total = Math.max(0, openStreams.total - 1);
+ const forIp = (openStreams.byIp.get(ip) ?? 1) - 1;
+ // Delete rather than keep a zero: the map is keyed by client IP and would otherwise grow
+ // once per distinct visitor for the life of the process.
+ if (forIp <= 0) openStreams.byIp.delete(ip);
+ else openStreams.byIp.set(ip, forIp);
+}
+
+/** Test seam — a module-level counter outlives a test file otherwise. */
+export function resetEventStreamsForTests(): void {
+ openStreams.total = 0;
+ openStreams.byIp.clear();
+}
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -42,6 +82,17 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
const denied = repoAccessDenied(req, initial.repoId);
if (denied) return denied;
+ // Taken AFTER the access check so a refused stream cannot be used to probe job ids, and
+ // released by `finish()` on every exit path below.
+ const ip = clientIp(req);
+ if (!acquireStream(ip)) {
+ logger.warn("SSE stream refused: at capacity", { jobId: id, open: openStreams.total });
+ return NextResponse.json(
+ { error: "Too many open progress streams. The client falls back to polling." },
+ { status: 503, headers: { "Retry-After": "5" } },
+ );
+ }
+
const encoder = new TextEncoder();
const started = Date.now();
@@ -67,6 +118,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
const finish = (): void => {
if (closed) return;
closed = true;
+ // Every exit runs through here — timeout, terminal status, vanished job, client
+ // disconnect — which is why the slot is released here and nowhere else.
+ releaseStream(ip);
if (timer !== undefined) clearInterval(timer);
try {
controller.close();
diff --git a/apps/web/src/app/api/org/route.ts b/apps/web/src/app/api/org/route.ts
new file mode 100644
index 0000000..1dbcda0
--- /dev/null
+++ b/apps/web/src/app/api/org/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from "next/server";
+import { viewerId } from "@/lib/authz";
+import { getRepo, listRepos } from "@/lib/store";
+import { buildOrgGraph, type OrgRepoInput } from "@/lib/orggraph";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * GET /api/org — the cross-repository knowledge graph.
+ *
+ * TENANCY IS THE WHOLE RISK HERE, and it is why this route resolves each repository through
+ * `getRepo(id, viewer)` rather than reading rows directly. Every other repo route is scoped to
+ * one id that `repoAccessDenied` has already checked; this one spans MANY, so a single
+ * unscoped query would return private repositories to anyone who asked — the exact
+ * cross-tenant leak this codebase has already had to close once (Phase 0.6).
+ *
+ * The scoping is enforced twice over, deliberately. `listRepos(viewer)` applies the
+ * `owner_id IS NULL OR owner_id = ?` predicate in SQL, and `getRepo(id, viewer)` applies it
+ * again per row. The second pass is not redundant: it means a future change to the listing
+ * query cannot silently widen what this route returns, because the detail fetch would still
+ * refuse. A repo that vanishes between the two calls simply contributes nothing.
+ */
+export async function GET(req: NextRequest) {
+ const viewer = viewerId(req);
+
+ const inputs: OrgRepoInput[] = [];
+ for (const summary of listRepos(viewer)) {
+ // Only completed indexes carry the manifests and ownership this graph is built from. A
+ // queued or failed repo is not evidence of anything and is left out rather than reported
+ // as a node with no edges, which would read as "this repo depends on nothing".
+ if (summary.status !== "done") continue;
+ const repo = getRepo(summary.id, viewer);
+ if (!repo) continue;
+ inputs.push({
+ id: repo.id,
+ name: repo.name,
+ dependencies: repo.dependencies,
+ packageNames: repo.packageNames ?? [],
+ ownership: repo.ownership,
+ });
+ }
+
+ return NextResponse.json(buildOrgGraph(inputs));
+}
diff --git a/apps/web/src/app/api/repos/[id]/assistant/route.ts b/apps/web/src/app/api/repos/[id]/assistant/route.ts
deleted file mode 100644
index 96d38f9..0000000
--- a/apps/web/src/app/api/repos/[id]/assistant/route.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { getWorkspaceDir } from "@/lib/store";
-import { repoAccessDenied } from "@/lib/authz";
-import { getSession } from "@/lib/session";
-import { aiAssistantConfigured, resetAssistantSession, sendMessage } from "@/lib/agents/assistant";
-import { localLlmConfigured, resetLocalAssistantSession, sendLocalMessage } from "@/lib/agents/localAssistant";
-import { effectiveLocalLlmConfig, effectiveClaudeModel, ANONYMOUS_USER_ID } from "@/lib/settings";
-import type { AssistantProvider, AssistantProviders } from "@/lib/types";
-
-export const runtime = "nodejs";
-export const dynamic = "force-dynamic";
-
-const MAX_MESSAGE_LENGTH = 8000;
-
-function userIdFrom(req: NextRequest): number {
- return getSession(req)?.userId ?? ANONYMOUS_USER_ID;
-}
-
-function providers(userId: number): AssistantProviders {
- const local = effectiveLocalLlmConfig(userId);
- return {
- claude: aiAssistantConfigured(userId),
- local: !!local,
- claudeModel: effectiveClaudeModel(userId),
- localModel: local?.model,
- };
-}
-
-// GET /api/repos/:id/assistant -> { providers } — lets the Editor UI decide
-// whether to show the AI Assistant tab, and which backend(s) to offer.
-export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- const denied = repoAccessDenied(req, id);
- if (denied) return denied;
- return NextResponse.json({ providers: providers(userIdFrom(req)) });
-}
-
-// POST /api/repos/:id/assistant { message: string, provider?: "claude" | "local" }
-// -> text/event-stream of AssistantEvent frames for one conversation turn.
-// `provider` defaults to Claude if configured, else the local model if that
-// alone is configured.
-export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- const denied = repoAccessDenied(req, id);
- if (denied) return denied;
-
- const ws = getWorkspaceDir(id);
- if (!ws) return NextResponse.json({ error: "Workspace not ready" }, { status: 404 });
-
- let body: { message?: unknown; provider?: unknown };
- try {
- body = await req.json();
- } catch {
- return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
- }
- const message = typeof body.message === "string" ? body.message.trim() : "";
- if (!message) return NextResponse.json({ error: "message is required" }, { status: 400 });
- if (message.length > MAX_MESSAGE_LENGTH) {
- return NextResponse.json({ error: `message is too long (max ${MAX_MESSAGE_LENGTH} characters)` }, { status: 400 });
- }
-
- const userId = userIdFrom(req);
- const available = providers(userId);
- const requested = body.provider === "claude" || body.provider === "local" ? body.provider : undefined;
- const provider: AssistantProvider | undefined = requested ?? (available.claude ? "claude" : available.local ? "local" : undefined);
- if (!provider || !available[provider]) {
- return NextResponse.json(
- {
- error: provider
- ? `The "${provider}" AI Assistant provider is not configured on this deployment.`
- : "No AI Assistant provider is configured on this deployment (set ANTHROPIC_API_KEY and/or CG_LOCAL_LLM_BASE_URL + CG_LOCAL_LLM_MODEL).",
- },
- { status: 501 },
- );
- }
-
- const hasGit = ws.sourceType === "git";
- const events = provider === "claude"
- ? sendMessage(id, ws.dir, hasGit, message, userId, req.signal)
- : sendLocalMessage(id, ws.dir, hasGit, message, userId, req.signal);
- const encoder = new TextEncoder();
- const stream = new ReadableStream({
- async start(controller) {
- try {
- for await (const event of events) {
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
- }
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ kind: "error", message: msg })}\n\n`));
- } finally {
- controller.close();
- }
- },
- });
-
- return new NextResponse(stream, {
- headers: {
- "Content-Type": "text/event-stream",
- "Cache-Control": "no-cache, no-transform",
- Connection: "keep-alive",
- "X-Accel-Buffering": "no",
- },
- });
-}
-
-// DELETE /api/repos/:id/assistant -> closes the repo's in-process assistant
-// session(s) ("New chat"); safe to call even if none exist.
-export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- const denied = repoAccessDenied(req, id);
- if (denied) return denied;
- const userId = userIdFrom(req);
- resetAssistantSession(id, userId);
- resetLocalAssistantSession(id, userId);
- return NextResponse.json({ ok: true });
-}
diff --git a/apps/web/src/app/api/repos/[id]/dependencies/route.ts b/apps/web/src/app/api/repos/[id]/dependencies/route.ts
new file mode 100644
index 0000000..fea7421
--- /dev/null
+++ b/apps/web/src/app/api/repos/[id]/dependencies/route.ts
@@ -0,0 +1,76 @@
+import { NextRequest, NextResponse } from "next/server";
+import { repoAccessDenied, viewerId } from "@/lib/authz";
+import { getRepo } from "@/lib/store";
+import { replacementImpact } from "@codegraph/analysis";
+import { QueryEngine } from "@/lib/codeintel/query";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * A package name from a query string.
+ *
+ * Never reaches a filesystem path or a subprocess — it is compared against strings already in
+ * the index — but it IS used to build a `RegExp` in `replacementImpact`, so an unbounded value
+ * is a ReDoS surface. npm's own naming rules are narrower than this and every other ecosystem's
+ * are too, so anything outside it cannot name a real package.
+ */
+const PACKAGE = /^[@a-zA-Z0-9._/-]{1,214}$/;
+
+// GET /api/repos/:id/dependencies?op=advisories | unused | impact&package=…
+export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const denied = repoAccessDenied(req, id);
+ if (denied) return denied;
+ const repo = getRepo(id, viewerId(req));
+ if (!repo) return NextResponse.json({ error: "Repo not found" }, { status: 404 });
+
+ const { searchParams } = new URL(req.url);
+ const op = searchParams.get("op") || "advisories";
+
+ if (op === "advisories") {
+ /**
+ * ABSENT and `status: "disabled"` are DIFFERENT answers and both are returned as-is.
+ *
+ * Absent means the run predates advisory lookup. `disabled` means this deployment did not
+ * ask for one. `unavailable` means we tried and could not. Only `checked` licenses the
+ * reader to conclude anything about vulnerabilities, and flattening any of the other three
+ * into an empty list would turn "we did not look" into "there is nothing there" — the one
+ * failure this whole feature is shaped to prevent.
+ */
+ if (!repo.advisories) {
+ return NextResponse.json({ error: "Not analysed — re-index this repo" }, { status: 409 });
+ }
+ return NextResponse.json(repo.advisories);
+ }
+
+ if (op === "unused") {
+ if (!repo.unusedDependencies) {
+ return NextResponse.json({ error: "Not analysed — re-index this repo" }, { status: 409 });
+ }
+ // Candidates with a confidence and a caveat, never a verdict — the shape says so and the
+ // UI must render both, or a 0.15-confidence guess reads like a fact.
+ return NextResponse.json({ candidates: repo.unusedDependencies });
+ }
+
+ if (op === "impact") {
+ const pkg = searchParams.get("package");
+ if (!pkg || !PACKAGE.test(pkg)) {
+ return NextResponse.json({ error: "Missing or malformed package" }, { status: 400 });
+ }
+ /**
+ * Import sites are found by scanning source text, and the stored index does not keep file
+ * CONTENTS — only the graph, the viz nodes and the findings. So this answers from the
+ * symbol graph alone and says which half it could not compute, rather than silently
+ * returning an empty `importSites` that reads as "nothing imports this".
+ */
+ const qe = new QueryEngine(repo.symbolGraph);
+ const impact = replacementImpact(pkg, [], repo.symbolGraph, qe);
+ return NextResponse.json({
+ ...impact,
+ note: "import sites need file contents, which the stored index does not retain; blast radius is from the symbol graph",
+ });
+ }
+
+ return NextResponse.json({ error: `Unknown op: ${op}` }, { status: 400 });
+}
diff --git a/apps/web/src/app/api/repos/[id]/git/route.ts b/apps/web/src/app/api/repos/[id]/git/route.ts
index 62792ac..3193fb9 100644
--- a/apps/web/src/app/api/repos/[id]/git/route.ts
+++ b/apps/web/src/app/api/repos/[id]/git/route.ts
@@ -148,7 +148,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ ok: true, output: out });
}
if (op === "pull") {
- const out = await pull(ws.dir);
+ // Same per-invocation credential as `push`, and the same host pin. The workspace's
+ // stored remote no longer carries a token (`cloneRepo` strips it), so a private repo
+ // is pulled with the caller's own PAT or not at all — the credential is never at rest
+ // on the data disk.
+ const repo = getRepo(id, viewerId(req));
+ if (githubToken && repo?.sourceType === "git" && !isGithubHost(repo.url)) {
+ return NextResponse.json(
+ { error: "A GitHub PAT can only be used to pull from a github.com-hosted repo." },
+ { status: 400 }
+ );
+ }
+ const remote = githubToken && repo?.sourceType === "git" ? withToken(repo.url, githubToken) : undefined;
+ const out = await pull(ws.dir, remote);
scheduleReindex(id);
return NextResponse.json({ ok: true, output: out });
}
diff --git a/apps/web/src/app/api/repos/[id]/intel/route.ts b/apps/web/src/app/api/repos/[id]/intel/route.ts
index 0ee1c0a..2733435 100644
--- a/apps/web/src/app/api/repos/[id]/intel/route.ts
+++ b/apps/web/src/app/api/repos/[id]/intel/route.ts
@@ -1,13 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { getRepo } from "@/lib/store";
import { repoAccessDenied, viewerId } from "@/lib/authz";
-import { QueryEngine } from "@/lib/codeintel/query";
+import { QueryEngine, blastRadius, isTestFile, rankUntestedHubs, untestedHubs } from "@/lib/codeintel/query";
import { buildContext } from "@/lib/codeintel/context";
+import { ask, endpointsAffectedBy, unauthenticatedSinkPaths } from "@codegraph/core-graph";
+import { askCorpus } from "@/lib/codeintel/askCorpus";
+import { logger } from "@codegraph/observability";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-// GET /api/repos/:id/intel?op=search&q=... | callers | callees | impact | context | cycles | deadcode | hubs
+// GET /api/repos/:id/intel?op=search&q=... | callers | callees | impact | blast | untested
+// | context | cycles | deadcode | hubs | endpoints | flows | api-impact | unauth-paths | taint
+// | ask
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const denied = repoAccessDenied(req, id);
@@ -33,6 +38,14 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ symbol: qe.get(sym), results: qe.members(sym) });
case "impact":
return NextResponse.json({ symbol: qe.get(sym), results: qe.impact(sym, 3) });
+ case "blast": {
+ // Four hops rather than impact()'s three: the fourth is usually where a route
+ // handler or a test finally shows up, which is the answer people came for.
+ const depth = Math.min(6, Math.max(1, Number(url.searchParams.get("depth")) || 4));
+ return NextResponse.json(blastRadius(qe, sym, depth));
+ }
+ case "untested":
+ return NextResponse.json({ results: rankUntestedHubs(untestedHubs(qe)) });
case "cycles":
return NextResponse.json({ cycles: qe.cycles() });
case "deadcode":
@@ -41,6 +54,69 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ results: qe.hubs(20) });
case "context":
return NextResponse.json(buildContext(g, q));
+ /**
+ * The API surface and taint report are computed at INDEX time and stored on the repo, so
+ * these ops read rather than recompute. `undefined` means the row predates the analysis;
+ * that is answered as a 409 rather than as an empty result, because "nothing found" and
+ * "never looked" are different answers and a caller cannot tell them apart from `[]`.
+ */
+ case "endpoints": {
+ if (!repo.apiSurface) return NextResponse.json({ error: "Not analysed — re-index this repo" }, { status: 409 });
+ return NextResponse.json({ endpoints: repo.apiSurface.endpoints, truncated: repo.apiSurface.truncated });
+ }
+ case "flows": {
+ if (!repo.apiSurface) return NextResponse.json({ error: "Not analysed — re-index this repo" }, { status: 409 });
+ return NextResponse.json({ flows: repo.apiSurface.flows, truncated: repo.apiSurface.truncated });
+ }
+ case "api-impact": {
+ if (!repo.apiSurface) return NextResponse.json({ error: "Not analysed — re-index this repo" }, { status: 409 });
+ if (!sym) return NextResponse.json({ error: "Missing symbol" }, { status: 400 });
+ return NextResponse.json({ endpoints: endpointsAffectedBy(repo.apiSurface, g, sym) });
+ }
+ case "unauth-paths": {
+ if (!repo.apiSurface) return NextResponse.json({ error: "Not analysed — re-index this repo" }, { status: 409 });
+ // Only endpoints whose handler RESOLVED and carries no guard. An unresolved handler is
+ // `authenticated: null` and is excluded, so an analysis gap never becomes an accusation.
+ return NextResponse.json({ paths: unauthenticatedSinkPaths(repo.apiSurface) });
+ }
+ case "taint": {
+ if (!repo.taint) return NextResponse.json({ error: "Not analysed — re-index this repo" }, { status: 409 });
+ return NextResponse.json(repo.taint);
+ }
+ /**
+ * Deterministic natural-language querying.
+ *
+ * The compiler runs against the SAME graph, API surface and test predicate the other ops
+ * use, so an answer here cannot contradict the Impact page or the Agents tab. It never
+ * reaches the network and consults no model: the failure mode is a refusal, returned as a
+ * 200 with `ok: false` because "I cannot answer that" is a successful, meaningful response
+ * to a well-formed request, not an HTTP error.
+ */
+ case "ask": {
+ if (!q.trim()) return NextResponse.json({ error: "Missing question" }, { status: 400 });
+ // Bounded so a pathological question cannot drive the tokeniser or the edit-distance
+ // sweep across a large graph; the longest supported form is far below this.
+ if (q.length > 300) return NextResponse.json({ error: "Question too long" }, { status: 400 });
+ const answer = ask(q, askCorpus(repo), isTestFile);
+ /**
+ * A refusal is the signal that grows the vocabulary.
+ *
+ * The synonym dictionary is hand-written, so it only ever contains the words its author
+ * thought of. Measured against forty-one questions phrased by someone else, four failed
+ * on missing vocabulary alone - including `blast radius`, which is the product's OWN
+ * term, printed on the Impact page. Guessing at the gaps is what produced them; this
+ * records the actual misses so the next dictionary entry is evidence-led.
+ *
+ * Only UNCLASSIFIED questions are logged, and only the question text. A classified
+ * question needs no help, and the text is what the user typed into a box they know is a
+ * query - the same expectation as any server access log. No repo id, no viewer id: this
+ * is for reading the vocabulary gap in aggregate, not for tracing a person.
+ */
+ if (!answer.ok && answer.reason === "unclassified") {
+ logger.info("ask: unclassified question", { question: q });
+ }
+ return NextResponse.json(answer);
+ }
default:
return NextResponse.json({ error: `Unknown op: ${op}` }, { status: 400 });
}
diff --git a/apps/web/src/app/api/repos/[id]/ownership/route.ts b/apps/web/src/app/api/repos/[id]/ownership/route.ts
new file mode 100644
index 0000000..0a90c19
--- /dev/null
+++ b/apps/web/src/app/api/repos/[id]/ownership/route.ts
@@ -0,0 +1,88 @@
+import { NextRequest, NextResponse } from "next/server";
+import { repoAccessDenied, requireWorkspace, viewerId } from "@/lib/authz";
+import { getRepo } from "@/lib/store";
+import { familiarity, gitCommitsForRoot, isGitRepo, recommendReviewers } from "@codegraph/vcs";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/** Matches `gitOwnership`'s own window, so "recently active" means one thing everywhere. */
+const WINDOW_DAYS = 180;
+
+/** Changed files a reviewer query will weigh. A larger list is a rename sweep. */
+const MAX_FILES = 50;
+
+/**
+ * GET /api/repos/:id/ownership?op=summary | file&path=… | reviewers&files=a,b | familiarity&author=…
+ *
+ * `summary` and `file` read the report computed at index time; `reviewers` and `familiarity`
+ * need the commit log, because both are questions about a set the caller supplies rather than
+ * about the repository as a whole, and precomputing every possible answer is not a thing.
+ */
+export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const denied = repoAccessDenied(req, id);
+ if (denied) return denied;
+ const repo = getRepo(id, viewerId(req));
+ if (!repo) return NextResponse.json({ error: "Repo not found" }, { status: 404 });
+
+ const { searchParams } = new URL(req.url);
+ const op = searchParams.get("op") || "summary";
+
+ if (op === "summary" || op === "file") {
+ // ABSENT is answered as 409, not as an empty report. A repository indexed before ownership
+ // analysis existed has no data; saying "no owners" would be a claim we cannot support.
+ if (!repo.ownership) {
+ return NextResponse.json({ error: "Not analysed — re-index this repo" }, { status: 409 });
+ }
+ if (op === "summary") {
+ return NextResponse.json({
+ authors: repo.ownership.authors,
+ windowDays: repo.ownership.windowDays,
+ commitsAnalysed: repo.ownership.commitsAnalysed,
+ truncated: repo.ownership.truncated,
+ // The stale/orphaned subset, which is the actionable half of the report.
+ stale: repo.ownership.files.filter((f) => f.orphaned).slice(0, 100),
+ });
+ }
+ const path = searchParams.get("path");
+ if (!path) return NextResponse.json({ error: "Missing path" }, { status: 400 });
+ const entry = repo.ownership.files.find((f) => f.path === path);
+ if (!entry) return NextResponse.json({ error: "No ownership recorded for that path" }, { status: 404 });
+ const symbols = repo.ownership.symbols.filter((s) => s.symbolId.startsWith(`${path}#`));
+ return NextResponse.json({ file: entry, symbols });
+ }
+
+ // The remaining ops read git, so they need the workspace and the same ownership check again
+ // — `requireWorkspace` resolves and authorises in one step, which is what stops a handler
+ // obtaining a directory it was not allowed to open.
+ const { denied: wsDenied, ws } = requireWorkspace(req, id);
+ if (wsDenied) return wsDenied;
+ if (!(await isGitRepo(ws.dir))) {
+ return NextResponse.json({ error: "Not a git workspace" }, { status: 409 });
+ }
+ const commits = gitCommitsForRoot(ws.dir, { since: `${WINDOW_DAYS}.days.ago` });
+
+ if (op === "reviewers") {
+ const files = (searchParams.get("files") ?? "")
+ .split(",")
+ .map((f) => f.trim())
+ .filter(Boolean)
+ .slice(0, MAX_FILES);
+ if (files.length === 0) return NextResponse.json({ error: "Missing files" }, { status: 400 });
+ return NextResponse.json({
+ reviewers: recommendReviewers(commits, files, { windowDays: WINDOW_DAYS }),
+ // Stated so an empty list is readable: no history means no recommendation, which is a
+ // different answer from "nobody is a good reviewer".
+ commitsAnalysed: commits.length,
+ });
+ }
+
+ if (op === "familiarity") {
+ const author = searchParams.get("author");
+ if (!author) return NextResponse.json({ error: "Missing author" }, { status: 400 });
+ return NextResponse.json(familiarity(commits, author));
+ }
+
+ return NextResponse.json({ error: `Unknown op: ${op}` }, { status: 400 });
+}
diff --git a/apps/web/src/app/api/repos/[id]/pr/route.ts b/apps/web/src/app/api/repos/[id]/pr/route.ts
new file mode 100644
index 0000000..3609af4
--- /dev/null
+++ b/apps/web/src/app/api/repos/[id]/pr/route.ts
@@ -0,0 +1,100 @@
+import { NextRequest, NextResponse } from "next/server";
+import { requireWorkspace } from "@/lib/authz";
+import { getRepo } from "@/lib/store";
+import { viewerId } from "@/lib/authz";
+import { diffRange, gitCommitsForRoot, isGitRepo } from "@codegraph/vcs";
+import { analysePr } from "@/lib/printel/analyse";
+import { parseUnifiedDiff } from "@/lib/printel/diff";
+import { logger } from "@codegraph/observability";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * History window the reviewer recommendation is drawn from. Six months, matching `gitSignals`
+ * and `gitOwnership`, so "recently active" means the same thing on every surface.
+ */
+const WINDOW_DAYS = 180;
+
+/**
+ * A git ref supplied by a caller.
+ *
+ * Deliberately NARROWER than git's own ref grammar. `git diff` takes options and refs in the
+ * same argv position, so a value beginning with `-` is an OPTION — `--output=/path` writes a
+ * file, `--upload-pack=cmd` runs one on a fetch. `packages/vcs`'s `assertRefArg` rejects the
+ * leading dash at the choke point, and this rejects it earlier and rejects more: a ref here can
+ * only be a branch, tag or hash-shaped name, because nothing this route legitimately serves
+ * needs `HEAD@{2}` or a pathspec. Whitelist, not blacklist, for the reason the timeline route
+ * validates its hash the same way — sanitising downstream is where these get missed.
+ */
+const REF = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
+function isRef(value: string | null): value is string {
+ // `..` would turn one ref into a range and `.lock`/trailing-dot are invalid to git anyway.
+ return value !== null && REF.test(value) && !value.includes("..");
+}
+
+// GET /api/repos/:id/pr?base=&head=
+export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const { denied, ws } = requireWorkspace(req, id);
+ if (denied) return denied;
+ if (!(await isGitRepo(ws.dir))) {
+ return NextResponse.json({ error: "Not a git workspace" }, { status: 409 });
+ }
+
+ const { searchParams } = new URL(req.url);
+ const base = searchParams.get("base");
+ const head = searchParams.get("head") ?? "HEAD";
+ if (!isRef(base) || !isRef(head)) {
+ return NextResponse.json(
+ { error: "Missing or malformed base/head ref" },
+ { status: 400 },
+ );
+ }
+
+ // The graph and API surface come from the stored index, not from a fresh one: analysing a
+ // diff must not cost a full re-index per request. The consequence — symbol spans are the
+ // CURRENT tree's, not `head`'s — is documented on `analysePr` and surfaced to the caller
+ // below rather than hidden.
+ const repo = getRepo(id, viewerId(req));
+ if (!repo) return NextResponse.json({ error: "Repo not found" }, { status: 404 });
+ if (repo.status !== "done") {
+ return NextResponse.json({ error: "Repo not indexed yet" }, { status: 409 });
+ }
+
+ try {
+ const raw = await diffRange(ws.dir, base, head);
+ const changed = parseUnifiedDiff(raw);
+ const commits = gitCommitsForRoot(ws.dir, { since: `${WINDOW_DAYS}.days.ago` });
+
+ const analysis = analysePr({
+ base,
+ head,
+ changed,
+ graph: repo.symbolGraph,
+ apiSurface: repo.apiSurface,
+ commits,
+ windowDays: WINDOW_DAYS,
+ // The indexed file list, for test discovery. `viz` carries every file the scan kept,
+ // which is the same set the symbol graph was built from.
+ files: repo.viz.nodes.filter((n) => n.kind === "file").map((n) => ({ rel: n.id })),
+ });
+
+ return NextResponse.json({
+ ...analysis,
+ // Named, not implied: the graph this was joined against is the last index, so a PR that
+ // moves code is matched against where that code is now.
+ analysedAgainst: { indexedAt: repo.finishedAt, note: "symbol spans are from the latest index, not from `head`" },
+ });
+ } catch (e) {
+ // Ref resolution failures are the common case (a branch that does not exist locally),
+ // but git says so as `Command failed: git -C /app/data/workspaces/ diff …`, which
+ // hands every repo viewer the absolute workspace path and the internal repo UUID for
+ // the sake of one word. The actionable half is restated here without them (F023).
+ logger.warn("pr diff failed", { repoId: id, base, head, error: e instanceof Error ? e.message : String(e) });
+ return NextResponse.json(
+ { error: "Could not diff those refs. Check that both the base and the head exist in this workspace." },
+ { status: 400 },
+ );
+ }
+}
diff --git a/apps/web/src/app/api/repos/[id]/reindex/route.ts b/apps/web/src/app/api/repos/[id]/reindex/route.ts
index 3e0ef98..8f124ed 100644
--- a/apps/web/src/app/api/repos/[id]/reindex/route.ts
+++ b/apps/web/src/app/api/repos/[id]/reindex/route.ts
@@ -29,7 +29,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Same gate as every other workspace-touching route: the check and the workspace
// resolution are one step so a handler cannot obtain a directory without having
// passed the tenant check.
- const { denied } = requireWorkspace(req, id);
+ //
+ // `files: false` because re-indexing reads the repository out of git, not off disk. Letting
+ // this materialise the working tree would reintroduce the 614 MB checkout at exactly the
+ // moment the design exists to avoid it — and re-index is the one route guaranteed to run.
+ const { denied } = requireWorkspace(req, id, { files: false });
if (denied) return denied;
const result = reindexRepo(id);
diff --git a/apps/web/src/app/api/repos/[id]/timeline/route.ts b/apps/web/src/app/api/repos/[id]/timeline/route.ts
index 154397a..9c49814 100644
--- a/apps/web/src/app/api/repos/[id]/timeline/route.ts
+++ b/apps/web/src/app/api/repos/[id]/timeline/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { requireWorkspace } from "@/lib/authz";
import { TimelineEngine, Strategies } from "@/lib/gitops/timelineApi";
-import { isCommitHash, loadSnapshotCache } from "@/lib/gitops/timelineStore";
+import { loadSnapshotCache } from "@/lib/gitops/timelineStore";
import { isGitRepo } from "@codegraph/vcs";
import { logger } from "@codegraph/observability";
@@ -22,7 +22,18 @@ function badHash(): NextResponse {
return NextResponse.json({ error: "Invalid commit hash" }, { status: 400 });
}
-// GET /api/repos/:id/timeline?op=metadata|trends|snapshot|compare
+/**
+ * A commit hash from the client becomes a FILENAME in the snapshot cache
+ * (`data/timeline//.json`) and an argument to `git archive`. Anything that is
+ * not a hash has no business doing either, so it is rejected before it reaches the engine
+ * rather than sanitised somewhere downstream.
+ */
+const HASH = /^[0-9a-f]{7,40}$/;
+function isCommitHash(value: string | null): value is string {
+ return value !== null && HASH.test(value);
+}
+
+// GET /api/repos/:id/timeline?op=metadata|trends|points|snapshot|compare|delta
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { denied, ws } = requireWorkspace(req, id);
@@ -46,6 +57,12 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ trends });
}
+ if (op === "points") {
+ // Cache-only: the structural series plot whatever is already indexed. No build.
+ const points = await engine.getTrendPoints();
+ return NextResponse.json({ points });
+ }
+
if (op === "snapshot") {
const hash = searchParams.get("hash");
if (!hash) return NextResponse.json({ error: "Missing hash" }, { status: 400 });
@@ -57,21 +74,29 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ snapshot });
}
- if (op === "compare") {
+ if (op === "compare" || op === "delta") {
const base = searchParams.get("base");
const head = searchParams.get("head");
if (!base || !head) return NextResponse.json({ error: "Missing base or head hash" }, { status: 400 });
if (!isCommitHash(base) || !isCommitHash(head)) return badHash();
- await engine.ensureSnapshot(base);
- await engine.ensureSnapshot(head);
- const controller = await engine.getController();
- const evolution = await controller.compare(base, head);
-
- if (!evolution) {
+ // `delta` is the one-click "what changed since last index" path: both sides are
+ // already cached by definition, so it never spends minutes building one.
+ if (op === "compare") {
+ await engine.ensureSnapshot(base);
+ await engine.ensureSnapshot(head);
+ }
+
+ const delta = await engine.getSnapshotDelta(base, head);
+ if (!delta) {
return NextResponse.json({ error: "One or both snapshots are not cached. Call snapshot first." }, { status: 404 });
}
- return NextResponse.json({ evolution });
+
+ // Both sides are cached (the delta above proves it), so `compare` reads the same two
+ // files rather than building anything — the evolution narrative comes free either way.
+ const controller = await engine.getController();
+ const evolution = await controller.compare(base, head);
+ return NextResponse.json({ evolution, delta });
}
return NextResponse.json({ error: "Unknown op" }, { status: 400 });
diff --git a/apps/web/src/app/api/repos/[id]/trash/route.ts b/apps/web/src/app/api/repos/[id]/trash/route.ts
index 284b2e7..f7d73c5 100644
--- a/apps/web/src/app/api/repos/[id]/trash/route.ts
+++ b/apps/web/src/app/api/repos/[id]/trash/route.ts
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { getWorkspaceDir } from "@/lib/store";
-import { repoAccessDenied } from "@/lib/authz";
+import { repoAccessDenied, requireWorkspace } from "@/lib/authz";
import { listTrash, restoreFromTrash, purgeTrashEntry, emptyTrash } from "@/lib/trash";
import { WorkspacePathError } from "@codegraph/fsx";
import { logger } from "@codegraph/observability";
@@ -45,8 +44,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const { op, trashId } = body as { op: string; trashId?: string };
try {
if (op === "restore") {
- const ws = getWorkspaceDir(id);
- if (!ws) return NextResponse.json({ error: "Workspace not ready" }, { status: 404 });
+ /*
+ * Through `requireWorkspace`, not `getWorkspaceDir`, so restoring a file lands in a tree
+ * that actually exists: repositories are cloned `--no-checkout` and materialised on
+ * first file access. This was the one route reaching past the shared guard, which also
+ * meant it was the one route not getting the guard's access semantics.
+ */
+ const { denied: noWs, ws } = requireWorkspace(req, id);
+ if (noWs) return noWs;
if (!trashId) return NextResponse.json({ error: "Missing trashId" }, { status: 400 });
return NextResponse.json({ ok: true, entry: restoreFromTrash(id, ws.dir, trashId) });
}
diff --git a/apps/web/src/app/api/settings/assistant/route.ts b/apps/web/src/app/api/settings/assistant/route.ts
deleted file mode 100644
index 9063194..0000000
--- a/apps/web/src/app/api/settings/assistant/route.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { NextRequest, NextResponse } from "next/server";
-import { getSession } from "@/lib/session";
-import { githubOAuthConfigured } from "@/lib/githubOAuth";
-import { deleteLocalProvider, saveLocalProvider, setAssistantSettings, applyLocalProvider, viewAssistantSettings, ANONYMOUS_USER_ID } from "@/lib/settings";
-import { verifyAnthropicApiKey } from "@/lib/anthropicKeyCheck";
-import { rateLimit, clientIp } from "@/lib/rateLimit";
-
-export const runtime = "nodejs";
-export const dynamic = "force-dynamic";
-
-function unauthorized(req: NextRequest): NextResponse | null {
- if (githubOAuthConfigured() && !getSession(req)?.userId) {
- return NextResponse.json({ error: "Unauthorized. Please sign in." }, { status: 401 });
- }
- return null;
-}
-
-// Every value here is scoped to the calling account: `userId` derives from
-// the signed-in session (falling back to the shared ANONYMOUS_USER_ID
-// bucket only when GitHub sign-in isn't configured at all, or the
-// deployment is genuinely unauthenticated). A saved key/model/profile is
-// therefore visible and editable only from the GitHub account that saved
-// it -- never a different signed-in user, never overwritten by one.
-function userIdFrom(req: NextRequest): number {
- return getSession(req)?.userId ?? ANONYMOUS_USER_ID;
-}
-
-export async function GET(req: NextRequest) {
- const denied = unauthorized(req);
- if (denied) return denied;
- return NextResponse.json(viewAssistantSettings(userIdFrom(req)));
-}
-
-export async function POST(req: NextRequest) {
- const denied = unauthorized(req);
- if (denied) return denied;
- // The key-verification call below reaches out to Anthropic on the caller's behalf, so an
- // unthrottled POST loop turns this route into an outbound request amplifier (and, on a
- // deployment where sign-in is not configured, an anonymous one). Same limiter, same
- // shape as /api/browse and /api/index.
- const limited = rateLimit(`settings:${clientIp(req)}`, { capacity: 20, windowMs: 60_000 });
- if (!limited.ok) {
- return NextResponse.json(
- { error: "Too many settings updates. Try again shortly." },
- { status: 429, headers: { "Retry-After": String(limited.retryAfter) } }
- );
- }
- const userId = userIdFrom(req);
- let body: Record;
- try {
- body = await req.json();
- } catch {
- return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
- }
-
- const patch: Parameters[0] = {};
-
- if (typeof body.anthropicApiKey === "string" || body.anthropicApiKey === null) {
- patch.anthropicApiKey = body.anthropicApiKey;
- }
- // Verify a newly pasted Anthropic key against Anthropic's own API before
- // ever persisting it -- previously any string was accepted silently and
- // only failed deep inside a real chat turn ("Invalid API key"), with no
- // way to tell a CodeGraph bug from a bad paste. A confirmed-invalid key
- // (401/403) is rejected here with Anthropic's own error text; a network
- // failure verifying it is NOT evidence the key is bad, so it still saves.
- if (typeof patch.anthropicApiKey === "string" && patch.anthropicApiKey.trim()) {
- // Bounded. `fetch` has no default timeout, so an Anthropic endpoint that accepts the
- // connection and then stalls (a captive portal, a blocked egress path that blackholes
- // rather than resets) left this await pending forever — and with it the user's
- // settings save, holding a request handler open with no way to recover. The helper
- // already takes a signal; nothing was passing one. An abort lands in its catch and is
- // reported as `network`, which by design does not block the save.
- const check = await verifyAnthropicApiKey(patch.anthropicApiKey.trim(), AbortSignal.timeout(8_000));
- if (!check.ok && check.reason === "invalid") {
- return NextResponse.json({ error: `Anthropic rejected this API key: ${check.message}` }, { status: 400 });
- }
- }
- if (typeof body.claudeModel === "string" || body.claudeModel === null) {
- patch.claudeModel = body.claudeModel;
- }
- if (typeof body.useClaudeSubscription === "boolean") {
- patch.useClaudeSubscription = body.useClaudeSubscription ? "true" : null;
- }
- if (typeof body.localBaseUrl === "string" || body.localBaseUrl === null) {
- patch.localBaseUrl = body.localBaseUrl;
- }
- if (typeof body.localModel === "string" || body.localModel === null) {
- patch.localModel = body.localModel;
- }
- if (typeof body.localApiKey === "string" || body.localApiKey === null) {
- patch.localApiKey = body.localApiKey;
- }
- if (Array.isArray(body.localModelList)) {
- const cleaned = body.localModelList.filter((m): m is string => typeof m === "string" && m.trim().length > 0);
- patch.localModelList = cleaned.length > 0 ? JSON.stringify(cleaned) : null;
- } else if (body.localModelList === null) {
- patch.localModelList = null;
- }
-
- // Saved local-model provider profiles -- checked before the plain field
- // patch so a single POST can either mutate the active config directly, or
- // manage/apply a saved preset, without callers needing two round trips.
- if (body.saveProvider && typeof body.saveProvider === "object") {
- const p = body.saveProvider as Record;
- const name = typeof p.name === "string" ? p.name.trim() : "";
- const baseUrl = typeof p.baseUrl === "string" ? p.baseUrl.trim() : "";
- if (!name || !baseUrl) {
- return NextResponse.json({ error: "Profile name and base URL are required" }, { status: 400 });
- }
- const models = Array.isArray(p.models) ? p.models.filter((m): m is string => typeof m === "string" && m.trim().length > 0) : [];
- saveLocalProvider({
- id: typeof p.id === "string" && p.id ? p.id : randomUUID(),
- name,
- baseUrl,
- apiKey: typeof p.apiKey === "string" && p.apiKey ? p.apiKey : null,
- models,
- }, userId);
- }
- if (typeof body.deleteProviderId === "string") {
- deleteLocalProvider(body.deleteProviderId, userId);
- }
- if (typeof body.useProviderId === "string") {
- if (!applyLocalProvider(body.useProviderId, userId)) {
- return NextResponse.json({ error: "Provider profile not found" }, { status: 404 });
- }
- }
-
- setAssistantSettings(patch, userId);
-
- // Return updated view
- return NextResponse.json(viewAssistantSettings(userId));
-}
diff --git a/apps/web/src/app/api/settings/models/route.ts b/apps/web/src/app/api/settings/models/route.ts
deleted file mode 100644
index 4a00359..0000000
--- a/apps/web/src/app/api/settings/models/route.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { getSession } from "@/lib/session";
-import { githubOAuthConfigured } from "@/lib/githubOAuth";
-import { effectiveLocalLlmConfig, effectiveLocalModelList, ANONYMOUS_USER_ID } from "@/lib/settings";
-
-export const runtime = "nodejs";
-export const dynamic = "force-dynamic";
-
-// By default returns the user's curated model list (added/removed from
-// /settings) so the chat panel's dropdown stays short and intentional
-// instead of dumping a provider's full, often huge, raw catalog. Pass
-// ?discover=true to bypass the curation and fetch the provider's live
-// /v1/models list instead -- used by the "Discover from server" picker on
-// the Settings page when the user wants to browse what's actually available.
-export async function GET(req: NextRequest) {
- // Same account-scoping as /api/settings/assistant: this reads the
- // caller's own saved local-model config, so it needs the same gate.
- if (githubOAuthConfigured() && !getSession(req)?.userId) {
- return NextResponse.json({ error: "Unauthorized. Please sign in." }, { status: 401 });
- }
- const userId = getSession(req)?.userId ?? ANONYMOUS_USER_ID;
- const curated = effectiveLocalModelList(userId);
- const discover = req.nextUrl.searchParams.get("discover") === "true";
- if (curated.length > 0 && !discover) {
- return NextResponse.json({ models: curated, curated: true });
- }
-
- const config = effectiveLocalLlmConfig(userId);
- if (!config) return NextResponse.json({ models: curated, curated: curated.length > 0 });
-
- try {
- const baseUrl = config.baseUrl.replace(/\/+$/, "");
- const res = await fetch(`${baseUrl}/models`, {
- headers: config.apiKey && config.apiKey !== "local" ? { Authorization: `Bearer ${config.apiKey}` } : {}
- });
- if (!res.ok) return NextResponse.json({ models: curated, curated: curated.length > 0 });
-
- const data = await res.json() as { data?: Array<{ id: string }> };
- const models = data.data?.map(m => m.id) || [];
- return NextResponse.json({ models, curated: false });
- } catch {
- return NextResponse.json({ models: curated, curated: curated.length > 0 });
- }
-}
diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx
index 2ec8e50..9ebf753 100644
--- a/apps/web/src/app/dashboard/page.tsx
+++ b/apps/web/src/app/dashboard/page.tsx
@@ -2,8 +2,9 @@
import { useEffect, useState } from "react";
import Link from "next/link";
-import { ArrowRight, FolderGit2, Loader2, Network, Trash2 } from "lucide-react";
-import { fetchRepos, deleteRepo } from "@/lib/api";
+import { ArrowRight, FolderGit2, Loader2, Network, RotateCw, Trash2 } from "lucide-react";
+import { fetchRepos, deleteRepo, startIndex } from "@/lib/api";
+import { plural } from "@/lib/plural";
import type { RepoSummary } from "@/lib/types";
import { CountUp, Reveal, Stagger, StaggerItem } from "@/components/motion/primitives";
import { ScoreDial } from "@/components/ScoreDial";
@@ -87,9 +88,50 @@ const BTN_GHOST =
type Order = "risk" | "recent";
+/**
+ * Which repositories compete for attention, and which are merely dead entries.
+ *
+ * Default order is RISK, not recency: the question this page exists to answer is "where do I
+ * look first", and insertion order answers a different one. In-flight rows sort to the top of
+ * the risk view because an index still running is the most urgent thing on the page and has no
+ * score to rank it by.
+ *
+ * FAILED ROWS ARE NOT RANKED AT ALL, and that is a correction. They used to sort above
+ * everything for the same "no score" reason. Measured on a real dashboard: three dead entries
+ * — a typo'd URL from the previous day among them — sat above every repository the reader
+ * actually works on, 4 of 19 rows outranking the other 15. A run that never finished is
+ * urgent; a run that finished by failing yesterday is a dead entry needing dismissal or
+ * another attempt. They get their own group, where they can be acted on without competing.
+ *
+ * Pure and exported because the partition is the part that can be wrong in a way users see,
+ * and a rendered component is a bad place to prove it is not.
+ */
+export function triage(
+ repos: readonly RepoSummary[] | null,
+ order: Order,
+): { live: RepoSummary[]; failed: RepoSummary[] } {
+ const recent = (a: RepoSummary, b: RepoSummary) =>
+ (b.finishedAt ?? b.createdAt) - (a.finishedAt ?? a.createdAt);
+ // `filter` allocates, so neither branch can reach the caller's array — no defensive copy.
+ const all = repos ?? [];
+ return {
+ live: all
+ .filter((r) => r.status !== "error")
+ .sort((a, b) =>
+ order === "recent"
+ ? recent(a, b)
+ : (a.status !== "done" ? -1 : (a.score ?? 101)) - (b.status !== "done" ? -1 : (b.score ?? 101)),
+ ),
+ // Always newest-first: there is no risk to rank, and the one you just tried is the one
+ // you are still thinking about.
+ failed: all.filter((r) => r.status === "error").sort(recent),
+ };
+}
+
export default function DashboardPage() {
const [repos, setRepos] = useState(null);
const [deletingId, setDeletingId] = useState(null);
+ const [retryingId, setRetryingId] = useState(null);
const [order, setOrder] = useState("risk");
async function handleDelete(id: string, name: string) {
@@ -105,6 +147,36 @@ export default function DashboardPage() {
}
}
+ /**
+ * Another attempt at a repository whose index failed.
+ *
+ * Re-submits the URL rather than calling `/api/repos/:id/reindex`. Verified, not assumed:
+ * re-index answers 404 "Workspace not ready" for a failed row, and correctly so — the clone
+ * never landed, so there is nothing on disk to read again.
+ *
+ * THE OLD ROW IS THEN RETIRED, because `/api/index` does NOT reuse it. Measured: retrying
+ * left two rows for the same URL, both errored, so the dashboard grew a duplicate every time
+ * someone tried again — turning a fix for clutter into a source of it. Deleting only when
+ * the id actually changed keeps this correct if that ever starts reusing: superseding a row
+ * is the intent, and deleting the id the new job is running under would kill the retry.
+ *
+ * Worth having because the common cause is transient or trivially correctable — a rate
+ * limit, a private repo before signing in, a typo already visible in the row — and the URL
+ * is right there. Without it the only path forward is retyping it on another page.
+ */
+ async function handleRetry(repo: RepoSummary) {
+ setRetryingId(repo.id);
+ try {
+ const { repoId } = await startIndex({ repoUrl: repo.url });
+ if (repoId !== repo.id) await deleteRepo(repo.id);
+ setRepos(await fetchRepos());
+ } catch (err) {
+ window.alert(err instanceof Error ? err.message : "Failed to start indexing");
+ } finally {
+ setRetryingId(null);
+ }
+ }
+
useEffect(() => {
let active = true;
const load = async () => {
@@ -134,20 +206,7 @@ export default function DashboardPage() {
? scored.reduce((lo, r) => ((r.score ?? 100) < (lo.score ?? 100) ? r : lo))
: null;
- /**
- * Default order is RISK, not recency.
- *
- * The question this page exists to answer is "where do I look first", and
- * insertion order answers a different one. In-flight and failed rows sort to the
- * top of the risk view because an index that never finished is the most urgent
- * thing on the page and has no score to rank it by.
- */
- const rows = [...(repos ?? [])].sort((a, b) => {
- if (order === "recent") return (b.finishedAt ?? b.createdAt) - (a.finishedAt ?? a.createdAt);
- const rank = (r: RepoSummary) =>
- r.status === "error" ? -2 : r.status !== "done" ? -1 : (r.score ?? 101);
- return rank(a) - rank(b);
- });
+ const { live, failed } = triage(repos, order);
const meanBand = band(mean);
const meanBandColor = meanBand.color;
@@ -331,7 +390,7 @@ export default function DashboardPage() {
- {rows.map((r) => {
+ {live.map((r) => {
const processing = r.status !== "done" && r.status !== "error";
const clickable = r.status === "done";
const b = band(r.score);
@@ -382,6 +441,28 @@ export default function DashboardPage() {
lowest
)}
+ {/* A ranked table that puts a sampled score beside whole-repository
+ ones without saying so is the misleading case ADR-008 names. The
+ word carries the meaning; the colour only reinforces it. */}
+ {r.capHit && (
+
+ sample
+ {/* The row is `pointer-events-none`, so a `title` tooltip would
+ never open — the explanation goes where it can still be read. */}
+
+ {" "}
+ — the walk stopped at the CG_MAX_FILES cap, so this score was
+ computed over part of the repository
+
+
+ )}
{r.sourceType}
@@ -446,6 +527,52 @@ export default function DashboardPage() {
})}
+
+ {/*
+ * Below the list, not inside it. These have no score to rank and no page to open;
+ * what they need is another attempt or removal, which is all this group offers.
+ */}
+ {failed.length > 0 && (
+
+
+ {plural(failed.length, "repository", "repositories")} failed to index · not scored, not counted
+
+
+ {failed.map((r) => (
+
+
+
{r.name}
+ {/* The URL is the diagnosis for the most common cause: a typo you can
+ see the moment it is put in front of you. */}
+
{r.url}
+
+ {ago(r.finishedAt ?? r.createdAt)}
+
+
+
+
+
+ ))}
+
+
+ )}
>
)}
diff --git a/apps/web/src/app/fleet/page.tsx b/apps/web/src/app/fleet/page.tsx
index 46a61c2..a0e54a3 100644
--- a/apps/web/src/app/fleet/page.tsx
+++ b/apps/web/src/app/fleet/page.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
+import { rankByDrift } from "@/lib/fleet-drift";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Loader2, Network } from "lucide-react";
import type { FleetGraph } from "@/lib/types";
@@ -33,9 +34,15 @@ function scoreColor(s: number | null): string {
return "text-[var(--coral-text)]";
}
-/** One grid track definition shared by the header row and every data row. */
+/**
+ * One grid track definition shared by the header row and every data row.
+ *
+ * At ();
for (const e of graph.edges) outDegree.set(e.source, (outDegree.get(e.source) ?? 0) + 1);
+ // The order the table is drawn in: biggest movers since each repo's previous index.
+ // Same comparator the dashboard's ranked table uses, so the two pages cannot disagree
+ // about which repository is first.
+ const ranked = rankByDrift(graph.nodes);
+
+ // The mean is a secondary reading, not the headline: it says how the estate is doing
+ // overall and nothing at all about where to look next, which is what the ranking above
+ // is for.
+ const scored = graph.nodes.filter((n) => n.score !== null);
+ const mean = scored.length
+ ? Math.round(scored.reduce((acc, n) => acc + (n.score ?? 0), 0) / scored.length)
+ : null;
+
return (
- {n.name}
+
+ {n.name}
+ {/* Same marker the dashboard row carries, for the same reason: this list is
+ ranked by score, and a truncated walk's score is not comparable with a
+ whole repository's without saying so. */}
+ {n.capHit && (
+
+ sample
+
+ {" "}
+ — the walk stopped at the CG_MAX_FILES cap, so this score was computed
+ over part of the repository
+
+
+ )}
+ {n.url}{n.sourceType === "git" ? "git" : "local"}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 23f87f6..f28091f 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -14,11 +14,38 @@ import { CountUp, Entrance, Magnetic, Reveal, Stagger, StaggerItem } from "@/com
* do, and a landing page is a README with better typography.
*/
+/**
+ * The figures the hero publishes.
+ *
+ * WHY THESE FOUR, AND WHY TWO OTHERS ARE GONE
+ *
+ * The page's own footer promises that every number on it was measured on a real repository and
+ * that the commands producing them are in the repo. That promise was false in four places.
+ * `957 tests / 75 files` was stale against 1,844 across 107; `2.1s to index 327 files` was
+ * taken on a 303-file tree before six of the eleven pipeline stages existed, and the largest
+ * one today (`taint`, 3.0s of 6.0s) was not among them.
+ *
+ * Two claims were REMOVED rather than corrected, because nothing in the repository
+ * substantiates them and a number nobody can re-derive is the thing the footer promises not to
+ * publish:
+ *
+ * - a claimed detector accuracy percentage on held-out repositories - no corpus, protocol or
+ * command backing it exists anywhere here.
+ * - a claimed peak-memory figure of 313.9 MiB - `npm run selfindex` under `/usr/bin/time -l`
+ * on this machine. The container may well be leaner; until something measures IT, the
+ * honest move is silence. The "runs on 512 MB" badge is a deployment target, not a claim
+ * about a measurement, and is left alone.
+ *
+ * What remains is asserted by `apps/web/tests/landing-claims.test.ts` against the working tree,
+ * except the wall clock, which is published with a date the way the README publishes its case
+ * count - a timing measures the machine as much as the code. `npm run selfindex` reprints all
+ * of it.
+ */
const PROOF = [
- { value: 957, suffix: "", label: "tests, all green", note: "75 files, every gate in CI" },
- { value: 87, suffix: "%", label: "detection precision", note: "on held-out repos, never tuned against" },
- { value: 2.1, decimals: 1, suffix: "s", label: "to index 327 files", note: "cold, single container" },
- { value: 313.9, decimals: 1, suffix: " MiB", label: "peak memory", note: "under a hard 512 MiB cap" },
+ { value: 2206, suffix: "", label: "tests, all green", note: "120 files, every gate in CI" },
+ { value: 379, suffix: "", label: "TypeScript files", note: "498 of 519 scanned, 90,663 LOC analysed" },
+ { value: 6.0, decimals: 1, suffix: "s", label: "to index this repo, cold", note: "11 stages, measured 2026-08-09" },
+ { value: 11, suffix: "", label: "instrumented stages", note: "each one times itself, every run" },
];
const LENSES = [
@@ -234,7 +261,7 @@ export default function LandingPage() {
index gets slower you get a package name, not a shrug.
- Below: an actual run over this repository's 327 TypeScript files.
+ Below: an actual run over this repository's 379 TypeScript files.
diff --git a/apps/web/src/app/repos/[id]/agents/page.tsx b/apps/web/src/app/repos/[id]/agents/page.tsx
index 20b6672..854e155 100644
--- a/apps/web/src/app/repos/[id]/agents/page.tsx
+++ b/apps/web/src/app/repos/[id]/agents/page.tsx
@@ -12,7 +12,7 @@ export default function AgentsPage() {
title="Agent swarm"
blurb="Deterministic specialists argue findings out among themselves, a critic challenges them, a judge ranks what survives. Any finding can then be turned into a fix proved against your own test suite."
/>
-
+
>
);
}
diff --git a/apps/web/src/app/repos/[id]/architecture/page.tsx b/apps/web/src/app/repos/[id]/architecture/page.tsx
index ac1a9d0..d48d437 100644
--- a/apps/web/src/app/repos/[id]/architecture/page.tsx
+++ b/apps/web/src/app/repos/[id]/architecture/page.tsx
@@ -21,6 +21,7 @@ export default function ArchitecturePage() {
diff --git a/apps/web/src/app/repos/[id]/ask/page.tsx b/apps/web/src/app/repos/[id]/ask/page.tsx
new file mode 100644
index 0000000..1e903f3
--- /dev/null
+++ b/apps/web/src/app/repos/[id]/ask/page.tsx
@@ -0,0 +1,327 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { ArrowRight, Loader2, Search } from "lucide-react";
+import Link from "next/link";
+import type { AskIntent, AskPlan, AskResult } from "@codegraph/core-graph";
+import { intelAsk } from "@/lib/api";
+import { directionApplies, intentsFor, phraseFor, transitiveApplies, type PlanDraft } from "@/lib/askPhrase";
+import { editorHref } from "@/lib/findings";
+import { plural } from "@/lib/plural";
+import { SectionHead, useRepo } from "../repo-context";
+
+/**
+ * Ask the graph a question, in English, with no model involved.
+ *
+ * WHY THE PLAN IS ON SCREEN AND NOT BEHIND A TOGGLE
+ *
+ * Every other answer in this product shows its working: the Health Score lists the dimensions
+ * that moved it, a finding carries the line that triggered it. A natural-language box is the
+ * one surface where a reader has no way to tell an analysis from a guess, so the compiled
+ * plan - the intent, the entity it bound, the direction it walked, the operations it ran - is
+ * rendered next to the answer rather than hidden. It is the same promise the rest of the
+ * report makes, kept in the place it is easiest to break.
+ *
+ * A question the compiler cannot classify is answered as a refusal listing the forms that do
+ * work. That is deliberate and it is the feature: the alternative is a confident wrong answer
+ * about who calls what, which a reader cannot detect.
+ */
+
+/** Enough to teach the grammar without turning the page into documentation. */
+const SUGGESTIONS = [
+ "what breaks if I change ",
+ "what depends on ",
+ "which endpoints are unauthenticated",
+ "what is dead code",
+ "what are the circular dependencies",
+ "what is untested",
+];
+
+export default function AskPage() {
+ const repo = useRepo();
+ const router = useRouter();
+ const pathname = usePathname();
+ const params = useSearchParams();
+ /* The question lives in the URL so an answer can be shared and re-derived, exactly like
+ `?symbol=` on the impact page. Same question, same repository, same answer. */
+ const asked = params.get("q") ?? "";
+
+ const [draft, setDraft] = useState(asked);
+ const [result, setResult] = useState(null);
+ /* Derived, for the same reason as `syncedTo` below: a question with no answer yet IS the
+ loading state, so storing it separately only creates a second thing to keep in step. */
+ const [answeredFor, setAnsweredFor] = useState(null);
+ const loading = asked.trim() !== "" && answeredFor !== asked;
+ const [error, setError] = useState(null);
+
+ /*
+ * Adjusted DURING RENDER, not in an effect.
+ *
+ * Two effects used to do this — one copying `?q=` into the input, one clearing the answer
+ * when the question emptied — and both are the cascading-render shape React warns about:
+ * the component paints the previous question's answer, then re-renders to correct itself.
+ * Comparing against the last question this render tree synced to is the documented
+ * alternative, and it fixes a visible artefact rather than only a lint error: the stale
+ * answer no longer flashes under the new question.
+ */
+ const [syncedTo, setSyncedTo] = useState(asked);
+ if (syncedTo !== asked) {
+ setSyncedTo(asked);
+ setDraft(asked);
+ setResult(null);
+ setError(null);
+ }
+
+ useEffect(() => {
+ if (!asked.trim()) return;
+ let active = true;
+ // No `setLoading(true)` here: `answeredFor` still names the previous question, which is
+ // what `loading` reads. Nothing to set, so nothing cascades.
+ intelAsk(repo.id, asked)
+ .then((r) => { if (active) setResult(r); })
+ .catch((e: unknown) => { if (active) setError(e instanceof Error ? e.message : "Query failed"); })
+ .finally(() => { if (active) setAnsweredFor(asked); });
+ return () => { active = false; };
+ }, [repo.id, asked]);
+
+ const submit = useCallback(
+ (q: string) => {
+ const next = new URLSearchParams(params.toString());
+ if (q.trim()) next.set("q", q.trim());
+ else next.delete("q");
+ router.replace(`${pathname}?${next.toString()}`, { scroll: false });
+ },
+ [params, pathname, router],
+ );
+
+ return (
+
+
+
+
+
+ {!asked.trim() && (
+
+ {SUGGESTIONS.map((s) => (
+
+ ))}
+
+ )}
+
+ {error && (
+
{error}
+ )}
+
+ {result && !result.ok && (
+
+
{result.message}
+ {result.didYouMean.length > 0 && (
+
+ Did you mean
+
+ {result.didYouMean.map((s) => (
+
+ ))}
+
+
+ )}
+
+ Forms this graph can answer
+
+ {result.examples.map((x) => (
+
+
+
+ ))}
+
+
+
+ )}
+
+ {result?.ok && (
+
+
+
{result.headline}
+ {/* The receipt. See the module comment: this is why the surface is trustworthy. */}
+
+
+ {/*
+ * The receipt above says what ran. These say what to run INSTEAD.
+ *
+ * Measured classifier recall is high but not perfect, and the failure a reader
+ * cannot recover from is a plan that is nearly right - correct entity, wrong
+ * direction. Rephrasing blindly is a guessing game; changing the chip is not.
+ * Every control rewrites the QUESTION through `phraseFor`, so the compiler still
+ * does the classifying and there is exactly one code path. A synthesised phrase
+ * that would not round-trip is not offered at all: `phraseFor` returns null and
+ * the control disappears rather than producing a question the compiler misreads.
+ */}
+
+
+ {/* Every row is a place in the code, so every row opens there. */}
+
+ {r.file}:{r.line}
+
+
+ ))}
+
+ )}
+
+ {result.truncated && (
+
+ A cap was reached: this answer is a prefix of the truth, not the whole of it.
+
+ )}
+
+ )}
+
+ );
+}
+
+/** One control chip: a label and whatever sets it. */
+function Control({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+const SELECT = "bg-transparent text-micro font-mono text-[var(--text-primary)] outline-none";
+
+/**
+ * The plan, as controls.
+ *
+ * Each change is expressed by rewriting the QUESTION, never by posting a plan: `phraseFor`
+ * produces a canonical sentence the compiler is guaranteed to classify back to this plan, and
+ * `ask-phrase.test.ts` asserts that round-trip for every combination offered here. So the
+ * controls cannot drift away from the grammar - if a phrasing stopped compiling, that test
+ * fails rather than this panel silently generating questions the compiler misreads.
+ */
+function PlanControls({ plan, onChange }: { plan: AskPlan; onChange: (q: string) => void }) {
+ const draft: PlanDraft = { intent: plan.intent, entity: plan.entity, direction: plan.direction, transitive: plan.transitive };
+ const emit = (next: Partial) => {
+ const phrase = phraseFor({ ...draft, ...next });
+ if (phrase) onChange(phrase);
+ };
+
+ const intents = intentsFor(plan.entity?.kind ?? null);
+ // Nothing to switch between, and no modifier applies: a panel with one frozen control is
+ // furniture. Drawn only when it can actually change the answer.
+ const canSwitchIntent = intents.length > 1;
+ if (!canSwitchIntent && !directionApplies(draft) && !transitiveApplies(draft)) return null;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/repos/[id]/circle-pack/page.tsx b/apps/web/src/app/repos/[id]/circle-pack/page.tsx
index 6067c92..44d7e1a 100644
--- a/apps/web/src/app/repos/[id]/circle-pack/page.tsx
+++ b/apps/web/src/app/repos/[id]/circle-pack/page.tsx
@@ -15,7 +15,7 @@ export default function CirclePackPage() {
/>
{repo.tree && repo.tree.children && repo.tree.children.length > 0 ? (
- {(onSelect) => }
+ {(onSelect) => }
) : (
diff --git a/apps/web/src/app/repos/[id]/impact/page.tsx b/apps/web/src/app/repos/[id]/impact/page.tsx
new file mode 100644
index 0000000..a46eb08
--- /dev/null
+++ b/apps/web/src/app/repos/[id]/impact/page.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { AlertTriangle, FlaskConical, Loader2, Radius, Search } from "lucide-react";
+import { intelBlast, intelSearch, intelUntestedHubs } from "@/lib/api";
+import type { BlastCaller, BlastReport } from "@/lib/codeintel/query";
+import type { CodeSymbol } from "@/lib/types";
+import { Empty, SectionHead, useRepo } from "../repo-context";
+
+const NO_SYMBOLS: CodeSymbol[] = [];
+
+/** Callers of one file, so the answer reads as "these files break", not "these 40 functions". */
+type FileGroup = {
+ file: string;
+ callers: BlastCaller[];
+ /** Nearest hop in the group — the sort key, because closest breaks first. */
+ nearest: number;
+ tested: number;
+};
+
+/**
+ * Group by file, nearest-first.
+ *
+ * A blast radius arrives as a flat list of symbols, and a flat list of forty function
+ * names is a wall. What a reader is deciding is which FILES they now have to open, and
+ * how far away each one is, so the file is the row and the hop distance of its closest
+ * caller is the order.
+ */
+function groupByFile(callers: readonly BlastCaller[]): FileGroup[] {
+ const byFile = new Map();
+ for (const c of callers) {
+ const bucket = byFile.get(c.file);
+ if (bucket) bucket.push(c);
+ else byFile.set(c.file, [c]);
+ }
+ return [...byFile.entries()]
+ .map(([file, group]) => ({
+ file,
+ callers: [...group].sort((a, b) => a.hops - b.hops || a.line - b.line),
+ nearest: Math.min(...group.map((c) => c.hops)),
+ tested: group.filter((c) => c.tested).length,
+ }))
+ .sort((a, b) => a.nearest - b.nearest || b.callers.length - a.callers.length || a.file.localeCompare(b.file));
+}
+
+/** The result of one blast-radius request, tagged with the symbol that asked for it. */
+interface Loaded {
+ readonly for: string;
+ readonly report: BlastReport | null;
+ readonly error: string | null;
+}
+
+export default function ImpactPage() {
+ const repo = useRepo();
+ const router = useRouter();
+ const pathname = usePathname();
+ const params = useSearchParams();
+ const selectedId = params.get("symbol") ?? "";
+
+ /**
+ * The selection lives in `?symbol=`, not in state.
+ *
+ * A blast radius is the thing you paste into a pull request or a Slack thread —
+ * "here is what this touches" is worth nothing if the recipient has to re-run the
+ * search to see it. `replace` rather than `push` for the same reason `useGraphUrl`
+ * does: picking through a candidate list is not twenty history entries.
+ */
+ const select = useCallback(
+ (id: string) => {
+ const next = new URLSearchParams(params.toString());
+ if (id) next.set("symbol", id);
+ else next.delete("symbol");
+ const qs = next.toString();
+ router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
+ },
+ [params, pathname, router]
+ );
+
+ const [q, setQ] = useState("");
+ /** Symbols together with the exact query they answer. */
+ const [found, setFound] = useState<{ readonly for: string; readonly symbols: CodeSymbol[] } | null>(null);
+
+ /** A blast radius together with the symbol id it was computed for. */
+ const [loaded, setLoaded] = useState(null);
+
+ /**
+ * DERIVED, not stored. An empty query has no results and an unselected symbol has no blast
+ * radius — those are facts about the current inputs, so reading them from the inputs cannot
+ * go stale. The version that cleared them with `setState` inside the effect rendered the
+ * PREVIOUS symbol's radius for one frame after a deselect, and cost a cascading render on
+ * every keystroke that emptied the box (`react-hooks/set-state-in-effect`).
+ */
+ const query = q.trim();
+ const results = query && found?.for === query ? found.symbols : NO_SYMBOLS;
+ // In flight whenever the answer on hand is not the answer to what is typed now. A stored
+ // boolean said "done" while the previous query's rows were still on screen.
+ const searching = query !== "" && found?.for !== query;
+ const current = selectedId && loaded?.for === selectedId ? loaded : null;
+ const blast = current?.report ?? null;
+ const blastError = current?.error ?? null;
+ const blastLoading = selectedId !== null && current === null;
+
+ const [hubs, setHubs] = useState(NO_SYMBOLS);
+ const [hubsLoading, setHubsLoading] = useState(true);
+
+ // Debounced, mirroring CodeIntelPanel: the same 250ms and the same cancel-on-clear,
+ // because a search box that behaves differently on two pages is two search boxes.
+ useEffect(() => {
+ if (!query) return;
+ let cancelled = false;
+ const t = setTimeout(async () => {
+ // Failure records the query too, so the box settles on "no matches" instead of
+ // spinning forever against a request that will never arrive.
+ const symbols = await intelSearch(repo.id, query).catch(() => NO_SYMBOLS);
+ if (!cancelled) setFound({ for: query, symbols });
+ }, 250);
+ return () => {
+ cancelled = true;
+ clearTimeout(t);
+ };
+ }, [query, repo.id]);
+
+ useEffect(() => {
+ if (!selectedId) return;
+ let cancelled = false;
+ // One state write per outcome, and the write CARRIES the symbol it describes. Loading is
+ // then "the record on hand is not for this symbol", which cannot get out of step with the
+ // selection the way a separate boolean did.
+ void intelBlast(repo.id, selectedId)
+ .then((r): Loaded => ({
+ for: selectedId,
+ report: r,
+ // A link can outlive a re-index, and an empty panel would look like "nothing calls
+ // this" rather than "this symbol is gone".
+ error: r.symbol ? null : `No symbol ${selectedId} in the current index.`,
+ }))
+ .catch(
+ (e: unknown): Loaded => ({
+ for: selectedId,
+ report: null,
+ error: e instanceof Error ? e.message : "Could not compute the blast radius.",
+ }),
+ )
+ .then((next) => {
+ if (!cancelled) setLoaded(next);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [repo.id, selectedId]);
+
+ useEffect(() => {
+ let cancelled = false;
+ intelUntestedHubs(repo.id)
+ .then((r) => {
+ if (!cancelled) setHubs(r);
+ })
+ .catch(() => {
+ if (!cancelled) setHubs(NO_SYMBOLS);
+ })
+ .finally(() => {
+ if (!cancelled) setHubsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [repo.id]);
+
+ const groups = useMemo(() => groupByFile(blast?.callers ?? []), [blast]);
+
+ if (!repo.symbolGraph || repo.symbolGraph.symbols.length === 0) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ {/* min-w-0: an unbroken path in the results would otherwise widen the track. */}
+
+
+
+ setQ(e.target.value)}
+ placeholder="Search a symbol or a file to change…"
+ className="w-full rounded-md border border-[var(--line)] bg-[var(--surface-2)] py-sm pl-xl pr-md text-meta text-[var(--text-primary)] placeholder-[var(--text-muted)] focus:border-[var(--violet-500)]/50 focus:outline-none"
+ />
+ {searching && (
+
+ )}
+
+
+ {/* The hop histogram: one hop is "callers", three hops is "the part you
+ were not going to check". Counting them is the cheap version of that. */}
+
+ Symbols with the most callers that no test file reaches, most-depended-upon first.
+ This is the same set the agent swarm raises as “untested core logic” — one
+ definition, so the two cannot disagree. Pick one to see what it would take down.
+
+
+ {hubsLoading && (
+
+ Ranking hubs…
+
+ )}
+ {!hubsLoading && hubs.length === 0 && (
+
+ Every hub in this graph has a test caller. That is the good outcome.
+
+ {/* Points at `ask`, not `network`. It used to open the force-directed graph,
+ which is already one click away in the sidebar and answers no question:
+ the label promised a query surface and delivered a picture. */}
Query the graph
diff --git a/apps/web/src/app/repos/[id]/network/page.tsx b/apps/web/src/app/repos/[id]/network/page.tsx
index 1fca3b8..9c0b281 100644
--- a/apps/web/src/app/repos/[id]/network/page.tsx
+++ b/apps/web/src/app/repos/[id]/network/page.tsx
@@ -10,7 +10,7 @@ export default function NetworkPage() {
<>
{repo.viz && repo.viz.nodes.length > 0 ? (
- {(onSelect) => }
+ {(onSelect) => }
) : (
diff --git a/apps/web/src/app/repos/[id]/ownership/page.tsx b/apps/web/src/app/repos/[id]/ownership/page.tsx
new file mode 100644
index 0000000..eccb26b
--- /dev/null
+++ b/apps/web/src/app/repos/[id]/ownership/page.tsx
@@ -0,0 +1,256 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { Loader2, UserRound, Users } from "lucide-react";
+import { ownershipReviewers, ownershipSummary, type OwnershipSummary, type ReviewerSuggestion } from "@/lib/api";
+import { Empty, SectionHead, useRepo } from "../repo-context";
+
+/**
+ * Ownership: who knows this code, and where nobody does any more.
+ *
+ * EVERY NUMBER HERE IS A SHARE OF COMMITS, not of lines, and the page says so rather than
+ * leaving a percentage to be read as something stronger. Git attributes a commit to a file;
+ * per-line authorship for a whole tree means a blame per file, which the index does not run.
+ * "Owns 62%" therefore means "made 62% of the commits that touched this", which is the standard
+ * proxy and is not the same claim.
+ *
+ * The report is computed at index time, so a repository indexed before it existed has none —
+ * the route answers 409 and this renders that as "re-index", never as "no owners".
+ */
+
+const DAY_MS = 86_400_000;
+
+/** Files listed under stale areas. The report already caps at 100; this is what fits a page. */
+const SHOWN_STALE = 25;
+
+function relativeDay(epochSeconds: number): string {
+ const days = Math.round((Date.now() - epochSeconds * 1000) / DAY_MS);
+ if (days <= 0) return "today";
+ if (days === 1) return "yesterday";
+ if (days < 60) return `${days} days ago`;
+ return `${Math.round(days / 30)} months ago`;
+}
+
+function Bar({ share }: { share: number }) {
+ const pct = Math.round(share * 100);
+ return (
+
+
+
+
+ {/* The number is the accessible carrier; the bar is decoration. */}
+ {pct}%
+
+ );
+}
+
+export default function OwnershipPage() {
+ const repo = useRepo();
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ /*
+ * DERIVED, not set inside the effect. `setLoading(true)` in an effect body is a cascading
+ * render, and it also encoded the state twice: "loading" is exactly "the data I hold is not
+ * for the repo I am rendering", which this compares directly and cannot get out of step.
+ */
+ const [loadedFor, setLoadedFor] = useState(null);
+ const loading = loadedFor !== repo.id;
+
+ const [files, setFiles] = useState("");
+ const [reviewers, setReviewers] = useState(null);
+ const [reviewerError, setReviewerError] = useState(null);
+ const [reviewersLoading, setReviewersLoading] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ ownershipSummary(repo.id)
+ .then((d) => {
+ if (!cancelled) {
+ setData(d);
+ setError(null);
+ }
+ })
+ .catch((e: unknown) => {
+ if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load ownership");
+ })
+ .finally(() => {
+ // Marks the data as belonging to THIS repo, which is what ends the loading state.
+ if (!cancelled) setLoadedFor(repo.id);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [repo.id]);
+
+ const parsedFiles = useMemo(
+ () => files.split(/[\n,]/).map((f) => f.trim()).filter(Boolean),
+ [files],
+ );
+
+ async function askReviewers(): Promise {
+ if (parsedFiles.length === 0) return;
+ setReviewersLoading(true);
+ setReviewerError(null);
+ try {
+ const res = await ownershipReviewers(repo.id, parsedFiles);
+ setReviewers(res.reviewers);
+ } catch (e: unknown) {
+ setReviewers(null);
+ setReviewerError(e instanceof Error ? e.message : "Failed to load reviewers");
+ } finally {
+ setReviewersLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ {loading ? (
+
+ Reading history…
+
+ ) : error ? (
+ // The route's own message. A 409 says "re-index"; anything else is a real failure. Both
+ // are shown verbatim rather than flattened into an empty state that would read as
+ // "this repository has no owners".
+
+ ) : data === null ? (
+
+ ) : (
+ <>
+
+ {data.commitsAnalysed.toLocaleString()} commits
+ over the last {data.windowDays} days, from{" "}
+ {data.authors.length} author
+ {data.authors.length === 1 ? "" : "s"}. Shares are of commits touching a file, not
+ of lines.
+ {data.truncated ? " A bound was hit, so this is a partial view." : ""}
+
+ Files whose every owner has stopped committing in the recent window. Old is not the
+ same as abandoned — a file nobody has needed to touch is merely stale, and is not
+ listed here.
+
+ Ranked over real history: ownership of the files you name, how recently each person
+ touched that area, and what else changes alongside it. Anyone inactive in the window
+ is excluded — routing a review to someone who left is worse than routing it nowhere.
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/repos/[id]/page.tsx b/apps/web/src/app/repos/[id]/page.tsx
index fd8e0fb..9c46718 100644
--- a/apps/web/src/app/repos/[id]/page.tsx
+++ b/apps/web/src/app/repos/[id]/page.tsx
@@ -1,11 +1,19 @@
"use client";
import Link from "next/link";
-import { AlertTriangle, ArrowRight, ArrowUpRight, Crosshair, Layers, SquareArrowOutUpRight, Target } from "lucide-react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { AlertTriangle, ArrowRight, ArrowUpRight, ChevronRight, Crosshair, Layers, SquareArrowOutUpRight, Target, TrendingDown } from "lucide-react";
+import { tallyByRule } from "@codegraph/analysis-model";
import type { Dimension } from "@/lib/types";
import { DIMENSION_META, PILLAR_META, pillarsFrom } from "@/lib/types";
+import type { FindingGroupKey, TierFilter } from "@/lib/findings";
+import { GROUP_META, editorHref, findingLocation, groupByTier, parseTierFilter, ruleBreakdown } from "@/lib/findings";
+import { coverageNote } from "@/lib/coverage-note";
+import { plural } from "@/lib/plural";
import { CountUp, Reveal, Stagger, StaggerItem } from "@/components/motion/primitives";
+import { FindingEvidence } from "@/components/FindingEvidence";
import { ScoreDial } from "@/components/ScoreDial";
+import { TierFilterBar } from "@/components/TierFilterBar";
import { band, useRepo } from "./repo-context";
import { SECTIONS, sectionHref } from "./sections";
@@ -63,6 +71,25 @@ const TIER_META: Record
};
const TIER_ORDER = ["full", "ast", "lexical", "skipped"] as const;
+/**
+ * A tier's colour, defined once so the breakdown's bars, its tier counts and the group
+ * headers below it cannot drift into disagreeing about what "medium" looks like.
+ */
+const TIER_TONE: Record = {
+ high: "var(--coral-text)",
+ medium: "var(--amber-text)",
+ low: "var(--text-muted)",
+ accepted: "var(--text-faint)",
+};
+const TIER_KEYS: readonly FindingGroupKey[] = ["high", "medium", "low", "accepted"];
+
+/**
+ * Rows drawn per open group. The group header always states the true count, so this
+ * trims the page without trimming the fact — 71 high-confidence rows inline would push
+ * every other section off the bottom of the overview.
+ */
+const GROUP_CAP = 12;
+
export default function RepoOverview() {
const repo = useRepo();
@@ -82,7 +109,33 @@ export default function RepoOverview() {
const measured = other.filter((p) => p.score !== null);
const unmeasured = other.filter((p) => p.score === null);
- const ranked = [...repo.issues].slice(0, 12);
+ /**
+ * The filter lives in the query string, not in `useState`: "here are the 11 medium
+ * findings" is a thing one person sends another, and a filter held in component state
+ * makes that link show them the default view instead.
+ *
+ * `replace`, not `push` — flipping chips is browsing one list, not visiting six pages.
+ */
+ const router = useRouter();
+ const pathname = usePathname();
+ const search = useSearchParams();
+ const filter = parseTierFilter(search.get("tier"));
+ const setFilter = (next: TierFilter) => {
+ const params = new URLSearchParams(search);
+ // `default` is the absence of a choice, so it is the absence of a param.
+ if (next === "default") params.delete("tier");
+ else params.set("tier", next);
+ const query = params.toString();
+ router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
+ };
+
+ const tiered = groupByTier(repo.issues, filter);
+ const breakdown = ruleBreakdown(repo.issues);
+ const topRule = breakdown.rows[0];
+
+ // Null means the run recorded no coverage at all, which the sentence below renders as
+ // UNKNOWN — never as complete.
+ const coverage = repo.coverage ? coverageNote(repo.coverage) : null;
/**
* Percentages are computed over the tiers PRESENT, not over `locAnalysed`, so the
@@ -148,26 +201,16 @@ export default function RepoOverview() {
)}{" "}
{/* ADR-008: the score states the coverage it was computed over, inside the
sentence that makes the claim. */}
- {repo.coverage ? (
-
- Scored over{" "}
-
- {repo.coverage.filesSeen === 0
- ? "—"
- : `${Math.round((repo.coverage.filesAnalysed / repo.coverage.filesSeen) * 100)}%`}
- {" "}
- of files ({repo.coverage.filesAnalysed} of{" "}
- {repo.coverage.filesSeen}
- {repo.coverage.skippedTooLarge > 0 && (
- <>, {repo.coverage.skippedTooLarge} over the size cap>
- )}
- {repo.coverage.skippedNoLanguage > 0 && (
- <>, {repo.coverage.skippedNoLanguage} unsupported>
+ {coverage ? (
+ <>
+ {coverage.scope}
+ {coverage.sample !== null && (
+ <>
+ {" "}
+ {coverage.sample}
+ >
)}
- ){repo.coverage.capHit && (
- — the scan hit the file cap
- )}.
-
+ >
) : (
Coverage was not recorded for this index, so what it was computed over is
@@ -226,6 +269,126 @@ export default function RepoOverview() {
+ {/* ---- What is producing the list --------------------------------
+ The Health Score says 72 and the stat strip says 200 findings; neither
+ answers the first question a reader actually has, which is whether those
+ 200 are 200 problems or one problem counted 100 times. The tally is the
+ answer and it fits in a row. */}
+
+
+
+
+
+
What is producing the findings
+
+ {/* Every tier, including the empty ones — "accepted 0" is a fact, and
+ omitting it reads as "not measured". */}
+
+ {TIER_KEYS.map((key) => (
+
+
{GROUP_META[key].short}
+
+ {breakdown.tiers[key]}
+
+
+ ))}
+
+
+
+ {breakdown.total === 0 ? (
+
+ No findings, so there is nothing to break down by rule.
+
+ ) : (
+ <>
+
+ {breakdown.rows.map((row) => (
+
+
+ {/* A derived id is title prose, not a rule id, so it is not dressed
+ as one: chrome that says "copy me into a baseline" on a string
+ the next index can change is the lie this label prevents. */}
+ {row.derived ? (
+
+ {row.title}
+
+ ) : (
+
+ {row.rule}
+
+ )}
+ {row.derived && (
+
+ no rule id
+
+ )}
+
+
+
+
+
+ {row.count}
+
+
+ {GROUP_META[row.tier].short}
+
+
+ {row.suppressed > 0 ? `${row.suppressed} accepted` : "—"}
+
+
+ {breakdown.derivedFindings} of these findings were
+ indexed before rule ids were recorded, so they are grouped by their title text
+ rather than by a rule — re-index for rule-level grouping.
+
Ranked by severity weighted with blast radius — how many symbols reach this one through
- the graph — rather than by severity alone.
+ the graph — rather than by severity alone, and grouped by how much the detector could
+ prove.
{repo.issues.length === 0 ? (
@@ -409,68 +574,121 @@ export default function RepoOverview() {
No findings detected in this index.
) : (
-
-
- Finding
- Severity
- Blast
- Churn
- Open in editor
+ <>
+
+ {/* The location is the link, not just the icon at the end of
+ the row. `index.js:123` is what a reader reaches for — it
+ names the destination — and until now it was inert text
+ while the only working affordance was a 14px glyph that
+ appears on hover. Both go to the same place. */}
+
+ {/* The evidence, the rule id, and the two strings you need to
+ accept this finding — in the row, because "why do you
+ believe this" and "how do I make it stop" are the two
+ questions every row raises. */}
+
+
+
+ S{iss.severity} {sev.label}
+
+
+ ×{iss.blastRadius}
+
+
+ {iss.churn ?? "—"}
+
+ {/* Straight to the line. A finding names a file and a line and
+ then makes you go and find them yourself, which is the one
+ step of this workflow the product can just do.
+
+ Enabled only with a live workspace: without a clone there is
+ nothing for the editor to open, and a link that lands on an
+ empty state is worse than a disabled control that says why. */}
+ {repo.hasWorkspace ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ {group.open && group.issues.length > GROUP_CAP && (
+
+ {group.issues.length - GROUP_CAP} more in this
+ group — take them to the swarm
+
+ )}
+
+ ))}
+
+ >
)}
@@ -494,7 +712,7 @@ export default function RepoOverview() {
{meta.label}{" "}
- · {d.issueCount} issues · weight{" "}
+ · {plural(d.issueCount, "issue")} · weight{" "}
{Math.round(meta.weight * 100)}%
diff --git a/apps/web/src/app/repos/[id]/sections.ts b/apps/web/src/app/repos/[id]/sections.ts
index 817ca60..997649e 100644
--- a/apps/web/src/app/repos/[id]/sections.ts
+++ b/apps/web/src/app/repos/[id]/sections.ts
@@ -5,7 +5,10 @@ import {
Gauge,
History,
LayoutGrid,
+ MessagesSquare,
Network,
+ Radius,
+ Users,
type LucideIcon,
} from "lucide-react";
@@ -65,6 +68,27 @@ export const SECTIONS: readonly Section[] = [
blurb: "Force-directed import graph. Click a file to inspect its symbols and callers.",
immersive: true,
},
+ {
+ group: "Intelligence",
+ slug: "ask",
+ label: "Ask",
+ icon: MessagesSquare,
+ blurb: "A question in English, compiled into graph operations and answered from the index.",
+ },
+ {
+ group: "Intelligence",
+ slug: "impact",
+ label: "Impact",
+ icon: Radius,
+ blurb: "What breaks if you change a symbol, and which hubs no test reaches.",
+ },
+ {
+ group: "Intelligence",
+ slug: "ownership",
+ label: "Ownership",
+ icon: Users,
+ blurb: "Who knows this code, where nobody does any more, and who should review a change.",
+ },
{
group: "Intelligence",
slug: "agents",
diff --git a/apps/web/src/app/repos/[id]/timeline/page.tsx b/apps/web/src/app/repos/[id]/timeline/page.tsx
index de6ac38..d8656c0 100644
--- a/apps/web/src/app/repos/[id]/timeline/page.tsx
+++ b/apps/web/src/app/repos/[id]/timeline/page.tsx
@@ -10,7 +10,7 @@ export default function TimelinePage() {
>
diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx
deleted file mode 100644
index b1e0065..0000000
--- a/apps/web/src/app/settings/page.tsx
+++ /dev/null
@@ -1,674 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { useEffect, useState } from "react";
-import { fetchAssistantSettingsView, updateAssistantSettings, fetchMe, type AuthMe } from "@/lib/api";
-import type { AssistantSettingsView } from "@/lib/settings";
-import { Loader2, Save, CheckCircle2, Plus, X, RefreshCw, Zap, Trash2, User, LogIn, AlertTriangle } from "lucide-react";
-
-/**
- * Form chrome, written once.
- *
- * Every control on this page is the same control, so the class strings live here
- * rather than being retyped per field — that is what kept eight inputs in step
- * before, and it is what keeps the focus treatment identical across all of them.
- * Focus deliberately does NOT set `outline-none`: `globals.css` gives every
- * focusable element a signal-coloured ring, and the border shift below is an
- * addition to it, not a replacement.
- */
-const INPUT =
- "w-full min-h-11 rounded-lg border border-[var(--line)] bg-[var(--surface-2)] px-sm py-sm text-meta text-[var(--text-primary)] placeholder:text-[var(--text-muted)] transition-colors duration-200 hover:border-[var(--line-strong)] focus:border-[var(--accent-text)]";
-const LABEL = "mb-xs block text-meta font-medium text-[var(--text-secondary)]";
-const HELP = "mt-xs text-meta leading-relaxed text-[var(--text-muted)]";
-const BTN =
- "flex min-h-11 cursor-pointer items-center gap-xs rounded-lg border border-[var(--line)] bg-[var(--surface-2)] px-sm text-meta text-[var(--text-secondary)] transition-colors duration-200 hover:border-[var(--line-strong)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50";
-/** Coral is risk, and clearing a saved credential is the only risk on this page. */
-const BTN_DANGER =
- "flex min-h-11 cursor-pointer items-center gap-xs rounded-lg border border-[var(--line)] bg-[var(--surface-2)] px-sm text-meta text-[var(--text-secondary)] transition-colors duration-200 hover:border-[var(--coral-500)]/40 hover:text-[var(--coral-text)] disabled:cursor-not-allowed disabled:opacity-50";
-
-export default function SettingsPage() {
- const [settings, setSettings] = useState(null);
- const [me, setMe] = useState(null);
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [saved, setSaved] = useState(false);
- const [error, setError] = useState(null);
-
- // Form state
- const [anthropicKey, setAnthropicKey] = useState("");
- const [localBaseUrl, setLocalBaseUrl] = useState("");
- const [localModel, setLocalModel] = useState("");
- const [claudeModel, setClaudeModel] = useState("sonnet");
- const [useSubscription, setUseSubscription] = useState(false);
- const [subscriptionBusy, setSubscriptionBusy] = useState(false);
- const [localApiKey, setLocalApiKey] = useState("");
- const [modelList, setModelList] = useState([]);
- const [newModelInput, setNewModelInput] = useState("");
- const [discovering, setDiscovering] = useState(false);
- const [discovered, setDiscovered] = useState([]);
- const [modelListError, setModelListError] = useState(null);
- const [providerName, setProviderName] = useState("");
- const [providerBusy, setProviderBusy] = useState(null);
- const [providerError, setProviderError] = useState(null);
-
- useEffect(() => {
- fetchAssistantSettingsView()
- .then((data) => {
- setSettings(data);
- setLocalBaseUrl(data.localBaseUrl || "");
- setLocalModel(data.localModel || "");
- setClaudeModel(data.claudeModel || "sonnet");
- setUseSubscription(data.useClaudeSubscription);
- setModelList(data.localModelList || []);
- setLoading(false);
- })
- .catch((err) => {
- setError(err.message || "Failed to load settings");
- setLoading(false);
- });
- fetchMe().then(setMe).catch(() => {});
- }, []);
-
- async function persistModelList(next: string[]) {
- setModelListError(null);
- try {
- const updated = await updateAssistantSettings({ localModelList: next.length > 0 ? next : null });
- setSettings(updated);
- setModelList(updated.localModelList || []);
- } catch (err) {
- setModelListError(err instanceof Error ? err.message : "Failed to update model list");
- }
- }
-
- function handleAddModel() {
- const trimmed = newModelInput.trim();
- if (!trimmed || modelList.includes(trimmed)) { setNewModelInput(""); return; }
- setNewModelInput("");
- persistModelList([...modelList, trimmed]);
- }
-
- function handleRemoveModel(m: string) {
- persistModelList(modelList.filter((x) => x !== m));
- }
-
- async function handleDiscover() {
- setDiscovering(true);
- setModelListError(null);
- try {
- const res = await fetch("/api/settings/models?discover=true");
- const data = await res.json() as { models?: string[] };
- setDiscovered((data.models || []).filter((m) => !modelList.includes(m)));
- } catch (err) {
- setModelListError(err instanceof Error ? err.message : "Failed to reach the local model server");
- } finally {
- setDiscovering(false);
- }
- }
-
- async function handleUseProvider(id: string) {
- setProviderBusy(id);
- setProviderError(null);
- try {
- const updated = await updateAssistantSettings({ useProviderId: id });
- setSettings(updated);
- setLocalBaseUrl(updated.localBaseUrl || "");
- setLocalModel(updated.localModel || "");
- setModelList(updated.localModelList || []);
- setLocalApiKey("");
- } catch (err) {
- setProviderError(err instanceof Error ? err.message : "Failed to switch provider");
- } finally {
- setProviderBusy(null);
- }
- }
-
- async function handleDeleteProvider(id: string) {
- if (!confirm("Delete this saved provider profile?")) return;
- setProviderBusy(id);
- setProviderError(null);
- try {
- const updated = await updateAssistantSettings({ deleteProviderId: id });
- setSettings(updated);
- } catch (err) {
- setProviderError(err instanceof Error ? err.message : "Failed to delete profile");
- } finally {
- setProviderBusy(null);
- }
- }
-
- async function handleSaveProvider() {
- const name = providerName.trim();
- if (!name) { setProviderError("Enter a name for this profile"); return; }
- if (!localBaseUrl.trim()) { setProviderError("Set a Base URL above before saving a profile"); return; }
- setProviderBusy("__new__");
- setProviderError(null);
- try {
- const updated = await updateAssistantSettings({
- saveProvider: { name, baseUrl: localBaseUrl.trim(), apiKey: localApiKey || undefined, models: modelList },
- });
- setSettings(updated);
- setProviderName("");
- } catch (err) {
- setProviderError(err instanceof Error ? err.message : "Failed to save profile");
- } finally {
- setProviderBusy(null);
- }
- }
-
- async function handleToggleSubscription() {
- setSubscriptionBusy(true);
- setError(null);
- try {
- const next = !useSubscription;
- const updated = await updateAssistantSettings({ useClaudeSubscription: next });
- setSettings(updated);
- setUseSubscription(updated.useClaudeSubscription);
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to update subscription setting");
- } finally {
- setSubscriptionBusy(false);
- }
- }
-
- async function handleSave(e: React.FormEvent) {
- e.preventDefault();
- setSaving(true);
- setSaved(false);
- setError(null);
- try {
- const patch: { anthropicApiKey?: string | null; claudeModel?: string | null; localBaseUrl?: string | null; localModel?: string | null; localApiKey?: string | null } = {
- localBaseUrl: localBaseUrl || null,
- localModel: localModel || null,
- claudeModel: claudeModel || null,
- };
-
- if (anthropicKey) patch.anthropicApiKey = anthropicKey;
- if (localApiKey) patch.localApiKey = localApiKey;
-
- const updated = await updateAssistantSettings(patch);
- setSettings(updated);
- setAnthropicKey("");
- setLocalApiKey("");
- setSaved(true);
- setTimeout(() => setSaved(false), 3000);
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to save settings");
- } finally {
- setSaving(false);
- }
- }
-
- async function handleClearAnthropic() {
- if (!confirm("Clear saved Anthropic API key?")) return;
- setSaving(true);
- try {
- const updated = await updateAssistantSettings({ anthropicApiKey: null });
- setSettings(updated);
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err));
- } finally {
- setSaving(false);
- }
- }
-
- async function handleClearLocalKey() {
- if (!confirm("Clear saved Local API key?")) return;
- setSaving(true);
- try {
- const updated = await updateAssistantSettings({ localApiKey: null });
- setSettings(updated);
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err));
- } finally {
- setSaving(false);
- }
- }
-
- async function handleClearClaudeModel() {
- setSaving(true);
- try {
- const updated = await updateAssistantSettings({ claudeModel: null });
- setSettings(updated);
- setClaudeModel(updated.claudeModel || "sonnet");
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err));
- } finally {
- setSaving(false);
- }
- }
-
- if (loading) {
- return (
-
- Loading settings…
-
- );
- }
-
- if (error && !settings) {
- // The whole page for anyone who is not signed in, so it gets a designed state
- // rather than a bare pill above an empty screen: name the situation, explain it
- // in one line, and offer the action that resolves it. An error with no way out
- // is a dead end, and this one has an obvious exit.
- const needsAuth = /unauthor|sign in/i.test(error ?? "");
- return (
-
- {me.user
- ?
- : }
- {me.user ? (
-
- Signed in as {me.user.login} — everything below is saved to your account only, never
- shared with or visible to any other GitHub account on this deployment.
-
- ) : (
-
- You're not signed in with GitHub. Settings saved now go to a shared configuration anyone using this
- deployment can see and overwrite.{" "}
-
- Sign in with GitHub
- {" "}
- first to keep your API key and model choices private to your own account.
-
- )}
-
- )}
-
-
-
- );
-}
diff --git a/apps/web/src/components/AgentSwarm.tsx b/apps/web/src/components/AgentSwarm.tsx
index e04995f..f450231 100644
--- a/apps/web/src/components/AgentSwarm.tsx
+++ b/apps/web/src/components/AgentSwarm.tsx
@@ -1,10 +1,15 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { useEffect, useMemo, useRef, useState } from "react";
import { Bot, Loader2, Play, ShieldAlert, Gauge, Wrench, Skull, Package, Network, FlaskConical, ChevronRight, TrendingUp, GitPullRequest, Copy, Check } from "lucide-react";
import { runAgents, runFix } from "@/lib/api";
import { once, useSharedState, writeState } from "@/lib/ui-state";
+import { autoFixable, editorHref, findingLocation } from "@/lib/findings";
+import { plural } from "@/lib/plural";
import type { AgentId, Finding, Priority, RemediationPlan } from "@/lib/agents/types";
+import type { Issue } from "@codegraph/analysis-model";
+import { FIXERS } from "@codegraph/remediate-engine/fixers";
import type { FixResult } from "@/lib/agents/executor-types";
import { VerificationVerdict } from "./VerificationVerdict";
@@ -25,6 +30,27 @@ const PRIO_STYLE: Record = {
P3: "text-[var(--text-secondary)] bg-[var(--surface-active)] border-[var(--line)]",
};
+/**
+ * Every row carries a four-part shorthand — `P1 · S3 · ×12 · M` — and the page shipped with
+ * nothing anywhere that decoded it. A reader who has not read `orchestrator.ts` cannot tell
+ * whether `×12` is good or bad, or what separates P1 from P2.
+ *
+ * Both halves of the fix are here on purpose. The visible legend above the list is what makes
+ * the shorthand learnable at all, because a tooltip is only found by someone who already
+ * suspects there is something to hover; the per-row titles are what answer the question at the
+ * row you are actually looking at, with that row's own numbers in it. Priority wording states
+ * the thresholds `priorityOf` applies, so the chip and the code cannot drift into disagreeing.
+ */
+const PRIO_TITLE: Record = {
+ P0: "Priority 0 — fix first. Score 70 or above, or any security finding at severity 4+. Counted in the projected Health Score.",
+ P1: "Priority 1 — high. Score 40 to 69. Counted in the projected Health Score.",
+ P2: "Priority 2 — routine. Score 20 to 39.",
+ P3: "Priority 3 — lowest. Score below 20.",
+};
+
+/** The word the expanded row already uses for an effort letter. One vocabulary, two places. */
+const EFFORT_WORD: Record = { S: "small", M: "medium", L: "large" };
+
/**
* Swarm state is keyed by repository and lives outside the component.
*
@@ -50,7 +76,7 @@ function startSwarm(repoId: string): void {
});
}
-export function AgentSwarm({ repoId }: { repoId: string }) {
+export function AgentSwarm({ repoId, hasWorkspace, issues }: { repoId: string; hasWorkspace: boolean; issues: readonly Issue[] }) {
const [plan] = useSharedState(key(repoId, "plan"), null);
const [loading] = useSharedState(key(repoId, "loading"), false);
const [error] = useSharedState(key(repoId, "error"), null);
@@ -122,7 +148,7 @@ export function AgentSwarm({ repoId }: { repoId: string }) {
))}
-
+
{/* Filter + findings */}
+ {/* The legend, not a tooltip, is what makes the row shorthand readable on first
+ sight — see PRIO_TITLE. Deliberately one line: it sits above every finding on
+ the page and has to cost a glance, not a paragraph. */}
+
+ Each row reads priority · severity · blast radius · effort. P0–P3 is the fix order,
+ S1–S5 the severity, ×n the blast radius — how many symbols reach this finding through
+ the graph — and S/M/L the size of the remediation.
+