diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5b080..99a51b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **M5 anti-over-engineering is now measured, not guessed (`forge lean`).** The paper's + `φ(y) − φ*(x)` check replaces the old three-keyword stub: `src/lean.js` reads the working diff + and flags the footprint beyond what the task asked for — new abstractions the task never named, + a large diff for a short ask, files touched beyond the stated scope. Folded into + `forge substrate` (a `minimality.footprint` field) and available standalone as `forge lean ""`. +- **Doom-loop breaker (self-correction).** Complements the shell guard (which catches the *same + action* repeated) by catching the subtler loop the paper names — *different edits that keep + producing the same test failure*. `cortex_hook` now captures a normalized signature of failing + test output; `detectDoomLoop` fires when one signature recurs past a threshold, and the + pre-edit hook surfaces a "stop and find the root cause" advisory with the diagnosis. +- **Consequence simulation — failing-tests class (Eq 4).** `forge substrate` now predicts the + tests likely to break *before* an edit (`impact.predictedTests`): the impacted files that are + tests, plus each impacted source file's sibling test — surfaced so you run the narrowest + affected tests first, not after the fact. + ### Changed - **`forge sync` now adopts an existing project `CLAUDE.md` instead of skipping it.** Previously a diff --git a/src/cli.js b/src/cli.js index 93bd200..ac3afd9 100755 --- a/src/cli.js +++ b/src/cli.js @@ -25,6 +25,7 @@ const COMMANDS = { substrate: "one pre-action gate: assumptions, route, impact, scope, memory, verify", scope: "decompose files into independent clusters (+ coupled files you didn't name)", anchor: "goal-drift check — are your actual (git) changes still on the stated goal?", + lean: "scope-minimality (M5) — measure the diff's footprint vs what the task asked for", uicheck: "deterministic UI check — WCAG contrast (assertable, no guessing)", brand: "print the active brand token map", }; @@ -480,6 +481,24 @@ async function run(argv) { console.log(json ? JSON.stringify(r, null, 2) : renderAnchor(r)); return; // advisory — never fails the process } + if (cmd === "lean") { + const { leanRepo, renderLean } = await import("./lean.js"); + const json = argv.includes("--json"); + const task = argv + .slice(1) + .filter((a) => a !== "--json") + .join(" "); + if (!task) { + console.error( + 'usage: forge lean "" [--json] (measures the working diff vs the task)', + ); + process.exitCode = 1; + return; + } + const r = leanRepo(process.cwd(), task); + console.log(json ? JSON.stringify(r, null, 2) : renderLean(r)); + return; // advisory — never fails the process + } if (cmd === "scope") { const { decompose } = await import("./scope.js"); const files = argv.slice(1); diff --git a/src/cortex_hook.js b/src/cortex_hook.js index 0fae62e..9d9d47c 100644 --- a/src/cortex_hook.js +++ b/src/cortex_hook.js @@ -47,6 +47,25 @@ const slug = (s) => .replace(/(^-|-$)/g, "") .slice(0, 40); +// A cheap, dependency-free signature of a failing test's output. Line numbers, hex addresses, +// timings, and temp paths are normalized out so "the same failure" hashes the same across runs +// even as surrounding noise shifts — this is what lets us catch a same-error doom loop. +function outputSignature(text) { + const norm = String(text) + .toLowerCase() + .replace(/0x[0-9a-f]+/g, "0xADDR") + .replace(/\b\d+(\.\d+)?(ms|s)\b/g, "T") + .replace(/:\d+:\d+/g, ":L:C") + .replace(/\b\d+\b/g, "N") + .replace(/\/tmp\/\S+/g, "/tmp/X") + .replace(/\s+/g, " ") + .trim() + .slice(0, 800); + let h = 5381; + for (let i = 0; i < norm.length; i++) h = ((h << 5) + h + norm.charCodeAt(i)) >>> 0; + return norm ? h.toString(36) : ""; +} + /** Normalize a raw hook payload into a compact event, or null if it carries no signal. */ export function classifyEvent(hook) { const tool = hook.tool_name; @@ -55,10 +74,15 @@ export function classifyEvent(hook) { return { type: "edit", file: inp.file_path ?? "" }; } if (tool === "Bash") { + const exitCode = hook.exitCode; + const failed = typeof exitCode === "number" && exitCode !== 0; + const out = hook.tool_response?.stdout ?? hook.tool_response ?? hook.output ?? ""; return { type: "bash", command: inp.command ?? "", - exitCode: hook.exitCode, + exitCode, + // Only carry a signature for FAILED runs — that's all the doom-loop check needs. + ...(failed && out ? { outputSig: outputSignature(out) } : {}), }; } if (hook.hook_event_name === "UserPromptSubmit" || typeof hook.prompt === "string") { @@ -132,6 +156,45 @@ export function detectEpisodes(events, { nowDay = 0 } = {}) { return episodes; } +/** + * Doom-loop breaker (self-correction #5). The shell guard catches the SAME action repeated; + * this catches the subtler loop the paper names — different edits that keep producing the SAME + * test failure. When one failure signature recurs ≥ `threshold` times, the agent is stuck: halt + * and escalate with a diagnosis rather than burning more attempts. Pure + advisory. + * @returns {{loop:boolean, signature?:string, count?:number, files?:string[]}} + */ +export function detectDoomLoop(events, { threshold = 3 } = {}) { + const counts = new Map(); // outputSig -> occurrences + const filesAround = new Map(); // outputSig -> Set(files edited between failures) + let editsSinceFail = []; + for (const e of events) { + if (e.type === "edit" && e.file) { + editsSinceFail.push(e.file); + } else if (e.type === "bash" && e.outputSig) { + counts.set(e.outputSig, (counts.get(e.outputSig) ?? 0) + 1); + const set = filesAround.get(e.outputSig) ?? new Set(); + for (const f of editsSinceFail) set.add(f); + filesAround.set(e.outputSig, set); + editsSinceFail = []; + } + } + let worst = null; + for (const [sig, count] of counts) { + if (count >= threshold && (!worst || count > worst.count)) { + worst = { signature: sig, count, files: [...(filesAround.get(sig) ?? [])] }; + } + } + return worst ? { loop: true, ...worst } : { loop: false }; +} + +/** The advisory string for a detected doom loop (empty when there's no loop). */ +export function doomLoopAdvisory(events, opts = {}) { + const r = detectDoomLoop(events, opts); + if (!r.loop) return ""; + const where = r.files.length ? ` around ${r.files.slice(0, 5).join(", ")}` : ""; + return `Forge Cortex — doom loop: the SAME test failure has recurred ${r.count}× this session${where}. Different edits aren't fixing it. Stop, find the root cause (re-read the failing assertion and the code it exercises), or ask a human — don't keep patching.`; +} + /** Drive the orchestrator from a session's events (called by the Stop hook). */ export function processSession(root, events, nowDay = 0) { return detectEpisodes(events, { nowDay }).map((ep) => diff --git a/src/cortex_hook_main.js b/src/cortex_hook_main.js index 721c3ef..f6f55b9 100644 --- a/src/cortex_hook_main.js +++ b/src/cortex_hook_main.js @@ -14,6 +14,7 @@ import { appendSessionEvent, classifyEvent, clearSession, + doomLoopAdvisory, processSession, readSession, } from "./cortex_hook.js"; @@ -69,7 +70,10 @@ async function main() { const block = startupBlock(root, today); if (block) emit("SessionStart", block); } else if (mode === "pre-edit") { - const advice = await preEditAdvisory(root, hook.tool_input?.file_path, today); + // A doom loop (the same failure recurring) is the loudest thing to say — it means "stop", + // so it takes precedence over lesson/risk advice. + const loop = doomLoopAdvisory(readSession(root, sid)); + const advice = loop || (await preEditAdvisory(root, hook.tool_input?.file_path, today)); if (advice) emit("PreToolUse", advice); } else if (mode === "preflight") { // Ambient cognitive substrate: assumption gate + (when an atlas is already cached) diff --git a/src/lean.js b/src/lean.js new file mode 100644 index 0000000..a712df8 --- /dev/null +++ b/src/lean.js @@ -0,0 +1,147 @@ +// forge lean — M5 anti-over-engineering, made measurable. The paper flags φ(y) − φ*(x) > 0: the +// solution's footprint beyond the task's minimal sufficient footprint. The shipped substrate only +// had three keyword regexes; this measures the ACTUAL footprint from the working diff — files +// touched, lines added, and NEW abstractions introduced — against what the task NAMED, and flags +// the excess. Deterministic, git/diff-based, zero-dep. Advisory (never blocks); tests always win. +import { execFileSync } from "node:child_process"; +import { referencedEntities } from "./preflight.js"; + +// A new top-level definition introduced on an added (+) diff line — the over-abstraction signal. +const NEW_DEF_RES = [ + /^\+\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/, + /^\+\s*(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, + /^\+\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/, + /^\+\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*=/, + // const/let/var bound to a function value (a real new abstraction, not a scalar) + /^\+\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/, + /^\+\s*def\s+([A-Za-z_]\w*)/, + /^\+\s*class\s+([A-Za-z_]\w*)/, +]; + +/** Pure: a unified diff → the actual footprint {files, linesAdded, newSymbols}. */ +export function parseDiffFootprint(diff) { + const files = new Set(); + const newSymbols = []; + let linesAdded = 0; + for (const line of String(diff).split("\n")) { + if (line.startsWith("+++ ")) { + const m = line.match(/^\+\+\+ (?:b\/)?(.+)$/); + if (m && m[1] !== "/dev/null") files.add(m[1].trim()); + continue; + } + if (line.startsWith("+") && !line.startsWith("+++")) { + linesAdded += 1; + for (const re of NEW_DEF_RES) { + const m = line.match(re); + if (m) { + newSymbols.push(m[1]); + break; + } + } + } + } + return { files: [...files], linesAdded, newSymbols }; +} + +/** + * Pure: compare the actual footprint against what the task asked for and flag the excess. + * @param {string} task + * @param {{files:string[], linesAdded:number, newSymbols:string[]}} actual + */ +export function assessFootprint(task, actual, { maxLinesForShortTask = 120 } = {}) { + const { symbols: named, files: namedFiles } = referencedEntities(task); + const namedSymbols = new Set(named.map((s) => s.toLowerCase())); + const words = String(task).trim().split(/\s+/).filter(Boolean).length; + const warnings = []; + + // Abstractions the task never named — the core φ(y) − φ*(x) signal. + const unrequested = [...new Set(actual.newSymbols)].filter( + (s) => !namedSymbols.has(s.toLowerCase()), + ); + if (unrequested.length >= 3) { + warnings.push( + `${unrequested.length} new abstractions the task didn't ask for (${unrequested.slice(0, 5).join(", ")}) — is each one necessary, or is this over-built?`, + ); + } + + // A short ask that produced a large diff. + if (words <= 12 && actual.linesAdded > maxLinesForShortTask) { + warnings.push( + `${actual.linesAdded} lines added for a ${words}-word task — confirm the scope matches the request.`, + ); + } + + // Touched far more files than the task named. + if (namedFiles.length) { + const extra = actual.files.filter( + (f) => !namedFiles.some((nf) => f.endsWith(nf) || nf.endsWith(f)), + ); + if (extra.length > Math.max(2, namedFiles.length * 2)) { + warnings.push( + `Touched ${actual.files.length} files but the task named ${namedFiles.length} — ${extra.length} are beyond the stated scope.`, + ); + } + } + + return { + warnings, + footprint: { + files: actual.files.length, + linesAdded: actual.linesAdded, + newAbstractions: [...new Set(actual.newSymbols)], + unrequestedAbstractions: unrequested, + }, + }; +} + +function gitDiff(root, base) { + try { + const staged = execFileSync("git", ["diff", "--unified=0", base], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return ( + staged || + execFileSync("git", ["diff", "--unified=0", "--cached"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }) + ); + } catch { + return ""; + } +} + +/** + * Repo wrapper: measure the working-tree footprint against a task. `diff` injectable for tests. + * @param {string} root + * @param {string} task + * @param {object} [opts] + * @param {string} [opts.base] + * @param {string} [opts.diff] + */ +export function leanRepo(root, task, { base = "HEAD", diff } = {}) { + const d = diff ?? gitDiff(root, base); + return { + ...assessFootprint(String(task || ""), parseDiffFootprint(d)), + hasDiff: Boolean(d.trim()), + }; +} + +export function renderLean(r) { + const lines = ["Forge lean — scope minimality (M5)", ""]; + if (!r.hasDiff) return `${lines.join("\n")} no diff vs HEAD yet — nothing to measure.`; + const f = r.footprint; + lines.push( + ` footprint: ${f.files} file(s), +${f.linesAdded} line(s), ${f.newAbstractions.length} new abstraction(s)`, + ); + if (r.warnings.length) { + lines.push("", " possible over-engineering:"); + for (const w of r.warnings) lines.push(` - ${w}`); + } else { + lines.push("", " ✓ footprint looks proportionate to the task."); + } + return lines.join("\n"); +} diff --git a/src/substrate.js b/src/substrate.js index 6052011..0f257f7 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -9,6 +9,7 @@ import { buildRunner, llmEnabled } from "./adjudicate.js"; import { goalDrift } from "./anchor.js"; import { build as buildAtlas, impact as impactGraph, load as loadAtlas } from "./atlas.js"; import { matchingLessons } from "./cortex.js"; +import { leanRepo } from "./lean.js"; import { load as loadLessons } from "./lessons_store.js"; import { clarifyBlock, preflightRepo, referencedEntities } from "./preflight.js"; import { routeTask } from "./route.js"; @@ -62,6 +63,43 @@ function minimalityWarnings(task, route, preflight) { return warnings; } +const TEST_FILE_RE = + /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[jt]sx?$|_test\.(py|go|rs)$|(^|\/)test_[^/]+\.py$/i; + +export const isTestFile = (f) => TEST_FILE_RE.test(String(f)); + +// Candidate sibling-test paths for a source file: foo.js → foo.test.js / foo.spec.js / +// __tests__/foo.js / test(s)/foo.js, and foo.py → test_foo.py / tests/test_foo.py. +function siblingTestCandidates(file) { + const s = String(file); + const slash = s.lastIndexOf("/"); + const dir = slash >= 0 ? s.slice(0, slash + 1) : ""; + const nameExt = s.slice(slash + 1); + const dot = nameExt.lastIndexOf("."); + 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`]; + const out = []; + for (const suf of [".test", ".spec"]) out.push(`${dir}${base}${suf}${ext}`); + for (const d of ["__tests__/", "test/", "tests/"]) + out.push(`${dir}${d}${base}${ext}`, `${d}${base}${ext}`); + return out; +} + +/** Predict the tests likely to fail if the impacted files change (impacted tests + siblings). */ +export function predictFailingTests(root, impactedFiles) { + const out = new Set(); + for (const f of impactedFiles) { + if (isTestFile(f)) { + out.add(f); + continue; + } + for (const c of siblingTestCandidates(f)) if (existsSync(join(root, c))) out.add(c); + } + return [...out].sort(); +} + // Grep-style verify for the LLM impact pass: a proposed dependent is only kept if the target // symbol/file name actually appears in the candidate file's source. External check, not trust. function makeImpactVerify(root) { @@ -172,6 +210,10 @@ export function substrateCheck( ) : []; 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). + const predictedTests = predictFailingTests(root, impactedFiles); const scopedFiles = [...new Set([...entities.files, ...impactedFiles])]; const scope = scopedFiles.length ? decompose(root, scopedFiles) @@ -187,7 +229,7 @@ export function substrateCheck( clarify: clarifyBlock(preflight), route, entities, - impact: { targets: impactTargets, reports: impacts, impactedFiles }, + impact: { targets: impactTargets, reports: impacts, impactedFiles, predictedTests }, scope, memory: { matchingLessons: lessons.length, @@ -197,7 +239,14 @@ export function substrateCheck( scope: lesson.scope, })), }, - minimality: { warnings: minimalityWarnings(text, route, preflight) }, + // M5 anti-over-engineering: the pre-action keyword heuristics PLUS a measured footprint check + // (φ(y) − φ*(x)) against the working diff once one exists — abstractions/files/lines the task + // 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 }; + return { warnings: [...pre, ...lean.warnings], footprint: lean.footprint }; + })(), // M4 goal-anchoring: re-read the stated goal against files already changed this session. // Quiet pre-action (clean tree → no drift); speaks mid-session when work wandered off-goal. goalAnchor: goalDrift(root, text, llmOpts), @@ -266,6 +315,11 @@ export function renderSubstrate(result) { 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`); + const tests = result.impact.predictedTests || []; + if (tests.length) { + 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) { lines.push("", " minimality warnings:"); for (const w of result.minimality.warnings) lines.push(` - ${w}`); @@ -310,6 +364,11 @@ export function substrateContext(result) { `- Predicted blast radius (${files.length}): ${files.slice(0, 8).join(", ")}${files.length > 8 ? " …" : ""}. Review these before editing.`, ); } + const predTests = result.impact.predictedTests || []; + if (predTests.length) + lines.push( + `- Likely-affected tests (${predTests.length}): ${predTests.slice(0, 6).join(", ")}${predTests.length > 6 ? " …" : ""}. Run these first.`, + ); for (const w of result.minimality.warnings) lines.push(`- Minimality: ${w}`); if (result.goalAnchor?.drift) lines.push( diff --git a/test/cortex_hook.test.js b/test/cortex_hook.test.js index efcad7e..7c59064 100644 --- a/test/cortex_hook.test.js +++ b/test/cortex_hook.test.js @@ -3,7 +3,13 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { classifyEvent, detectEpisodes, processSession } from "../src/cortex_hook.js"; +import { + classifyEvent, + detectDoomLoop, + detectEpisodes, + doomLoopAdvisory, + processSession, +} from "../src/cortex_hook.js"; import { load } from "../src/lessons_store.js"; const fixture = () => mkdtempSync(join(tmpdir(), "forge-hook-")); @@ -107,3 +113,70 @@ test("end-to-end (weak pattern): a 0.4–0.7 episode is ignored once, earns a le "recurrence promotes the weak episode to a candidate", ); }); + +test("classifyEvent attaches an output signature only to a FAILED bash run", () => { + const failed = classifyEvent({ + tool_name: "Bash", + tool_input: { command: "npm test" }, + exitCode: 1, + tool_response: "AssertionError: expected 3 to equal 4\n at test.js:12:5", + }); + assert.ok(failed.outputSig, "failed run carries a signature"); + const passed = classifyEvent({ + tool_name: "Bash", + tool_input: { command: "npm test" }, + exitCode: 0, + tool_response: "all good", + }); + assert.equal(passed.outputSig, undefined, "a passing run carries none"); +}); + +test("detectDoomLoop fires when the SAME failure signature recurs past the threshold", () => { + // same normalized failure three times, with different edits in between + const fail = (n) => ({ + type: "bash", + command: "npm test", + exitCode: 1, + outputSig: "sameSig", + _n: n, + }); + const events = [ + { type: "edit", file: "a.js" }, + fail(1), + { type: "edit", file: "a.js" }, + fail(2), + { type: "edit", file: "b.js" }, + fail(3), + ]; + const r = detectDoomLoop(events, { threshold: 3 }); + assert.equal(r.loop, true); + assert.equal(r.count, 3); + assert.ok(r.files.includes("a.js")); + assert.match(doomLoopAdvisory(events, { threshold: 3 }), /doom loop/i); +}); + +test("detectDoomLoop stays quiet when failures differ or are below threshold", () => { + const events = [ + { type: "bash", command: "npm test", exitCode: 1, outputSig: "sigA" }, + { type: "bash", command: "npm test", exitCode: 1, outputSig: "sigB" }, + { type: "bash", command: "npm test", exitCode: 1, outputSig: "sigA" }, + ]; + assert.equal(detectDoomLoop(events, { threshold: 3 }).loop, false, "no single signature hit 3×"); + assert.equal(doomLoopAdvisory(events), ""); +}); + +test("outputSignature normalizes line numbers/timings so the same error matches across runs", () => { + const e1 = classifyEvent({ + tool_name: "Bash", + tool_input: { command: "pytest" }, + exitCode: 1, + tool_response: "FAILED test_x.py:41 in 0.3s — assert 1 == 2", + }); + const e2 = classifyEvent({ + tool_name: "Bash", + tool_input: { command: "pytest" }, + exitCode: 1, + tool_response: "FAILED test_x.py:57 in 1.1s — assert 1 == 2", + }); + assert.equal(e1.outputSig, e2.outputSig, "line/timing noise is normalized out"); +}); diff --git a/test/lean.test.js b/test/lean.test.js new file mode 100644 index 0000000..a4d612a --- /dev/null +++ b/test/lean.test.js @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { assessFootprint, leanRepo, parseDiffFootprint } from "../src/lean.js"; + +const diff = (s) => s.trimStart(); + +test("parseDiffFootprint counts files, added lines, and new abstractions", () => { + const d = diff(` ++++ b/src/thing.js ++export function alpha(){ return 1 } ++export class Beta {} ++const gamma = () => 2 ++const scalar = 42 ++++ b/src/other.js ++import x from './x' +`); + const fp = parseDiffFootprint(d); + assert.deepEqual(fp.files.sort(), ["src/other.js", "src/thing.js"]); + assert.ok( + fp.newSymbols.includes("alpha") && + fp.newSymbols.includes("Beta") && + fp.newSymbols.includes("gamma"), + ); + assert.ok(!fp.newSymbols.includes("scalar"), "a plain scalar const is not a new abstraction"); + assert.ok(fp.linesAdded >= 5); +}); + +test("assessFootprint flags abstractions the task never asked for", () => { + const actual = { + files: ["a.js"], + linesAdded: 40, + newSymbols: ["Factory", "Manager", "Registry", "Proxy"], + }; + const r = assessFootprint("add a helper to a.js", actual); + assert.ok(r.warnings.some((w) => /new abstractions the task didn't ask for/.test(w))); + assert.deepEqual(r.footprint.unrequestedAbstractions.sort(), [ + "Factory", + "Manager", + "Proxy", + "Registry", + ]); +}); + +test("assessFootprint stays quiet when the footprint matches the ask", () => { + const actual = { files: ["auth.js"], linesAdded: 8, newSymbols: ["validateToken"] }; + const r = assessFootprint("add validateToken to auth.js", actual); + assert.equal(r.warnings.length, 0, "a proportionate change raises nothing"); +}); + +test("assessFootprint flags a large diff for a short task", () => { + const actual = { files: ["a.js"], linesAdded: 300, newSymbols: [] }; + const r = assessFootprint("fix the typo", actual); + assert.ok(r.warnings.some((w) => /lines added for a .* task/.test(w))); +}); + +test("leanRepo: no diff → quiet; injected diff → measured", () => { + const empty = leanRepo("/nope", "do a thing", { diff: "" }); + assert.equal(empty.hasDiff, false); + assert.equal(empty.warnings.length, 0); + const measured = leanRepo("/nope", "tiny tweak", { + diff: "+++ b/x.js\n+export class A{}\n+export class B{}\n+export class C{}\n+export class D{}\n", + }); + assert.equal(measured.hasDiff, true); + assert.ok(measured.warnings.length >= 1); +}); diff --git a/test/substrate.test.js b/test/substrate.test.js index b7aef13..90b9b97 100644 --- a/test/substrate.test.js +++ b/test/substrate.test.js @@ -131,3 +131,23 @@ test("substrateCheck: an explicit bidirectional:false threads through", () => { const r = substrateCheck(root, "Refactor computeTax in math.js", { bidirectional: false }); assert.equal(r.llm.bidirectional, false, "explicit override wins over the config default"); }); + +test("predictFailingTests: impacted test files + siblings of impacted source", async () => { + const { predictFailingTests, isTestFile } = await import("../src/substrate.js"); + const root = mkdtempSync(join(tmpdir(), "forge-pt-")); + writeFileSync(join(root, "math.js"), "export function computeTax(x){return x}\n"); + writeFileSync(join(root, "math.test.js"), "import './math.js'\n"); + writeFileSync(join(root, "helper.js"), "export function h(){}\n"); + assert.equal(isTestFile("a.test.js"), true); + assert.equal(isTestFile("src/a.js"), false); + const pred = predictFailingTests(root, ["math.js", "helper.js", "math.test.js"]); + assert.ok(pred.includes("math.test.js"), "direct impacted test + sibling of math.js"); + assert.ok(!pred.includes("helper.js"), "a source file with no sibling test is not predicted"); +}); + +test("substrateCheck surfaces impact.predictedTests", () => { + const root = repo(); + writeFileSync(join(root, "math.test.js"), "import './math.js'\n"); + const r = substrateCheck(root, "Update `computeTax` in `math.js`"); + assert.ok(Array.isArray(r.impact.predictedTests), "predictedTests present"); +});