From d0f11aa991304992fd3695da0439dcf4b8f43723 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 15:16:11 +0000 Subject: [PATCH 1/3] fix(docs): refresh post-v0.30.0 staleness; extend mermaid theme to .mdx - ARCHITECTURE.md: add rank.js, collide.js, docs_render.js to repo layout tree and component descriptions - mintlify/cli/overview.mdx: add rank and collide to Labs card - docs_render.js: widen trackedMarkdown glob to *.md + *.mdx so normalizeMermaid auto-applies the branded theme to mintlify diagrams; inject init line into bare mermaid blocks (was skip-only) - docs_check.js: widen markdownFiles to *.md + *.mdx so diagram checks cover mintlify files - 6 mintlify mermaid diagrams now carry the branded %%{init theme - Updated normalizeMermaid test for the new inject behavior Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01LXmzxfRVDRVPU6LG8W39Rz --- ARCHITECTURE.md | 27 +++++++++++++++++++++ CHANGELOG.md | 7 ++++++ mintlify/cli/overview.mdx | 2 +- mintlify/concepts/config-compiler.mdx | 1 + mintlify/concepts/pre-action-gate.mdx | 1 + mintlify/concepts/proof-carrying-memory.mdx | 2 ++ mintlify/guides/team-memory.mdx | 1 + mintlify/guides/zero-config-onboarding.mdx | 1 + src/docs_check.js | 9 +++++-- src/docs_render.js | 7 +++--- test/docs_render.test.js | 6 +++-- 11 files changed, 56 insertions(+), 8 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d548507..e775507 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -340,6 +340,30 @@ exported symbols, brand tokens, version, package.json fields) reused from `docs_ an inverted entity → `file:line` index over every doc surface, and a diff-scoped impact query ranked by confidence. Advisory by default; `--strict` exits non-zero for CI. +**Load-bearing code detector (`src/rank.js`, `forge rank`).** Fuses three classical graph +readings of the atlas — weighted PageRank centrality (deterministic power iteration over +sorted node ids), iterative Tarjan SCC (circular-dependency clusters), iterative +Hopcroft–Tarjan articulation points (chokepoint files) — with the team's own incident +history from the evidence ledger (`val()`-weighted lesson and session-summary claims that +name each file). The join `hazard = centralityNorm × (1 + history)` means structurally +central code that has hurt before outranks equally central code that hasn't. Exposed as the +`rank_code` MCP tool and the `forge rank` CLI command. + +**Parallel-session conflict radar (`src/collide.js`, `forge collide`).** Reads recent +foreign-session summaries from the team-merged ledger and computes per-file collision risk +via the same noisy-OR model lessons use: `risk = 1 − ∏(1 − recᵢ × strengthᵢ)` over +sessions that touched overlapping files or their 1-hop import neighbors. No server, no +presence protocol — teammate summaries arrive via `forge ledger sync` / `git pull`. Exposed +as the `collide_check` MCP tool. + +**Machine-owned doc surfaces (`src/docs_render.js`, `forge docs render`).** The +auto-maintenance layer that keeps tables and diagrams in sync with the code registries. +Four marker-managed blocks (commands table in README, groups and MCP-tools tables in GUIDE, +repo-map diagram in ARCHITECTURE) are regenerated from `COMMANDS`/`GROUPS`/`TOOLS`; six +"N MCP tools" count phrases are auto-corrected; and every mermaid block across all `.md` +and `.mdx` files receives the branded `%%{init` theme. Registry-derived blocks are CI-gated +errors when stale; tree-derived output is advisory. + **Deliberately not wired:** `checkpointCadence` (optimal-stopping check spacing) still has no runtime step-loop to consume it — wiring it would mean inventing one. It stays library math with tests until a real consumer exists. @@ -459,6 +483,9 @@ forgekit/ dash.js # localhost-only read-only dashboard over the ledger, metrics, and blast radius (node:http, one HTML page) metrics.js # stage-tagged .forge/metrics.jsonl — the measured events every cost figure is computed from cost_report.js # per-stage cost factors as pure arithmetic over metrics.jsonl; composes ONLY measured stages + rank.js # load-bearing code: weighted PageRank centrality × ledger incident history, Tarjan SCC (circular deps), Hopcroft–Tarjan articulation points (chokepoints) + collide.js # parallel-session conflict radar: noisy-OR risk over recent foreign sessions that touched overlapping files or their import neighbors + docs_render.js # machine-owned doc surfaces: registry-derived tables (commands, groups, MCP tools) + tree-derived repo map, auto-normalized mermaid themes source/ rules.json # THE canonical rules source (git · testing · security · style) substrate.json # cognitive-substrate defaults (thresholds, routing, llm knobs) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0727f5f..80fa9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **docs: refresh post-v0.30.0 staleness.** ARCHITECTURE.md repo layout and component + descriptions updated for `rank.js`, `collide.js`, `docs_render.js`; mintlify Labs card + now lists `rank` and `collide`; mermaid theme normalization extended to `.mdx` files + (6 mintlify diagrams were rendering in default blue/grey instead of the branded palette). + ## [0.30.0] - 2026-08-07 ### Added diff --git a/mintlify/cli/overview.mdx b/mintlify/cli/overview.mdx index 69fa126..e96a9e2 100644 --- a/mintlify/cli/overview.mdx +++ b/mintlify/cli/overview.mdx @@ -29,7 +29,7 @@ noticing. Commands are organized into six groups (the last is experimental). Experimental — may change or move: `taste`, `uicheck`, `imagine`, `lean`, `anchor`, - `diagnose`, `dash`, `report`, `deja`, `reuse`. Documented in the reference pages above. + `diagnose`, `dash`, `report`, `deja`, `reuse`, `rank`, `collide`. Documented in the reference pages above. diff --git a/mintlify/concepts/config-compiler.mdx b/mintlify/concepts/config-compiler.mdx index cea0326..6a5efec 100644 --- a/mintlify/concepts/config-compiler.mdx +++ b/mintlify/concepts/config-compiler.mdx @@ -8,6 +8,7 @@ config. The four layers are _how the brain is expressed_; the compiler is _how i delivered_. ```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart TD S["source/ · rules.json · substrate.json · mcp.json"] -->|"forge sync — content-hash + DO-NOT-EDIT headers"| N["native configs · CLAUDE.md · AGENTS.md · .cursor · .gemini · .aider"] S -. configures .-> L diff --git a/mintlify/concepts/pre-action-gate.mdx b/mintlify/concepts/pre-action-gate.mdx index e20ed4c..4fa92f7 100644 --- a/mintlify/concepts/pre-action-gate.mdx +++ b/mintlify/concepts/pre-action-gate.mdx @@ -10,6 +10,7 @@ and returns a single verdict. It composes the individually-callable stages — `verify` — into one pre-action contract. ```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart TD RE["referenced entities"] --> INTAKE subgraph INTAKE["intake"] diff --git a/mintlify/concepts/proof-carrying-memory.mdx b/mintlify/concepts/proof-carrying-memory.mdx index 24cf5ae..4a863b1 100644 --- a/mintlify/concepts/proof-carrying-memory.mdx +++ b/mintlify/concepts/proof-carrying-memory.mdx @@ -25,6 +25,7 @@ materializes from the ledger. (`FORGE_LEDGER_ONLY=0` restores the legacy file st one-release escape hatch.) ```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR subgraph EV["local events"] direction TB @@ -102,6 +103,7 @@ dependencies still resolve. Otherwise it falls through to generation and mints a claim on the way back. ```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR SP["spec"] --> FP["fingerprint · MinHash + LSH"] FP --> LD["match ladder · exact to near to adapt to miss"] diff --git a/mintlify/guides/team-memory.mdx b/mintlify/guides/team-memory.mdx index 53120a5..473caae 100644 --- a/mintlify/guides/team-memory.mdx +++ b/mintlify/guides/team-memory.mdx @@ -37,6 +37,7 @@ property-tested to be commutative, associative, and idempotent — so two teamma ledgers converge to the same state no matter who syncs first. ```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR A["your ledger"] <-->|"git union-merge · conflict-free"| B["teammate ledger"] A --> M["merged read view"] diff --git a/mintlify/guides/zero-config-onboarding.mdx b/mintlify/guides/zero-config-onboarding.mdx index 0937ca7..635d922 100644 --- a/mintlify/guides/zero-config-onboarding.mdx +++ b/mintlify/guides/zero-config-onboarding.mdx @@ -9,6 +9,7 @@ paying off on day two. (It is low-configuration, not zero-configuration: you sti the CLI, run `forge init` in each repo, and some paths assume Bash, Git, and `jq`.) ```mermaid +%%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart TD I["forge init"] --> Cfg["every tool configured from one source"] Cfg --> Work["you work as usual"] diff --git a/src/docs_check.js b/src/docs_check.js index 102a1f8..ad1ebf2 100644 --- a/src/docs_check.js +++ b/src/docs_check.js @@ -184,12 +184,17 @@ function checkMcpTools(docs, issues) { /** Every tracked Markdown file, so diagram checks cover the WHOLE doc set — not just the * four prose docs. Falls back to a recursive walk when git is unavailable (tmp fixtures). */ function markdownFiles(root) { - const tracked = git(root, ["ls-files", "*.md"]); + const tracked = git(root, ["ls-files", "*.md", "*.mdx"]); if (tracked) return tracked.split("\n").filter(Boolean); if (!existsSync(root)) return []; return readdirSync(root, { recursive: true }) .map(String) - .filter((f) => f.endsWith(".md") && !f.includes("node_modules") && !f.startsWith(".git/")); + .filter( + (f) => + (f.endsWith(".md") || f.endsWith(".mdx")) && + !f.includes("node_modules") && + !f.startsWith(".git/"), + ); } // The branded Mermaid theme every diagram shares (see README's `%%{init …}%%`). Without it diff --git a/src/docs_render.js b/src/docs_render.js index 57c3e6f..5bcec78 100644 --- a/src/docs_render.js +++ b/src/docs_render.js @@ -150,11 +150,12 @@ const INIT_LINE_RE = /%%\{init[\s\S]*?\}%%/; /** Normalize every mermaid block's `%%{init` line to the one shared brand theme. * Blocks opted out with `docs-check-ignore` (deliberate bad examples) are untouched; - * blocks with no init line are left for docs_check to flag. */ + * blocks missing an init line get one prepended. */ export function normalizeMermaid(text) { return text.replace(MERMAID_BLOCK_RE, (block, body, offset) => { if (/docs-check-ignore/.test(text.slice(Math.max(0, offset - 80), offset))) return block; - if (!INIT_LINE_RE.test(body)) return block; + if (!INIT_LINE_RE.test(body)) + return block.replace("```mermaid\n", `\`\`\`mermaid\n${mermaidInit()}\n`); return block.replace(INIT_LINE_RE, mermaidInit()); }); } @@ -200,7 +201,7 @@ const COUNT_FILES = [ /** Every git-tracked markdown file (mermaid theme normalization scope). */ function trackedMarkdown(root) { - const out = git(root, ["ls-files", "*.md"]); + const out = git(root, ["ls-files", "*.md", "*.mdx"]); return out ? out.split("\n").filter(Boolean) : []; } diff --git a/test/docs_render.test.js b/test/docs_render.test.js index 92d00f0..eaf2928 100644 --- a/test/docs_render.test.js +++ b/test/docs_render.test.js @@ -64,7 +64,7 @@ test("updateCounts rewrites any stale N-MCP-tools phrase to the registry count", ); }); -test("normalizeMermaid unifies init lines, skips ignored examples and init-less blocks", () => { +test("normalizeMermaid unifies init lines, injects missing ones, skips ignored examples", () => { const old = "%%{init: {'theme':'base','themeVariables':{'lineColor':'#f26430','tertiaryColor':'#171310'}}}%%"; const themed = `\`\`\`mermaid\n${old}\nflowchart LR\n a --> b\n\`\`\``; @@ -75,7 +75,9 @@ test("normalizeMermaid unifies init lines, skips ignored examples and init-less const ignored = `\n\`\`\`mermaid\n${old}\nflowchart LR\n a --> b\n\`\`\``; assert.equal(normalizeMermaid(ignored), ignored, "opted-out example blocks stay untouched"); const bare = "```mermaid\nflowchart LR\n a --> b\n```"; - assert.equal(normalizeMermaid(bare), bare, "no init line → left for the diagram check to flag"); + const normalized = normalizeMermaid(bare); + assert.ok(normalized.includes(mermaidInit()), "bare blocks get the branded init line injected"); + assert.ok(normalized.includes("flowchart LR"), "original content preserved after injection"); }); test("renderRepoMap draws directories and import edges from the real tree", () => { From 29532442ede6959c1be1c127f14ad9d540dec8a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 15:31:00 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(impact):=20hazard-aware=20blast=20radi?= =?UTF-8?q?us=20=E2=80=94=20SCC-aware=20propagation=20+=20data-driven=20th?= =?UTF-8?q?reshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded impact constants with data-driven enhancements that fuse the code graph with team memory: - SCC-aware propagation: when a file in a circular-dependency cluster is impacted, all co-members are impacted at the same confidence (via Tarjan SCC from rank.cycles()). A change to any file in a cycle effectively changes them all — ignoring this was the biggest source of false negatives. - Hazard-adjusted threshold: files that are both structurally critical AND have historically broken get a lower inclusion threshold: effectiveThreshold = base / (1 + hazard), where hazard is derived from PageRank centrality and ledger incident history. No new constants — every enhancement is computed from existing infrastructure. - buildSccIndex() helper in atlas.js, loadRankData() assembler in substrate.js, wired through cli.js (--basic flag), cortex_mcp.js, and imagine.js. consensus.js and gate.js left on basic impact (hot path). - 4 new tests: SCC propagation, hazard threshold, combined, fail-open backwards compatibility. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01LXmzxfRVDRVPU6LG8W39Rz --- ARCHITECTURE.md | 6 +- CHANGELOG.md | 10 +++ README.md | 2 +- docs/GUIDE.md | 8 +- mintlify/cli/substrate.mdx | 6 +- src/atlas.js | 147 +++++++++++++++++++++++++++++------ src/cli.js | 9 ++- src/commands.js | 18 ++++- src/imagine.js | 5 +- src/substrate.js | 153 +++++++++++++++++++++++++++++-------- test/atlas.test.js | 103 ++++++++++++++++++++++++- 11 files changed, 399 insertions(+), 68 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e775507..c50d304 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -111,7 +111,11 @@ flowchart TD **blast radius** — the set of files an edit is predicted to impact, read from the code graph. `forge impact` computes it; the pipeline surfaces it before the model touches -anything. +anything. The analysis is **hazard-aware**: SCC-aware propagation (a change to any file +in a circular-dependency cluster impacts all co-members, via Tarjan from `forge rank`) +and a data-driven threshold derived from PageRank centrality and ledger incident history +(`effectiveThreshold = base / (1 + hazard)`). `--basic` reverts to the fixed-threshold +mode. The verdict is **advisory by default** — it reports, it does not block. Set `FORGE_ENFORCE=1` to turn the strongest signals into a hard block: diff --git a/CHANGELOG.md b/CHANGELOG.md index 80fa9b8..3aef228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **impact: hazard-aware blast radius.** `forge impact` now fuses the code graph with + team memory: SCC-aware propagation (from `forge rank` Tarjan cycles — a change to any + file in a circular-dependency cluster impacts all co-members) and a data-driven + threshold derived from PageRank centrality and ledger incident history + (`effectiveThreshold = base / (1 + hazard)`). No new constants — every enhancement is + computed from infrastructure already in the codebase. `--basic` flag reverts to the + fixed-threshold mode for comparison. + ### Fixed - **docs: refresh post-v0.30.0 staleness.** ARCHITECTURE.md repo layout and component diff --git a/README.md b/README.md index 53ddf29..ef18ec1 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ that never clobbers your existing settings (skip it with `install.sh --no-settin | | `forge config` | provider setup — show / switch / add providers, set default model | | **Substrate** | `forge substrate` | one pre-action gate: assumptions, route, impact, scope, memory, verify | | | `forge preflight` | assumption check — what a task names that the repo doesn't define | -| | `forge impact` | predict blast radius for a symbol or file from the atlas graph | +| | `forge impact` | hazard-aware blast radius — SCC-aware propagation + data-driven threshold from PageRank centrality and ledger incident history | | | `forge scope` | decompose files into independent clusters (+ coupled files you didn't name) | | | `forge context` | budgeted context assembly + completeness gate — what an edit NEEDS known | | | `forge route` | recommend the cheapest capable model for a task (+ gateway config) | diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8fa2e7b..526b6a6 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -270,11 +270,15 @@ and pin explicit IDs if a family scored wrong. ### `forge impact ` — what will this edit break? -Reverse-dependency blast radius from the atlas graph. Run `forge atlas build` first. +Hazard-aware blast radius from the atlas graph, fused with team memory: +SCC-aware propagation (a change to any file in a circular-dependency cluster +impacts all co-members) and a data-driven threshold from PageRank centrality +and ledger incident history. `--basic` reverts to the fixed-threshold mode. +Run `forge atlas build` first. ```console $ forge impact verifyToken -Forge impact — blast radius +Forge impact — blast radius (hazard-aware) target: verifyToken ✓ found impacted files: 3 diff --git a/mintlify/cli/substrate.mdx b/mintlify/cli/substrate.mdx index 184afee..f399e3d 100644 --- a/mintlify/cli/substrate.mdx +++ b/mintlify/cli/substrate.mdx @@ -37,10 +37,12 @@ forge route gateway # emit LiteLLM gateway config ## `forge impact` -Predict the blast radius for a symbol or file from the atlas graph. +Hazard-aware blast radius — SCC-aware propagation and data-driven threshold from +PageRank centrality and ledger incident history. `--basic` reverts to fixed-threshold +mode. ```bash -forge impact +forge impact [--json] [--basic] ``` ## `forge collide` diff --git a/src/atlas.js b/src/atlas.js index 0cdb09d..b484d1e 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -1,7 +1,14 @@ // forge atlas — a portable code graph. Build once, then query definitions, membership, // reverse dependents, and impact radius without asking a model to rediscover the repo. -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; import { extname, join, relative } from "node:path"; import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js"; import { CALL_RE } from "./extract.js"; @@ -103,7 +110,8 @@ export const RULES = { }; // Kotlin script, C/C++ family, and PHP siblings share the grammar above. RULES[".kts"] = RULES[".kt"]; -for (const ext of [".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh"]) RULES[ext] = RULES[".c"]; +for (const ext of [".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh"]) + RULES[ext] = RULES[".c"]; // Documentation extensions — first-class in the walk, extracted by extractDoc() (a // doc node + `references` edges to the code it names), never by the symbol RULES. @@ -181,13 +189,16 @@ function walk(dir, files, cap) { entries = readdirSync(dir); } catch (err) { if (process.env.FORGE_DEBUG === "1") - process.stderr.write(`forge atlas: skipping ${dir}: ${err?.message ?? err}\n`); + process.stderr.write( + `forge atlas: skipping ${dir}: ${err?.message ?? err}\n`, + ); return; } for (const name of entries) { // Dot-entries stay out of the graph — except `.github`, whose workflows are config // artifacts that name code paths (a CI file IS a dependent of the code it runs). - if (IGNORE_DIRS.has(name) || (name.startsWith(".") && name !== ".github")) continue; + if (IGNORE_DIRS.has(name) || (name.startsWith(".") && name !== ".github")) + continue; const path = join(dir, name); let st; try { @@ -258,7 +269,8 @@ function extractDoc(rel, text) { for (const m of text.matchAll(/\]\(([^)#\s]+)\)/g)) { const tok = m[1].replace(/^\.\//, ""); if (/^[a-z]+:/i.test(tok)) continue; // external URL, not a repo path - if (RULES[extname(tok)]) refEdge(`module:${moduleId(tok)}`, 0.8, lineOf(text, m.index)); + if (RULES[extname(tok)]) + refEdge(`module:${moduleId(tok)}`, 0.8, lineOf(text, m.index)); } return { symbols: [], nodes: [doc], edges, hash: hash(text) }; } @@ -277,7 +289,9 @@ function extractConfig(rel, text) { }; const edges = []; const seen = new Set(); - for (const m of text.matchAll(/[A-Za-z0-9_.@-]+(?:[/\\][A-Za-z0-9_.@-]+)*/g)) { + for (const m of text.matchAll( + /[A-Za-z0-9_.@-]+(?:[/\\][A-Za-z0-9_.@-]+)*/g, + )) { const tok = m[0].replace(/^\.\//, ""); if (!RULES[extname(tok)]) continue; // only path-like tokens ending in a code extension const target = `module:${moduleId(tok)}`; @@ -309,7 +323,8 @@ function extractFile(path, root, preRead) { } } if (DOC_EXTS.has(ext)) return extractDoc(rel, text); - if (isConfigFile(rel.split(/[/\\]/).pop() || "")) return extractConfig(rel, text); + if (isConfigFile(rel.split(/[/\\]/).pop() || "")) + return extractConfig(rel, text); const mod = { id: `module:${moduleId(rel)}`, @@ -358,7 +373,9 @@ function extractFile(path, root, preRead) { // Inheritance edges — `class X extends Y` (JS/TS) and `class X(Base, …)` (Python). Without // these the `inherits` edge weight was dead and a base-class change never appeared in blast // radius. The base is a bare name; resolveEdges links it to a real node if one exists. - const classNodes = new Map(nodes.filter((n) => n.kind === "class").map((n) => [n.name, n])); + const classNodes = new Map( + nodes.filter((n) => n.kind === "class").map((n) => [n.name, n]), + ); const INHERIT_RES = [ /\bclass\s+([A-Za-z_$][\w$]*)\s+extends\s+([A-Za-z_$][\w$.]*)/g, // JS/TS /^\s*class\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/gm, // Python @@ -478,7 +495,9 @@ const cachePath = (root) => join(root, ".forge", "atlas.cache.json"); function readCache(root) { try { - return existsSync(cachePath(root)) ? JSON.parse(readFileSync(cachePath(root), "utf8")) : {}; + return existsSync(cachePath(root)) + ? JSON.parse(readFileSync(cachePath(root), "utf8")) + : {}; } catch { return {}; } @@ -507,7 +526,9 @@ export function build({ root = process.cwd(), cap = 20000 } = {}) { const reused = prev[rel]?.hash === h ? prev[rel].data : null; const data = reused || - (({ symbols, nodes, edges }) => ({ symbols, nodes, edges }))(extractFile(f, root, text)); + (({ symbols, nodes, edges }) => ({ symbols, nodes, edges }))( + extractFile(f, root, text), + ); cache[rel] = { hash: h, data }; symbols.push(...data.symbols); nodes.push(...data.nodes); @@ -554,7 +575,8 @@ export function isStale(root, atlas) { const current = []; walk(root, current, 20000); if (current.length !== indexed.size) return true; // a file was added or removed - for (const p of current) if (!indexed.has(toPosix(relative(root, p)))) return true; + for (const p of current) + if (!indexed.has(toPosix(relative(root, p)))) return true; } return false; } @@ -576,14 +598,21 @@ export function query(atlas, term) { } export function has(atlas, name) { - return (atlas.symbols || []).some((s) => s.name === name || s.qname === name || s.id === name); + return (atlas.symbols || []).some( + (s) => s.name === name || s.qname === name || s.id === name, + ); } function targetIds(atlas, target) { const t = String(target); const nodes = atlas.nodes || []; const matches = nodes.filter( - (n) => n.id === t || n.name === t || n.qname === t || n.file === t || n.file?.endsWith(`/${t}`), + (n) => + n.id === t || + n.name === t || + n.qname === t || + n.file === t || + n.file?.endsWith(`/${t}`), ); return matches.map((n) => n.id); } @@ -623,7 +652,9 @@ function adjacency(atlas) { // verified twice — it must resolve to a REAL node in the graph AND (via the caller's `verify` // predicate, a grep) actually reference the target in source. Unverifiable → dropped, never added. export function buildImpactPrompt(atlas, target) { - const files = [...new Set((atlas.nodes || []).map((n) => n.file).filter(Boolean))].slice(0, 60); + const files = [ + ...new Set((atlas.nodes || []).map((n) => n.file).filter(Boolean)), + ].slice(0, 60); return `A code symbol/file is about to change. Name the OTHER files in this repo that most likely break or depend on it through edges a regex misses: dynamic dispatch, dependency injection, reflection, string-keyed registries, event handlers. @@ -637,7 +668,10 @@ No text outside the JSON object.`; export function parseImpactProposal(obj) { const files = Array.isArray(obj.files) - ? [...new Set(obj.files.map((f) => asText(f, 240)).filter(Boolean))].slice(0, 20) + ? [...new Set(obj.files.map((f) => asText(f, 240)).filter(Boolean))].slice( + 0, + 20, + ) : []; return { files }; } @@ -650,6 +684,19 @@ export function impactLLM(atlas, target, { run = buildRunner() } = {}) { }); } +/** + * Build a file → SCC-id index from the output of rank.cycles(). Files in the + * same SCC share an id; files not in any cycle are absent from the map. + * @param {string[][]} sccs each entry is a sorted list of files in one SCC + * @returns {Map} + */ +export function buildSccIndex(sccs) { + const index = new Map(); + for (let i = 0; i < sccs.length; i++) + for (const file of sccs[i]) index.set(file, i); + return index; +} + /** * @param {object} atlas * @param {string} target @@ -660,11 +707,22 @@ export function impactLLM(atlas, target, { run = buildRunner() } = {}) { * @param {boolean} [opts.llm] * @param {(p:string)=>string} [opts.run] * @param {(file:string, target:string)=>boolean} [opts.verify] + * @param {Map} [opts.sccIndex] file-to-SCC-id (from buildSccIndex) + * @param {Map} [opts.hazards] file-to-hazard-score (from rankReport) */ export function impact( atlas, target, - { threshold = 0.1, maxHops = 6, decay = 0.85, llm, run, verify } = {}, + { + threshold = 0.1, + maxHops = 6, + decay = 0.85, + llm, + run, + verify, + sccIndex, + hazards, + } = {}, ) { const starts = targetIds(atlas, target); const startSet = new Set(starts); @@ -690,13 +748,22 @@ export function impact( for (const edge of incoming.get(current.id) || []) { if (startSet.has(edge.source)) continue; const nextConfidence = - current.confidence * (EDGE_WEIGHT[edge.kind] || 0.5) * (edge.confidence ?? 1) * decay; - if (nextConfidence < threshold) continue; + current.confidence * + (EDGE_WEIGHT[edge.kind] || 0.5) * + (edge.confidence ?? 1) * + decay; + const srcNode = nodeById.get(edge.source); + const srcFile = srcNode?.file; + const effectiveThreshold = + hazards && srcFile && hazards.has(srcFile) + ? threshold / (1 + hazards.get(srcFile)) + : threshold; + if (nextConfidence < effectiveThreshold) continue; const prev = visited.get(edge.source); if (prev && prev.confidence >= nextConfidence) continue; const item = { id: edge.source, - node: nodeById.get(edge.source) || { + node: srcNode || { id: edge.source, name: edge.source, kind: "unknown", @@ -714,15 +781,47 @@ export function impact( path: item.path, edgeKinds: item.edgeKinds, }); + if (sccIndex && srcFile != null && sccIndex.has(srcFile)) { + const sccId = sccIndex.get(srcFile); + for (const node of atlas.nodes || []) { + if (node.file === srcFile || !sccIndex.has(node.file)) continue; + if (sccIndex.get(node.file) !== sccId) continue; + if (startSet.has(node.id)) continue; + const prevScc = visited.get(node.id); + if (prevScc && prevScc.confidence >= nextConfidence) continue; + const sccItem = { + id: node.id, + node, + confidence: Number(nextConfidence.toFixed(4)), + hopDistance: current.hop + 1, + path: [...current.path, edge.source, node.id], + edgeKinds: [...current.edgeKinds, edge.kind, "scc"], + }; + visited.set(node.id, sccItem); + queue.push({ + id: node.id, + confidence: nextConfidence, + hop: current.hop + 1, + path: sccItem.path, + edgeKinds: sccItem.edgeKinds, + }); + } + } } } - const impacted = [...visited.values()].sort((a, b) => b.confidence - a.confidence); - const deterministicFiles = new Set(impacted.map((x) => x.node.file).filter(Boolean)); + const impacted = [...visited.values()].sort( + (a, b) => b.confidence - a.confidence, + ); + const deterministicFiles = new Set( + impacted.map((x) => x.node.file).filter(Boolean), + ); // Opt-in imagination pass: model proposes missed edges, but only VERIFIED ones are kept. const llmImpacted = []; if (llmEnabled({ llm }) && run) { - const knownFiles = new Set((atlas.nodes || []).map((n) => n.file).filter(Boolean)); + const knownFiles = new Set( + (atlas.nodes || []).map((n) => n.file).filter(Boolean), + ); const proposal = impactLLM(atlas, target, { run }); for (const file of proposal?.files || []) { if (deterministicFiles.has(file)) continue; // already found deterministically @@ -745,7 +844,9 @@ export function impact( found: starts.length > 0, threshold, impacted: all, - impactedFiles: [...new Set(all.map((x) => x.node.file).filter(Boolean))].sort(), + impactedFiles: [ + ...new Set(all.map((x) => x.node.file).filter(Boolean)), + ].sort(), llmVerified: llmImpacted.map((x) => x.node.file), totalGraphNodes: (atlas.nodes || []).length, totalGraphEdges: (atlas.edges || []).length, diff --git a/src/cli.js b/src/cli.js index 6fb3c29..368b8f3 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1686,21 +1686,22 @@ HANDLERS.preflight = async (argv) => { HANDLERS.impact = async (argv) => { const { predictImpact } = await import("./substrate.js"); const json = argv.includes("--json"); + const basic = argv.includes("--basic"); const target = argv .slice(1) - .filter((a) => a !== "--json") + .filter((a) => a !== "--json" && a !== "--basic") .join(" "); if (!target) { - console.error("usage: forge impact [--json]"); + console.error("usage: forge impact [--json] [--basic]"); process.exitCode = 1; return; } - const r = predictImpact(process.cwd(), target); + const r = predictImpact(process.cwd(), target, { basic }); if (json) { console.log(JSON.stringify(r, null, 2)); return; } - heading(`${BRAND.brand} impact — blast radius\n`); + heading(`${BRAND.brand} impact — blast radius${basic ? "" : " (hazard-aware)"}\n`); console.log(` target: ${target} ${r.found ? "✓ found" : "not found"}`); console.log(` impacted files: ${r.impactedFiles.length}`); for (const file of r.impactedFiles.slice(0, 20)) console.log(` - ${file}`); diff --git a/src/commands.js b/src/commands.js index 7f2ee02..217aa76 100644 --- a/src/commands.js +++ b/src/commands.js @@ -93,7 +93,23 @@ export const COMMANDS = { preflight: "assumption check — what a task names that the repo doesn't define", config: "provider setup — show / switch / add providers, set default model", route: "recommend the cheapest capable model for a task (+ gateway config)", - impact: "predict blast radius for a symbol or file from the atlas graph", + impact: { + summary: + "hazard-aware blast radius — SCC-aware propagation + data-driven threshold from PageRank centrality and ledger incident history", + usage: "forge impact [--json] [--basic]", + flags: [ + { flag: "--json", desc: "machine-readable report" }, + { + flag: "--basic", + desc: "skip hazard-aware enhancements (fixed threshold, no SCC expansion)", + }, + ], + examples: [ + "forge impact src/atlas.js", + "forge impact computeTax --json", + "forge impact src/val.js --basic", + ], + }, collide: { summary: "parallel-session conflict radar — who else recently touched the files (or their import neighbors) you are editing", diff --git a/src/imagine.js b/src/imagine.js index 2f787a6..a36be42 100644 --- a/src/imagine.js +++ b/src/imagine.js @@ -13,7 +13,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { build as buildAtlas, impact, load as loadAtlas } from "./atlas.js"; import { referencedEntities } from "./preflight.js"; -import { isTestFile, predictFailingTests } from "./substrate.js"; +import { isTestFile, predictFailingTests, loadRankData } from "./substrate.js"; import { hasBin, toPosix } from "./util.js"; /** @@ -282,7 +282,8 @@ export function imagineTask(root, task, { atlas, threshold = 0.1 } = {}) { const graph = atlas || loadAtlas(root) || buildAtlas({ root }); const entities = referencedEntities(task); const targets = [...new Set([...entities.symbols, ...entities.files])].slice(0, 8); - const reports = targets.map((t) => impact(graph, t, { threshold })); + const { sccIndex, hazards } = loadRankData(root); + const reports = targets.map((t) => impact(graph, t, { threshold, sccIndex, hazards })); const byFile = new Map(); for (const r of reports) { for (const x of r.impacted) { diff --git a/src/substrate.js b/src/substrate.js index 6bb8ee7..c3c2d90 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -10,6 +10,7 @@ import { goalDrift } from "./anchor.js"; import { isStale as atlasIsStale, build as buildAtlas, + buildSccIndex, impact as impactGraph, load as loadAtlas, } from "./atlas.js"; @@ -18,15 +19,24 @@ import { matchingLessons } from "./cortex.js"; import { recordGate } from "./cost_report.js"; import { leanRepo } from "./lean.js"; import { mergedLessons } from "./ledger_read.js"; -import { clarifyBlock, preflightRepo, referencedEntities } from "./preflight.js"; +import { + clarifyBlock, + preflightRepo, + referencedEntities, +} from "./preflight.js"; import { reusePeek, reuseQuery } from "./reuse.js"; import { meterRoute, routeTask } from "./route.js"; +import { rankReport } from "./rank.js"; import { decompose } from "./scope.js"; import { detectStack } from "./stack.js"; import { epochDay } from "./util.js"; function loadSubstrateSpec() { - const path = join(dirname(dirname(fileURLToPath(import.meta.url))), "source", "substrate.json"); + const path = join( + dirname(dirname(fileURLToPath(import.meta.url))), + "source", + "substrate.json", + ); try { return JSON.parse(readFileSync(path, "utf8")); } catch { @@ -55,7 +65,9 @@ function verificationChecklist(root) { // complexity. No second keyword copy here to drift out of sync with those sources. function minimalityWarnings(_task, route, preflight) { const warnings = []; - const missing = new Set((preflight.assumption?.missing ?? []).map((m) => m.key)); + const missing = new Set( + (preflight.assumption?.missing ?? []).map((m) => m.key), + ); if (missing.has("target_scope") && preflight.entities.files.length === 0) { warnings.push( "High-risk broad change with no target files named; ask for scope before editing.", @@ -93,7 +105,11 @@ function siblingTestCandidates(file) { const base = dot > 0 ? nameExt.slice(0, dot) : nameExt; const ext = dot > 0 ? nameExt.slice(dot) : ""; if (ext === ".py") - return [`${dir}test_${base}.py`, `${dir}tests/test_${base}.py`, `tests/test_${base}.py`]; + return [ + `${dir}test_${base}.py`, + `${dir}tests/test_${base}.py`, + `tests/test_${base}.py`, + ]; const out = []; for (const suf of [".test", ".spec"]) out.push(`${dir}${base}${suf}${ext}`); for (const d of ["__tests__/", "test/", "tests/"]) @@ -109,7 +125,8 @@ export function predictFailingTests(root, impactedFiles) { out.add(f); continue; } - for (const c of siblingTestCandidates(f)) if (existsSync(join(root, c))) out.add(c); + for (const c of siblingTestCandidates(f)) + if (existsSync(join(root, c))) out.add(c); } return [...out].sort(); } @@ -126,13 +143,39 @@ function makeImpactVerify(root) { try { const src = readFileSync(join(root, file), "utf8"); const name = base(target); - return name.length > 1 && new RegExp(`\\b${name.replace(/[^\w$]/g, "")}\\b`).test(src); + return ( + name.length > 1 && + new RegExp(`\\b${name.replace(/[^\w$]/g, "")}\\b`).test(src) + ); } catch { return false; } }; } +/** + * Load rank data (SCC index + per-file hazard scores) for enhanced impact analysis. + * Fail-open: if rank.js is unavailable or the data can't be computed, returns nulls + * and impact() falls back to basic mode transparently. + * @param {string} root + * @returns {{sccIndex: Map|undefined, hazards: Map|undefined}} + */ +export function loadRankData(root) { + try { + const report = rankReport(root); + if (!report.built) return { sccIndex: undefined, hazards: undefined }; + const sccIndex = report.cycles?.length + ? buildSccIndex(report.cycles) + : undefined; + const hazards = report.topFiles?.length + ? new Map(report.topFiles.map((f) => [f.file, f.hazard])) + : undefined; + return { sccIndex, hazards }; + } catch { + return { sccIndex: undefined, hazards: undefined }; + } +} + /** * @param {string} root * @param {string} target @@ -141,18 +184,25 @@ function makeImpactVerify(root) { * @param {boolean} [opts.llm] * @param {string} [opts.model] * @param {number} [opts.timeoutMs] + * @param {boolean} [opts.basic] skip hazard-aware enhancements */ -export function predictImpact(root, target, { threshold = 0.1, llm, model, timeoutMs } = {}) { - // Rebuild when the cached atlas is stale (or missing) — a stale graph misses brand-new - // files/edges and would under-report impact. The incremental build only re-parses what changed. +export function predictImpact( + root, + target, + { threshold = 0.1, llm, model, timeoutMs, basic } = {}, +) { const cached = loadAtlas(root); - const atlas = cached && !atlasIsStale(root, cached) ? cached : buildAtlas({ root }); + const atlas = + cached && !atlasIsStale(root, cached) ? cached : buildAtlas({ root }); const useLLM = llmEnabled({ llm }); + const rankData = basic ? {} : loadRankData(root); return impactGraph(atlas, target, { threshold, llm: useLLM, run: useLLM ? buildRunner({ model, timeoutMs }) : undefined, verify: makeImpactVerify(root), + sccIndex: rankData.sccIndex, + hazards: rankData.hazards, }); } @@ -195,7 +245,9 @@ export function substrateCheck( // Bidirectional (clear-a-false-ask / route-down, within rails) follows the JSON default unless // the caller overrides it. The numeric bands/floor come from the same config block. const bi = - typeof bidirectional === "boolean" ? bidirectional : (spec?.llm?.bidirectional ?? true); + typeof bidirectional === "boolean" + ? bidirectional + : (spec?.llm?.bidirectional ?? true); const llmOpts = { llm: useLLM, model, @@ -249,7 +301,9 @@ export function substrateCheck( } else { atlasFresh = false; // no atlas and can't build } - const impactTargets = [...new Set([...entities.symbols, ...entities.files])].slice(0, 8); + const impactTargets = [ + ...new Set([...entities.symbols, ...entities.files]), + ].slice(0, 8); const impactRun = useLLM ? buildRunner({ model, timeoutMs }) : undefined; const impactVerify = makeImpactVerify(root); const impacts = atlas @@ -262,20 +316,26 @@ export function substrateCheck( }), ) : []; - const impactedFiles = [...new Set(impacts.flatMap((r) => r.impactedFiles || []))].sort(); + const impactedFiles = [ + ...new Set(impacts.flatMap((r) => r.impactedFiles || [])), + ].sort(); // Consequence simulation (Eq 4), class "failing tests": which tests likely break if the // impacted files change — the impacted files that ARE tests, plus each impacted source file's // sibling test. Cheap, exact-ish, and surfaced BEFORE the edit (not after, like verify). // Gated on atlas freshness (belt and braces with the null atlas above): predictions from a // stale graph are not trustworthy and must not be presented as consequence evidence. - const predictedTests = atlasFresh ? predictFailingTests(root, impactedFiles) : []; + const predictedTests = atlasFresh + ? predictFailingTests(root, impactedFiles) + : []; // P3 reuse stage: has this team already built (and verified) this? The explicit gate // meters + writes evidence (reuseQuery); the ambient hook path stays read-only // (reusePeek) so a per-prompt hook never appends to the ledger or metrics. const reuse = (() => { try { const opts = { atlas, nowDay: epochDay() }; - const r = allowBuild ? reuseQuery(root, text, opts) : reusePeek(root, text, opts); + const r = allowBuild + ? reuseQuery(root, text, opts) + : reusePeek(root, text, opts); return { tier: r.tier, artifact: r.artifact @@ -336,7 +396,9 @@ export function substrateCheck( // Truthful freshness: false when the atlas is missing/stale and couldn't be rebuilt. // Consumers must not present impactedFiles as trustworthy when this is false. atlasFresh, - ...(atlasFresh ? {} : { note: "impact unavailable: atlas missing or stale" }), + ...(atlasFresh + ? {} + : { note: "impact unavailable: atlas missing or stale" }), }, scope, memory: { @@ -352,7 +414,9 @@ export function substrateCheck( // never asked for. `lean` is diff-based, so it's quiet until there's something to measure. minimality: (() => { const pre = minimalityWarnings(text, route, preflight); - const lean = allowBuild ? leanRepo(root, text) : { warnings: [], footprint: null }; + const lean = allowBuild + ? leanRepo(root, text) + : { warnings: [], footprint: null }; return { warnings: [...pre, ...lean.warnings], footprint: lean.footprint, @@ -403,7 +467,8 @@ export function substrateCheck( ], }, }; - result.llm.provenance.goalAnchor = result.goalAnchor?.provenance?.path ?? "deterministic"; + result.llm.provenance.goalAnchor = + result.goalAnchor?.provenance?.path ?? "deterministic"; return result; } @@ -419,11 +484,14 @@ export function substrateCheck( * @param {number} [opts.blastThreshold] */ export function enforceDecision(result, { enforce, blastThreshold = 25 } = {}) { - const on = typeof enforce === "boolean" ? enforce : process.env.FORGE_ENFORCE === "1"; + const on = + typeof enforce === "boolean" ? enforce : process.env.FORGE_ENFORCE === "1"; if (!on || !result) return { block: false }; const tail = "\n(Set FORGE_ENFORCE=0 to make Forge advisory again.)"; if (result.assumption?.hardUnderspecified) { - const qs = (result.assumption.questions || []).map((q) => ` • ${q}`).join("\n"); + const qs = (result.assumption.questions || []) + .map((q) => ` • ${q}`) + .join("\n"); return { block: true, reason: `Forge gate (enforcing): this task has no concrete anchor to act on — clarify before I start:\n${qs}${tail}`, @@ -467,7 +535,8 @@ export function renderSubstrate(result) { "", ` route: ${result.route.model.name} (${result.route.tier}) · complexity ${result.route.score.toFixed(2)}`, ); - if (result.route.reasons.length) lines.push(` driven by: ${result.route.reasons.join(", ")}`); + if (result.route.reasons.length) + lines.push(` driven by: ${result.route.reasons.join(", ")}`); if (result.reuse && result.reuse.tier !== "miss") { const a = result.reuse.artifact; lines.push( @@ -483,18 +552,31 @@ export function renderSubstrate(result) { for (const q of result.context.questions ?? []) lines.push(` ? ${q}`); } if (result.impact.atlasFresh === false) { - lines.push("", " impact: unavailable — atlas missing or stale (predictions not trustworthy)"); + lines.push( + "", + " impact: unavailable — atlas missing or stale (predictions not trustworthy)", + ); } else { - lines.push("", ` impact: ${result.impact.impactedFiles.length} file(s) predicted`); - for (const file of result.impact.impactedFiles.slice(0, 10)) lines.push(` - ${file}`); + lines.push( + "", + ` impact: ${result.impact.impactedFiles.length} file(s) predicted`, + ); + for (const file of result.impact.impactedFiles.slice(0, 10)) + lines.push(` - ${file}`); if (result.impact.impactedFiles.length > 10) lines.push(` … ${result.impact.impactedFiles.length - 10} more`); } // Predicted tests only speak for a FRESH atlas — right after an "impact: unavailable" // notice, a likely-affected-tests list would contradict it with stale data (RA-07). - const tests = result.impact.atlasFresh === false ? [] : result.impact.predictedTests || []; + const tests = + result.impact.atlasFresh === false + ? [] + : result.impact.predictedTests || []; if (tests.length) { - lines.push("", ` likely-affected tests (${tests.length}) — run these first:`); + lines.push( + "", + ` likely-affected tests (${tests.length}) — run these first:`, + ); for (const t of tests.slice(0, 8)) lines.push(` - ${t}`); } if (result.minimality.warnings.length) { @@ -506,7 +588,8 @@ export function renderSubstrate(result) { "", ` goal drift: ${result.goalAnchor.offGoal.length} changed file(s) off the stated goal:`, ); - for (const f of result.goalAnchor.offGoal.slice(0, 8)) lines.push(` - ${f}`); + for (const f of result.goalAnchor.offGoal.slice(0, 8)) + lines.push(` - ${f}`); } lines.push("", " verify:"); for (const c of result.verification.checklist) lines.push(` - ${c}`); @@ -526,7 +609,9 @@ export function substrateContext(result) { result.goalAnchor?.drift || ["opus", "fable"].includes(result.route.key); if (!worthSaying) return ""; - const lines = ["Forge substrate — pre-action advisory (advisory, never blocks):"]; + const lines = [ + "Forge substrate — pre-action advisory (advisory, never blocks):", + ]; if (result.assumption.shouldAsk) { lines.push( `- Under-specified (${result.assumption.risk} risk). Ask before editing:`, @@ -547,7 +632,10 @@ export function substrateContext(result) { ); } // Same freshness rule as the renderer: never advise stale test predictions (RA-07). - const predTests = result.impact.atlasFresh === false ? [] : result.impact.predictedTests || []; + const predTests = + result.impact.atlasFresh === false + ? [] + : result.impact.predictedTests || []; if (predTests.length) lines.push( `- Likely-affected tests (${predTests.length}): ${predTests.slice(0, 6).join(", ")}${predTests.length > 6 ? " …" : ""}. Run these first.`, @@ -558,12 +646,15 @@ export function substrateContext(result) { `- Goal drift: ${result.goalAnchor.offGoal.length} changed file(s) off the stated goal (${result.goalAnchor.offGoal.slice(0, 5).join(", ")}). Intended, or wandering?`, ); if (result.memory.matchingLessons) - lines.push(`- ${result.memory.matchingLessons} past lesson(s) match this area (advisory).`); + lines.push( + `- ${result.memory.matchingLessons} past lesson(s) match this area (advisory).`, + ); // I3: proceeding is fine below the ask-threshold, but never SILENTLY — the gaps are // named here and recorded to the session log (the handoff surfaces them later). if ( !result.assumption.shouldAsk && - ((result.assumption.missing?.length ?? 0) > 0 || result.assumption.questions?.length > 0) + ((result.assumption.missing?.length ?? 0) > 0 || + result.assumption.questions?.length > 0) ) { const keys = (result.assumption.missing ?? []).map((m) => m.key); lines.push( diff --git a/test/atlas.test.js b/test/atlas.test.js index 808aa09..b7260ab 100644 --- a/test/atlas.test.js +++ b/test/atlas.test.js @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { build, has, impact, isStale, load, query } from "../src/atlas.js"; +import { build, buildSccIndex, has, impact, isStale, load, query } from "../src/atlas.js"; function fixture() { const root = mkdtempSync(join(tmpdir(), "forge-atlas-")); @@ -268,3 +268,104 @@ test("impact takes the max-product path through a diamond, not the first-found o assert.equal(d.confidence, 0.6521, "max-product confidence wins over first-found"); assert.equal(d.hopDistance, 2, "the winning path is the two-hop calls chain"); }); + +test("SCC-aware propagation: impacting one cycle member reaches all co-members", () => { + const atlas = { + nodes: [ + { id: "a.js::A", name: "A", kind: "function", file: "a.js" }, + { id: "b.js::B", name: "B", kind: "function", file: "b.js" }, + { id: "c.js::C", name: "C", kind: "function", file: "c.js" }, + { id: "t.js::T", name: "T", kind: "function", file: "t.js" }, + ], + edges: [{ source: "a.js::A", target: "t.js::T", kind: "imports" }], + symbols: [], + }; + const sccIndex = buildSccIndex([["a.js", "b.js", "c.js"]]); + const withScc = impact(atlas, "T", { sccIndex }); + assert.ok(withScc.impactedFiles.includes("a.js"), "direct dependent found"); + assert.ok(withScc.impactedFiles.includes("b.js"), "SCC co-member b.js reached"); + assert.ok(withScc.impactedFiles.includes("c.js"), "SCC co-member c.js reached"); + const without = impact(atlas, "T"); + assert.ok(without.impactedFiles.includes("a.js"), "a.js found without SCC too"); + assert.ok(!without.impactedFiles.includes("b.js"), "b.js NOT reached without SCC (no edge)"); + assert.ok(!without.impactedFiles.includes("c.js"), "c.js NOT reached without SCC (no edge)"); +}); + +test("hazard-adjusted threshold includes low-confidence files with high hazard", () => { + // references weight 0.7 × edge confidence 0.1 × decay 0.85 = 0.0595 + // base threshold 0.1 → excluded; effective threshold 0.1/(1+2) = 0.033 → included + const atlas = { + nodes: [ + { id: "t.js::T", name: "T", kind: "function", file: "t.js" }, + { id: "h.js::H", name: "H", kind: "function", file: "h.js" }, + ], + edges: [ + { + source: "h.js::H", + target: "t.js::T", + kind: "references", + confidence: 0.1, + }, + ], + symbols: [], + }; + const hazards = new Map([["h.js", 2]]); + const withHazard = impact(atlas, "T", { threshold: 0.1, hazards }); + const hItem = withHazard.impacted.find((x) => x.id === "h.js::H"); + assert.ok(hItem, "h.js included — hazard=2 lowers effective threshold to 0.033"); + assert.ok(hItem.confidence < 0.1, "confidence is below base threshold"); + const without = impact(atlas, "T", { threshold: 0.1 }); + assert.ok(!without.impacted.find((x) => x.id === "h.js::H"), "without hazards, h.js excluded"); +}); + +test("SCC + hazard combined: cycle member with high hazard gets extra-low threshold", () => { + // references 0.7 × edge confidence 0.12 × decay 0.85 = 0.0714 + // base threshold 0.1 → excluded; hazard=3 → effective 0.1/4 = 0.025 → included + // SCC expansion brings b.js in at the same confidence + const atlas = { + nodes: [ + { id: "t.js::T", name: "T", kind: "function", file: "t.js" }, + { id: "a.js::A", name: "A", kind: "function", file: "a.js" }, + { id: "b.js::B", name: "B", kind: "function", file: "b.js" }, + ], + edges: [ + { + source: "a.js::A", + target: "t.js::T", + kind: "references", + confidence: 0.12, + }, + ], + symbols: [], + }; + const sccIndex = buildSccIndex([["a.js", "b.js"]]); + const hazards = new Map([ + ["a.js", 3], + ["b.js", 3], + ]); + const r = impact(atlas, "T", { threshold: 0.1, sccIndex, hazards }); + assert.ok(r.impactedFiles.includes("a.js"), "a.js included via hazard-lowered threshold"); + assert.ok(r.impactedFiles.includes("b.js"), "b.js included via SCC expansion from a.js"); +}); + +test("fail-open: undefined sccIndex/hazards produces identical output to basic impact", () => { + const atlas = { + nodes: [ + { id: "s.js::S", name: "S", kind: "function", file: "s.js" }, + { id: "d.js::D", name: "D", kind: "function", file: "d.js" }, + ], + edges: [{ source: "d.js::D", target: "s.js::S", kind: "calls" }], + symbols: [], + }; + const basic = impact(atlas, "S"); + const enhanced = impact(atlas, "S", { + sccIndex: undefined, + hazards: undefined, + }); + assert.deepEqual(basic.impactedFiles, enhanced.impactedFiles, "same files"); + assert.deepEqual( + basic.impacted.map((x) => x.confidence), + enhanced.impacted.map((x) => x.confidence), + "same confidences", + ); +}); From fd9280724db76d084f0f2e8c109996d130d13d9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 15:38:02 +0000 Subject: [PATCH 3/3] fix: biome formatting and import ordering in atlas/substrate/imagine Sort imports alphabetically (rank.js before reuse.js, loadRankData before predictFailingTests) and restore 100-column formatting that the environment hook had collapsed to 80 columns. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01LXmzxfRVDRVPU6LG8W39Rz --- src/atlas.js | 99 +++++++++----------------------------- src/imagine.js | 2 +- src/substrate.js | 122 ++++++++++++----------------------------------- 3 files changed, 53 insertions(+), 170 deletions(-) diff --git a/src/atlas.js b/src/atlas.js index b484d1e..ecb5c80 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -1,14 +1,7 @@ // forge atlas — a portable code graph. Build once, then query definitions, membership, // reverse dependents, and impact radius without asking a model to rediscover the repo. -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - statSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { extname, join, relative } from "node:path"; import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js"; import { CALL_RE } from "./extract.js"; @@ -110,8 +103,7 @@ export const RULES = { }; // Kotlin script, C/C++ family, and PHP siblings share the grammar above. RULES[".kts"] = RULES[".kt"]; -for (const ext of [".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh"]) - RULES[ext] = RULES[".c"]; +for (const ext of [".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh"]) RULES[ext] = RULES[".c"]; // Documentation extensions — first-class in the walk, extracted by extractDoc() (a // doc node + `references` edges to the code it names), never by the symbol RULES. @@ -189,16 +181,13 @@ function walk(dir, files, cap) { entries = readdirSync(dir); } catch (err) { if (process.env.FORGE_DEBUG === "1") - process.stderr.write( - `forge atlas: skipping ${dir}: ${err?.message ?? err}\n`, - ); + process.stderr.write(`forge atlas: skipping ${dir}: ${err?.message ?? err}\n`); return; } for (const name of entries) { // Dot-entries stay out of the graph — except `.github`, whose workflows are config // artifacts that name code paths (a CI file IS a dependent of the code it runs). - if (IGNORE_DIRS.has(name) || (name.startsWith(".") && name !== ".github")) - continue; + if (IGNORE_DIRS.has(name) || (name.startsWith(".") && name !== ".github")) continue; const path = join(dir, name); let st; try { @@ -269,8 +258,7 @@ function extractDoc(rel, text) { for (const m of text.matchAll(/\]\(([^)#\s]+)\)/g)) { const tok = m[1].replace(/^\.\//, ""); if (/^[a-z]+:/i.test(tok)) continue; // external URL, not a repo path - if (RULES[extname(tok)]) - refEdge(`module:${moduleId(tok)}`, 0.8, lineOf(text, m.index)); + if (RULES[extname(tok)]) refEdge(`module:${moduleId(tok)}`, 0.8, lineOf(text, m.index)); } return { symbols: [], nodes: [doc], edges, hash: hash(text) }; } @@ -289,9 +277,7 @@ function extractConfig(rel, text) { }; const edges = []; const seen = new Set(); - for (const m of text.matchAll( - /[A-Za-z0-9_.@-]+(?:[/\\][A-Za-z0-9_.@-]+)*/g, - )) { + for (const m of text.matchAll(/[A-Za-z0-9_.@-]+(?:[/\\][A-Za-z0-9_.@-]+)*/g)) { const tok = m[0].replace(/^\.\//, ""); if (!RULES[extname(tok)]) continue; // only path-like tokens ending in a code extension const target = `module:${moduleId(tok)}`; @@ -323,8 +309,7 @@ function extractFile(path, root, preRead) { } } if (DOC_EXTS.has(ext)) return extractDoc(rel, text); - if (isConfigFile(rel.split(/[/\\]/).pop() || "")) - return extractConfig(rel, text); + if (isConfigFile(rel.split(/[/\\]/).pop() || "")) return extractConfig(rel, text); const mod = { id: `module:${moduleId(rel)}`, @@ -373,9 +358,7 @@ function extractFile(path, root, preRead) { // Inheritance edges — `class X extends Y` (JS/TS) and `class X(Base, …)` (Python). Without // these the `inherits` edge weight was dead and a base-class change never appeared in blast // radius. The base is a bare name; resolveEdges links it to a real node if one exists. - const classNodes = new Map( - nodes.filter((n) => n.kind === "class").map((n) => [n.name, n]), - ); + const classNodes = new Map(nodes.filter((n) => n.kind === "class").map((n) => [n.name, n])); const INHERIT_RES = [ /\bclass\s+([A-Za-z_$][\w$]*)\s+extends\s+([A-Za-z_$][\w$.]*)/g, // JS/TS /^\s*class\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/gm, // Python @@ -495,9 +478,7 @@ const cachePath = (root) => join(root, ".forge", "atlas.cache.json"); function readCache(root) { try { - return existsSync(cachePath(root)) - ? JSON.parse(readFileSync(cachePath(root), "utf8")) - : {}; + return existsSync(cachePath(root)) ? JSON.parse(readFileSync(cachePath(root), "utf8")) : {}; } catch { return {}; } @@ -526,9 +507,7 @@ export function build({ root = process.cwd(), cap = 20000 } = {}) { const reused = prev[rel]?.hash === h ? prev[rel].data : null; const data = reused || - (({ symbols, nodes, edges }) => ({ symbols, nodes, edges }))( - extractFile(f, root, text), - ); + (({ symbols, nodes, edges }) => ({ symbols, nodes, edges }))(extractFile(f, root, text)); cache[rel] = { hash: h, data }; symbols.push(...data.symbols); nodes.push(...data.nodes); @@ -575,8 +554,7 @@ export function isStale(root, atlas) { const current = []; walk(root, current, 20000); if (current.length !== indexed.size) return true; // a file was added or removed - for (const p of current) - if (!indexed.has(toPosix(relative(root, p)))) return true; + for (const p of current) if (!indexed.has(toPosix(relative(root, p)))) return true; } return false; } @@ -598,21 +576,14 @@ export function query(atlas, term) { } export function has(atlas, name) { - return (atlas.symbols || []).some( - (s) => s.name === name || s.qname === name || s.id === name, - ); + return (atlas.symbols || []).some((s) => s.name === name || s.qname === name || s.id === name); } function targetIds(atlas, target) { const t = String(target); const nodes = atlas.nodes || []; const matches = nodes.filter( - (n) => - n.id === t || - n.name === t || - n.qname === t || - n.file === t || - n.file?.endsWith(`/${t}`), + (n) => n.id === t || n.name === t || n.qname === t || n.file === t || n.file?.endsWith(`/${t}`), ); return matches.map((n) => n.id); } @@ -652,9 +623,7 @@ function adjacency(atlas) { // verified twice — it must resolve to a REAL node in the graph AND (via the caller's `verify` // predicate, a grep) actually reference the target in source. Unverifiable → dropped, never added. export function buildImpactPrompt(atlas, target) { - const files = [ - ...new Set((atlas.nodes || []).map((n) => n.file).filter(Boolean)), - ].slice(0, 60); + const files = [...new Set((atlas.nodes || []).map((n) => n.file).filter(Boolean))].slice(0, 60); return `A code symbol/file is about to change. Name the OTHER files in this repo that most likely break or depend on it through edges a regex misses: dynamic dispatch, dependency injection, reflection, string-keyed registries, event handlers. @@ -668,10 +637,7 @@ No text outside the JSON object.`; export function parseImpactProposal(obj) { const files = Array.isArray(obj.files) - ? [...new Set(obj.files.map((f) => asText(f, 240)).filter(Boolean))].slice( - 0, - 20, - ) + ? [...new Set(obj.files.map((f) => asText(f, 240)).filter(Boolean))].slice(0, 20) : []; return { files }; } @@ -692,8 +658,7 @@ export function impactLLM(atlas, target, { run = buildRunner() } = {}) { */ export function buildSccIndex(sccs) { const index = new Map(); - for (let i = 0; i < sccs.length; i++) - for (const file of sccs[i]) index.set(file, i); + for (let i = 0; i < sccs.length; i++) for (const file of sccs[i]) index.set(file, i); return index; } @@ -713,16 +678,7 @@ export function buildSccIndex(sccs) { export function impact( atlas, target, - { - threshold = 0.1, - maxHops = 6, - decay = 0.85, - llm, - run, - verify, - sccIndex, - hazards, - } = {}, + { threshold = 0.1, maxHops = 6, decay = 0.85, llm, run, verify, sccIndex, hazards } = {}, ) { const starts = targetIds(atlas, target); const startSet = new Set(starts); @@ -748,10 +704,7 @@ export function impact( for (const edge of incoming.get(current.id) || []) { if (startSet.has(edge.source)) continue; const nextConfidence = - current.confidence * - (EDGE_WEIGHT[edge.kind] || 0.5) * - (edge.confidence ?? 1) * - decay; + current.confidence * (EDGE_WEIGHT[edge.kind] || 0.5) * (edge.confidence ?? 1) * decay; const srcNode = nodeById.get(edge.source); const srcFile = srcNode?.file; const effectiveThreshold = @@ -809,19 +762,13 @@ export function impact( } } } - const impacted = [...visited.values()].sort( - (a, b) => b.confidence - a.confidence, - ); - const deterministicFiles = new Set( - impacted.map((x) => x.node.file).filter(Boolean), - ); + const impacted = [...visited.values()].sort((a, b) => b.confidence - a.confidence); + const deterministicFiles = new Set(impacted.map((x) => x.node.file).filter(Boolean)); // Opt-in imagination pass: model proposes missed edges, but only VERIFIED ones are kept. const llmImpacted = []; if (llmEnabled({ llm }) && run) { - const knownFiles = new Set( - (atlas.nodes || []).map((n) => n.file).filter(Boolean), - ); + const knownFiles = new Set((atlas.nodes || []).map((n) => n.file).filter(Boolean)); const proposal = impactLLM(atlas, target, { run }); for (const file of proposal?.files || []) { if (deterministicFiles.has(file)) continue; // already found deterministically @@ -844,9 +791,7 @@ export function impact( found: starts.length > 0, threshold, impacted: all, - impactedFiles: [ - ...new Set(all.map((x) => x.node.file).filter(Boolean)), - ].sort(), + impactedFiles: [...new Set(all.map((x) => x.node.file).filter(Boolean))].sort(), llmVerified: llmImpacted.map((x) => x.node.file), totalGraphNodes: (atlas.nodes || []).length, totalGraphEdges: (atlas.edges || []).length, diff --git a/src/imagine.js b/src/imagine.js index a36be42..844ca90 100644 --- a/src/imagine.js +++ b/src/imagine.js @@ -13,7 +13,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { build as buildAtlas, impact, load as loadAtlas } from "./atlas.js"; import { referencedEntities } from "./preflight.js"; -import { isTestFile, predictFailingTests, loadRankData } from "./substrate.js"; +import { isTestFile, loadRankData, predictFailingTests } from "./substrate.js"; import { hasBin, toPosix } from "./util.js"; /** diff --git a/src/substrate.js b/src/substrate.js index c3c2d90..b532481 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -19,24 +19,16 @@ import { matchingLessons } from "./cortex.js"; import { recordGate } from "./cost_report.js"; import { leanRepo } from "./lean.js"; import { mergedLessons } from "./ledger_read.js"; -import { - clarifyBlock, - preflightRepo, - referencedEntities, -} from "./preflight.js"; +import { clarifyBlock, preflightRepo, referencedEntities } from "./preflight.js"; +import { rankReport } from "./rank.js"; import { reusePeek, reuseQuery } from "./reuse.js"; import { meterRoute, routeTask } from "./route.js"; -import { rankReport } from "./rank.js"; import { decompose } from "./scope.js"; import { detectStack } from "./stack.js"; import { epochDay } from "./util.js"; function loadSubstrateSpec() { - const path = join( - dirname(dirname(fileURLToPath(import.meta.url))), - "source", - "substrate.json", - ); + const path = join(dirname(dirname(fileURLToPath(import.meta.url))), "source", "substrate.json"); try { return JSON.parse(readFileSync(path, "utf8")); } catch { @@ -65,9 +57,7 @@ function verificationChecklist(root) { // complexity. No second keyword copy here to drift out of sync with those sources. function minimalityWarnings(_task, route, preflight) { const warnings = []; - const missing = new Set( - (preflight.assumption?.missing ?? []).map((m) => m.key), - ); + const missing = new Set((preflight.assumption?.missing ?? []).map((m) => m.key)); if (missing.has("target_scope") && preflight.entities.files.length === 0) { warnings.push( "High-risk broad change with no target files named; ask for scope before editing.", @@ -105,11 +95,7 @@ function siblingTestCandidates(file) { const base = dot > 0 ? nameExt.slice(0, dot) : nameExt; const ext = dot > 0 ? nameExt.slice(dot) : ""; if (ext === ".py") - return [ - `${dir}test_${base}.py`, - `${dir}tests/test_${base}.py`, - `tests/test_${base}.py`, - ]; + return [`${dir}test_${base}.py`, `${dir}tests/test_${base}.py`, `tests/test_${base}.py`]; const out = []; for (const suf of [".test", ".spec"]) out.push(`${dir}${base}${suf}${ext}`); for (const d of ["__tests__/", "test/", "tests/"]) @@ -125,8 +111,7 @@ export function predictFailingTests(root, impactedFiles) { out.add(f); continue; } - for (const c of siblingTestCandidates(f)) - if (existsSync(join(root, c))) out.add(c); + for (const c of siblingTestCandidates(f)) if (existsSync(join(root, c))) out.add(c); } return [...out].sort(); } @@ -143,10 +128,7 @@ function makeImpactVerify(root) { try { const src = readFileSync(join(root, file), "utf8"); const name = base(target); - return ( - name.length > 1 && - new RegExp(`\\b${name.replace(/[^\w$]/g, "")}\\b`).test(src) - ); + return name.length > 1 && new RegExp(`\\b${name.replace(/[^\w$]/g, "")}\\b`).test(src); } catch { return false; } @@ -164,9 +146,7 @@ export function loadRankData(root) { try { const report = rankReport(root); if (!report.built) return { sccIndex: undefined, hazards: undefined }; - const sccIndex = report.cycles?.length - ? buildSccIndex(report.cycles) - : undefined; + const sccIndex = report.cycles?.length ? buildSccIndex(report.cycles) : undefined; const hazards = report.topFiles?.length ? new Map(report.topFiles.map((f) => [f.file, f.hazard])) : undefined; @@ -192,8 +172,7 @@ export function predictImpact( { threshold = 0.1, llm, model, timeoutMs, basic } = {}, ) { const cached = loadAtlas(root); - const atlas = - cached && !atlasIsStale(root, cached) ? cached : buildAtlas({ root }); + const atlas = cached && !atlasIsStale(root, cached) ? cached : buildAtlas({ root }); const useLLM = llmEnabled({ llm }); const rankData = basic ? {} : loadRankData(root); return impactGraph(atlas, target, { @@ -245,9 +224,7 @@ export function substrateCheck( // Bidirectional (clear-a-false-ask / route-down, within rails) follows the JSON default unless // the caller overrides it. The numeric bands/floor come from the same config block. const bi = - typeof bidirectional === "boolean" - ? bidirectional - : (spec?.llm?.bidirectional ?? true); + typeof bidirectional === "boolean" ? bidirectional : (spec?.llm?.bidirectional ?? true); const llmOpts = { llm: useLLM, model, @@ -301,9 +278,7 @@ export function substrateCheck( } else { atlasFresh = false; // no atlas and can't build } - const impactTargets = [ - ...new Set([...entities.symbols, ...entities.files]), - ].slice(0, 8); + const impactTargets = [...new Set([...entities.symbols, ...entities.files])].slice(0, 8); const impactRun = useLLM ? buildRunner({ model, timeoutMs }) : undefined; const impactVerify = makeImpactVerify(root); const impacts = atlas @@ -316,26 +291,20 @@ export function substrateCheck( }), ) : []; - const impactedFiles = [ - ...new Set(impacts.flatMap((r) => r.impactedFiles || [])), - ].sort(); + const impactedFiles = [...new Set(impacts.flatMap((r) => r.impactedFiles || []))].sort(); // Consequence simulation (Eq 4), class "failing tests": which tests likely break if the // impacted files change — the impacted files that ARE tests, plus each impacted source file's // sibling test. Cheap, exact-ish, and surfaced BEFORE the edit (not after, like verify). // Gated on atlas freshness (belt and braces with the null atlas above): predictions from a // stale graph are not trustworthy and must not be presented as consequence evidence. - const predictedTests = atlasFresh - ? predictFailingTests(root, impactedFiles) - : []; + const predictedTests = atlasFresh ? predictFailingTests(root, impactedFiles) : []; // P3 reuse stage: has this team already built (and verified) this? The explicit gate // meters + writes evidence (reuseQuery); the ambient hook path stays read-only // (reusePeek) so a per-prompt hook never appends to the ledger or metrics. const reuse = (() => { try { const opts = { atlas, nowDay: epochDay() }; - const r = allowBuild - ? reuseQuery(root, text, opts) - : reusePeek(root, text, opts); + const r = allowBuild ? reuseQuery(root, text, opts) : reusePeek(root, text, opts); return { tier: r.tier, artifact: r.artifact @@ -396,9 +365,7 @@ export function substrateCheck( // Truthful freshness: false when the atlas is missing/stale and couldn't be rebuilt. // Consumers must not present impactedFiles as trustworthy when this is false. atlasFresh, - ...(atlasFresh - ? {} - : { note: "impact unavailable: atlas missing or stale" }), + ...(atlasFresh ? {} : { note: "impact unavailable: atlas missing or stale" }), }, scope, memory: { @@ -414,9 +381,7 @@ export function substrateCheck( // never asked for. `lean` is diff-based, so it's quiet until there's something to measure. minimality: (() => { const pre = minimalityWarnings(text, route, preflight); - const lean = allowBuild - ? leanRepo(root, text) - : { warnings: [], footprint: null }; + const lean = allowBuild ? leanRepo(root, text) : { warnings: [], footprint: null }; return { warnings: [...pre, ...lean.warnings], footprint: lean.footprint, @@ -467,8 +432,7 @@ export function substrateCheck( ], }, }; - result.llm.provenance.goalAnchor = - result.goalAnchor?.provenance?.path ?? "deterministic"; + result.llm.provenance.goalAnchor = result.goalAnchor?.provenance?.path ?? "deterministic"; return result; } @@ -484,14 +448,11 @@ export function substrateCheck( * @param {number} [opts.blastThreshold] */ export function enforceDecision(result, { enforce, blastThreshold = 25 } = {}) { - const on = - typeof enforce === "boolean" ? enforce : process.env.FORGE_ENFORCE === "1"; + const on = typeof enforce === "boolean" ? enforce : process.env.FORGE_ENFORCE === "1"; if (!on || !result) return { block: false }; const tail = "\n(Set FORGE_ENFORCE=0 to make Forge advisory again.)"; if (result.assumption?.hardUnderspecified) { - const qs = (result.assumption.questions || []) - .map((q) => ` • ${q}`) - .join("\n"); + const qs = (result.assumption.questions || []).map((q) => ` • ${q}`).join("\n"); return { block: true, reason: `Forge gate (enforcing): this task has no concrete anchor to act on — clarify before I start:\n${qs}${tail}`, @@ -535,8 +496,7 @@ export function renderSubstrate(result) { "", ` route: ${result.route.model.name} (${result.route.tier}) · complexity ${result.route.score.toFixed(2)}`, ); - if (result.route.reasons.length) - lines.push(` driven by: ${result.route.reasons.join(", ")}`); + if (result.route.reasons.length) lines.push(` driven by: ${result.route.reasons.join(", ")}`); if (result.reuse && result.reuse.tier !== "miss") { const a = result.reuse.artifact; lines.push( @@ -552,31 +512,18 @@ export function renderSubstrate(result) { for (const q of result.context.questions ?? []) lines.push(` ? ${q}`); } if (result.impact.atlasFresh === false) { - lines.push( - "", - " impact: unavailable — atlas missing or stale (predictions not trustworthy)", - ); + lines.push("", " impact: unavailable — atlas missing or stale (predictions not trustworthy)"); } else { - lines.push( - "", - ` impact: ${result.impact.impactedFiles.length} file(s) predicted`, - ); - for (const file of result.impact.impactedFiles.slice(0, 10)) - lines.push(` - ${file}`); + lines.push("", ` impact: ${result.impact.impactedFiles.length} file(s) predicted`); + for (const file of result.impact.impactedFiles.slice(0, 10)) lines.push(` - ${file}`); if (result.impact.impactedFiles.length > 10) lines.push(` … ${result.impact.impactedFiles.length - 10} more`); } // Predicted tests only speak for a FRESH atlas — right after an "impact: unavailable" // notice, a likely-affected-tests list would contradict it with stale data (RA-07). - const tests = - result.impact.atlasFresh === false - ? [] - : result.impact.predictedTests || []; + const tests = result.impact.atlasFresh === false ? [] : result.impact.predictedTests || []; if (tests.length) { - lines.push( - "", - ` likely-affected tests (${tests.length}) — run these first:`, - ); + lines.push("", ` likely-affected tests (${tests.length}) — run these first:`); for (const t of tests.slice(0, 8)) lines.push(` - ${t}`); } if (result.minimality.warnings.length) { @@ -588,8 +535,7 @@ export function renderSubstrate(result) { "", ` goal drift: ${result.goalAnchor.offGoal.length} changed file(s) off the stated goal:`, ); - for (const f of result.goalAnchor.offGoal.slice(0, 8)) - lines.push(` - ${f}`); + for (const f of result.goalAnchor.offGoal.slice(0, 8)) lines.push(` - ${f}`); } lines.push("", " verify:"); for (const c of result.verification.checklist) lines.push(` - ${c}`); @@ -609,9 +555,7 @@ export function substrateContext(result) { result.goalAnchor?.drift || ["opus", "fable"].includes(result.route.key); if (!worthSaying) return ""; - const lines = [ - "Forge substrate — pre-action advisory (advisory, never blocks):", - ]; + const lines = ["Forge substrate — pre-action advisory (advisory, never blocks):"]; if (result.assumption.shouldAsk) { lines.push( `- Under-specified (${result.assumption.risk} risk). Ask before editing:`, @@ -632,10 +576,7 @@ export function substrateContext(result) { ); } // Same freshness rule as the renderer: never advise stale test predictions (RA-07). - const predTests = - result.impact.atlasFresh === false - ? [] - : result.impact.predictedTests || []; + const predTests = result.impact.atlasFresh === false ? [] : result.impact.predictedTests || []; if (predTests.length) lines.push( `- Likely-affected tests (${predTests.length}): ${predTests.slice(0, 6).join(", ")}${predTests.length > 6 ? " …" : ""}. Run these first.`, @@ -646,15 +587,12 @@ export function substrateContext(result) { `- Goal drift: ${result.goalAnchor.offGoal.length} changed file(s) off the stated goal (${result.goalAnchor.offGoal.slice(0, 5).join(", ")}). Intended, or wandering?`, ); if (result.memory.matchingLessons) - lines.push( - `- ${result.memory.matchingLessons} past lesson(s) match this area (advisory).`, - ); + lines.push(`- ${result.memory.matchingLessons} past lesson(s) match this area (advisory).`); // I3: proceeding is fine below the ask-threshold, but never SILENTLY — the gaps are // named here and recorded to the session log (the handoff surfaces them later). if ( !result.assumption.shouldAsk && - ((result.assumption.missing?.length ?? 0) > 0 || - result.assumption.questions?.length > 0) + ((result.assumption.missing?.length ?? 0) > 0 || result.assumption.questions?.length > 0) ) { const keys = (result.assumption.missing ?? []).map((m) => m.key); lines.push(