diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d548507..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: @@ -340,6 +344,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 +487,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..3aef228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ 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 + 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/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/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/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/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/atlas.js b/src/atlas.js index 0cdb09d..ecb5c80 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -650,6 +650,18 @@ 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 +672,13 @@ 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); @@ -691,12 +705,18 @@ export function impact( if (startSet.has(edge.source)) continue; const nextConfidence = current.confidence * (EDGE_WEIGHT[edge.kind] || 0.5) * (edge.confidence ?? 1) * decay; - if (nextConfidence < threshold) continue; + 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,6 +734,32 @@ 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); 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/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/src/imagine.js b/src/imagine.js index 2f787a6..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 } from "./substrate.js"; +import { isTestFile, loadRankData, predictFailingTests } 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..b532481 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"; @@ -19,6 +20,7 @@ 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 { rankReport } from "./rank.js"; import { reusePeek, reuseQuery } from "./reuse.js"; import { meterRoute, routeTask } from "./route.js"; import { decompose } from "./scope.js"; @@ -133,6 +135,27 @@ function makeImpactVerify(root) { }; } +/** + * 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 +164,24 @@ 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 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, }); } 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", + ); +}); 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", () => {