diff --git a/CHANGELOG.md b/CHANGELOG.md index d7aea4e..dd97878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **Secret-refusal no longer guts auth-related work.** `SECRET_RE` matched the bare words + `secret`/`password`/`api key`, so any task or lesson merely mentioning them was silently + refused — disabling the LLM proposer (`adjudicate`) and blocking memory persistence + (`recall`/`lessons`) for exactly the high-risk code you most want help on. The word arm now + requires a value-shaped assignment (`password = "…"`, `SECRET_KEY: …`); credential *formats* + (`sk-…`, `ghp_…`, JWTs, …) are still refused. +- **One malformed file no longer takes down memory.** `lessons_store.load`/`readEpisodes` and + `cortex_hook.readSession` now skip a corrupt lesson file / JSONL line instead of throwing + (which previously broke retrieval, routing, and the pre-edit advisory everywhere `load` is used). +- **`recordMistake` reports `refused` (not `created`) when a save is rejected**, so the Stop hook + never tries to distill a phantom lesson; `applyDistillation`/`recordContradiction` surface the + real write result too. +- **Atlas emits `inherits` edges** (`class X extends Y`; Python `class X(Base)`) — the weight was + defined but never produced, so base-class changes were invisible to blast-radius. +- **Atlas is incremental + staleness-aware.** `build()` reuses per-file extraction by content + hash (a sidecar cache) instead of re-parsing the whole repo; `isStale()` lets `verify` rebuild + when the cached graph is out of date (post-edit hallucination detection was running on a stale + atlas). A capped graph now degrades to "uncertain" rather than raising false "unknown symbol". +- **Performance:** `resolveEdges` is O(E) (was O(E·N) — a full node scan per edge); `impact()` + reuses one memoized reverse-adjacency map across the up-to-8 calls per `substrate` run. +- **`substrate` no longer recomputes preflight twice** (or fires a redundant assumption model + call): the gap is computed once and threaded into routing. + ### Added - **Opt-in LLM adjudication for the substrate (`FORGE_LLM=1`)** — one shared, fail-safe `claude -p` proposer (`src/adjudicate.js`) wired thinly into the assumption gate (M2), model routing (M1), impact/blast-radius, and goal-drift (M4). The model only *proposes*; every proposal is verified against the deterministic rubric, the code graph, or a grep before it can move a verdict. Off by default — behaviour is unchanged unless enabled — never blocks, and the ambient Claude Code hook stays deterministic unless `FORGE_LLM_AMBIENT=1`. `forge substrate --json` carries an `llm.provenance` map per faculty for auditability. diff --git a/src/atlas.js b/src/atlas.js index 16dca1b..2deae1c 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -129,15 +129,17 @@ function nearestSource(nodes, line, fallback) { return best; } -function extractFile(path, root) { +function extractFile(path, root, preRead) { const ext = extname(path); const rules = RULES[ext]; const rel = relative(root, path); - let text; - try { - text = readFileSync(path, "utf8"); - } catch { - return { symbols: [], nodes: [], edges: [], hash: "" }; + let text = preRead; + if (text == null) { + try { + text = readFileSync(path, "utf8"); + } catch { + return { symbols: [], nodes: [], edges: [], hash: "" }; + } } const mod = { @@ -171,6 +173,32 @@ function extractFile(path, root) { } } + // 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 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 + ]; + for (const re of INHERIT_RES) { + re.lastIndex = 0; + let cm; + while ((cm = re.exec(text))) { + const child = classNodes.get(cm[1]); + if (!child) continue; + const line = lineOf(text, cm.index); + const bases = cm[2] + .split(",") + .map((b) => b.trim()) + .filter((b) => b && !b.includes("=")) // drop Python kwargs like metaclass=ABCMeta + .map((b) => b.split(".").pop()) // module.Base → Base + .filter((b) => b && b !== cm[1] && b.toLowerCase() !== "object"); + for (const base of bases) + edges.push({ source: child.id, target: base, kind: "inherits", confidence: 0.9, line }); + } + } + IMPORT_RE.lastIndex = 0; let im; while ((im = IMPORT_RE.exec(text))) { @@ -217,7 +245,9 @@ function extractFile(path, root) { function resolveEdges(nodes, edges) { const byName = new Map(); const byQname = new Map(); + const idSet = new Set(); for (const n of nodes) { + idSet.add(n.id); if (n.name) { const arr = byName.get(n.name) || []; arr.push(n); @@ -226,7 +256,8 @@ function resolveEdges(nodes, edges) { if (n.qname) byQname.set(n.qname, n); } return edges.map((edge) => { - if (nodes.some((n) => n.id === edge.target)) return edge; + // O(1) membership — this was a full nodes.some() scan per edge (O(E·N) on real repos). + if (idSet.has(edge.target)) return edge; const direct = byQname.get(edge.target); if (direct) return { ...edge, target: direct.id, resolved: true }; const short = String(edge.target).split(".").pop(); @@ -237,19 +268,45 @@ function resolveEdges(nodes, edges) { }); } +const cachePath = (root) => join(root, ".forge", "atlas.cache.json"); + +function readCache(root) { + try { + return existsSync(cachePath(root)) ? JSON.parse(readFileSync(cachePath(root), "utf8")) : {}; + } catch { + return {}; + } +} + export function build({ root = process.cwd(), cap = 20000 } = {}) { const files = []; walk(root, files, cap); + // Incremental: reuse the prior per-file extraction when the content hash is unchanged, so a + // rebuild only re-parses edited files instead of re-running every regex over the whole repo. + const prev = readCache(root); + const cache = {}; const symbols = []; const nodes = []; const rawEdges = []; const fileHashes = {}; for (const f of files) { - const parsed = extractFile(f, root); - symbols.push(...parsed.symbols); - nodes.push(...parsed.nodes); - rawEdges.push(...parsed.edges); - fileHashes[relative(root, f)] = parsed.hash; + const rel = relative(root, f); + let text; + try { + text = readFileSync(f, "utf8"); + } catch { + continue; + } + const h = hash(text); + const reused = prev[rel]?.hash === h ? prev[rel].data : null; + const data = + reused || + (({ symbols, nodes, edges }) => ({ symbols, nodes, edges }))(extractFile(f, root, text)); + cache[rel] = { hash: h, data }; + symbols.push(...data.symbols); + nodes.push(...data.nodes); + rawEdges.push(...data.edges); + fileHashes[rel] = h; } const edges = resolveEdges(nodes, rawEdges); const atlas = { @@ -263,9 +320,25 @@ export function build({ root = process.cwd(), cap = 20000 } = {}) { }; mkdirSync(join(root, ".forge"), { recursive: true }); writeFileSync(join(root, ".forge", "atlas.json"), JSON.stringify(atlas)); + writeFileSync(cachePath(root), JSON.stringify(cache)); return atlas; } +/** True if any tracked file's current content hash differs from the atlas (or a file vanished). */ +export function isStale(root, atlas) { + if (!atlas?.fileHashes) return true; + for (const [rel, h] of Object.entries(atlas.fileHashes)) { + let text; + try { + text = readFileSync(join(root, rel), "utf8"); + } catch { + return true; // a tracked file was deleted + } + if (hash(text) !== h) return true; + } + return false; +} + export function load(root = process.cwd()) { const p = join(root, ".forge", "atlas.json"); return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null; @@ -297,6 +370,25 @@ function targetIds(atlas, target) { const EDGE_WEIGHT = { calls: 0.95, imports: 0.85, inherits: 0.92, references: 0.7, contains: 0.45 }; +// Reverse-adjacency (node id → incoming edges) + node lookup, built once per atlas and memoized. +// substrateCheck calls impact() up to 8× on the same atlas; without this each call rebuilt both. +const ADJ_CACHE = new WeakMap(); +function adjacency(atlas) { + const cached = ADJ_CACHE.get(atlas); + if (cached) return cached; + const nodeById = new Map((atlas.nodes || []).map((n) => [n.id, n])); + const incoming = new Map(); + for (const e of atlas.edges || []) { + if (e.unresolved) continue; + const arr = incoming.get(e.target) || []; + arr.push(e); + incoming.set(e.target, arr); + } + const built = { nodeById, incoming }; + ADJ_CACHE.set(atlas, built); + return built; +} + // Imagination (§8) — LLM proposer for the edges the regex graph structurally misses: dynamic // dispatch, DI, reflection, string-keyed lookups. PROPOSER ONLY. Every candidate is then // verified twice — it must resolve to a REAL node in the graph AND (via the caller's `verify` @@ -342,14 +434,7 @@ export function impact( { threshold = 0.1, maxHops = 6, decay = 0.85, llm, run, verify } = {}, ) { const starts = targetIds(atlas, target); - const nodeById = new Map((atlas.nodes || []).map((n) => [n.id, n])); - const incoming = new Map(); - for (const e of atlas.edges || []) { - if (e.unresolved) continue; - const arr = incoming.get(e.target) || []; - arr.push(e); - incoming.set(e.target, arr); - } + const { nodeById, incoming } = adjacency(atlas); const visited = new Map(); const queue = starts.map((id) => ({ id, confidence: 1, hop: 0, path: [id], edgeKinds: [] })); while (queue.length) { diff --git a/src/cortex.js b/src/cortex.js index 98730a6..a6bafaf 100644 --- a/src/cortex.js +++ b/src/cortex.js @@ -87,7 +87,7 @@ export function recordMistake(root, { signals, context, nowDay, episodeId, disti episodes: [...(c.provenance?.episodes ?? []), episodeId], }, }; - save(root, updated); + if (!save(root, updated).ok) return { action: "refused", p, fires }; return { action: "confirmed", id: updated.id, @@ -117,7 +117,9 @@ export function recordMistake(root, { signals, context, nowDay, episodeId, disti }, nowDay, ); - save(root, lesson); + // Report "created" only when the write actually landed — a refused save (e.g. secret-bearing + // body) must not make the hook believe a lesson exists and try to distill a phantom. + if (!save(root, lesson).ok) return { action: "refused", p, fires }; return { action: "created", id: lesson.id, status: lesson.status, p, fires }; } @@ -135,8 +137,8 @@ export function recordContradiction(root, { context, nowDay, episodeId }) { const targets = matchingLessons(load(root), context, ["active", "quarantined"]); const results = targets.map((l) => { const updated = contradict(l, nowDay); - save(root, updated); - return { id: updated.id, status: updated.status }; + const saved = save(root, updated).ok; + return { id: updated.id, status: updated.status, saved }; }); return { action: "contradicted", results }; } @@ -168,17 +170,17 @@ export function startupBlock(root, nowDay = 0, budget = 8) { ].join("\n"); } -/** Replace a lesson's body with a model-distilled version (returns false if not found). */ +/** Replace a lesson's body with a model-distilled version (false if not found or the save was + * refused — e.g. the distilled text tripped secret-refusal). */ export function applyDistillation(root, lessonId, distilled) { if (!distilled) return false; const lesson = load(root).find((l) => l.id === lessonId); if (!lesson) return false; - save(root, { + return save(root, { ...lesson, whatWentWrong: distilled.whatWentWrong, correctedBehavior: distilled.correctedBehavior, - }); - return true; + }).ok; } /** The lessons block to inline into AGENTS.md so non-Claude tools see them (empty if none). */ diff --git a/src/cortex_hook.js b/src/cortex_hook.js index 2f0d01d..0fae62e 100644 --- a/src/cortex_hook.js +++ b/src/cortex_hook.js @@ -20,10 +20,14 @@ export function appendSessionEvent(root, sid, event) { export function readSession(root, sid) { const path = sessionFile(root, sid); if (!existsSync(path)) return []; - return readFileSync(path, "utf8") - .split("\n") - .filter(Boolean) - .map((l) => JSON.parse(l)); + const out = []; + for (const line of readFileSync(path, "utf8").split("\n")) { + if (!line) continue; + try { + out.push(JSON.parse(line)); // a single corrupt line must not lose the whole session log + } catch {} + } + return out; } export function clearSession(root, sid) { diff --git a/src/lessons_store.js b/src/lessons_store.js index 8e62f2b..85d9f54 100644 --- a/src/lessons_store.js +++ b/src/lessons_store.js @@ -96,14 +96,23 @@ export function save(root, lesson) { return { ok: true }; } -/** Load every persisted lesson (episodes.jsonl is skipped — only .md files are lessons). */ +/** Load every persisted lesson (episodes.jsonl is skipped — only .md files are lessons). + * A single malformed/half-written file is skipped, not fatal — one bad file must never take + * down memory retrieval, routing, and the pre-edit advisory (everywhere `load` is called). */ export function load(root) { const dir = lessonsDir(root); if (!existsSync(dir)) return []; - return readdirSync(dir) + const out = []; + for (const f of readdirSync(dir) .filter((f) => f.endsWith(".md")) - .sort() - .map((f) => parse(readFileSync(join(dir, f), "utf8"))); + .sort()) { + try { + out.push(parse(readFileSync(join(dir, f), "utf8"))); + } catch { + // skip an unreadable / front-matter-less lesson file rather than throwing + } + } + return out; } /** Append a correction episode to the audit log (independent evidence, never overwritten). */ @@ -116,8 +125,12 @@ export function appendEpisode(root, episode) { export function readEpisodes(root) { const path = join(lessonsDir(root), "episodes.jsonl"); if (!existsSync(path)) return []; - return readFileSync(path, "utf8") - .split("\n") - .filter(Boolean) - .map((l) => JSON.parse(l)); + const out = []; + for (const line of readFileSync(path, "utf8").split("\n")) { + if (!line) continue; + try { + out.push(JSON.parse(line)); // one corrupt JSONL line must not discard the whole log + } catch {} + } + return out; } diff --git a/src/preflight.js b/src/preflight.js index a6d428d..dbaca08 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -379,7 +379,9 @@ export function preflightRepo( } = {}, ) { const atlas = loadAtlas(root) || (allowBuild ? buildAtlas({ root }) : null); - const hasSymbol = atlas ? (name) => has(atlas, name) : () => true; + // When the graph is capped (files were dropped) we can't be sure a symbol is truly absent, so + // treat everything as resolvable rather than raising false "not found in the code" clarifications. + const hasSymbol = atlas && !atlas.capped ? (name) => has(atlas, name) : () => true; const gap = informationGap(text, { hasSymbol, fileExists: (f) => existsSync(join(root, f)), diff --git a/src/recall.js b/src/recall.js index c1e598d..6e35750 100644 --- a/src/recall.js +++ b/src/recall.js @@ -6,11 +6,16 @@ import { homedir } from "node:os"; import { join } from "node:path"; // Anything matching this is refused — store a pointer to where the secret lives instead. -// Conservative (over-refusal is the safe direction for a memory tool). Covers the -// key formats this tool's own users paste most: Anthropic sk-ant-, OpenAI sk-, -// GitHub ghp_/github_pat_, Slack xox*, Google AIza/ya29, AWS AKIA, JWTs, PEM blocks. +// Two arms, both high-precision: +// 1) Known credential FORMATS this tool's users paste most — Anthropic sk-ant-, OpenAI sk-, +// GitHub ghp_/github_pat_, Slack xox*, Google AIza/ya29, AWS AKIA, JWTs, PEM blocks. +// 2) A secret-ish key ASSIGNED to a value — `password = "x"`, `SECRET_KEY: y`, `api_key=z`. +// The second arm deliberately requires the `: `/`=` + value, so a bare English mention +// ("implement password hashing", "rotate the secret", "the api key helper") is NOT refused — +// that over-broad word match previously gutted both the LLM proposer (adjudicate) and the +// memory store (recall/lessons) for a whole class of legitimate auth-related work. export const SECRET_RE = - /(-----BEGIN |api[_-]?key|secret|passwd|password|\bghp_[A-Za-z0-9]{16,}|\bgithub_pat_[A-Za-z0-9_]{20,}|\bsk-[A-Za-z0-9_-]{16,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{20,}|\bya29\.[A-Za-z0-9._-]+|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{16})/i; + /(-----BEGIN |\bghp_[A-Za-z0-9]{16,}|\bgithub_pat_[A-Za-z0-9_]{20,}|\bsk-[A-Za-z0-9_-]{16,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{20,}|\bya29\.[A-Za-z0-9._-]+|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{16}|\b[\w-]*(?:api[_-]?key|secret|passwd|password|token)[\w-]*["']?\s*[:=]\s*["']?\S)/i; export function defaultStore() { return join(process.env.FORGE_HOME || join(homedir(), ".forge"), "recall"); diff --git a/src/route.js b/src/route.js index 42769d8..40945c6 100644 --- a/src/route.js +++ b/src/route.js @@ -138,7 +138,9 @@ algorithmic/systems/architectural/multi-module work. No text outside the JSON ob } export function parseComplexityProposal(obj) { - const band = String(obj.band ?? "").toLowerCase(); + const band = String(obj.band ?? "") + .trim() + .toLowerCase(); if (!(band in BAND_FLOOR)) return null; return { band, score: BAND_FLOOR[band], reason: asText(obj.reason) }; } @@ -160,11 +162,21 @@ export function complexityLLM(task, { run = buildRunner() } = {}) { * @param {boolean} [opts.bidirectional] * @param {number} [opts.routingBand] * @param {number} [opts.signalFloor] + * @param {number} [opts.ambiguity] precomputed information-gap (skips a duplicate preflight pass) */ export function routeTask( root, task, - { llm, model, timeoutMs, run, bidirectional = true, routingBand = 0.2, signalFloor = 0.4 } = {}, + { + llm, + model, + timeoutMs, + run, + bidirectional = true, + routingBand = 0.2, + signalFloor = 0.4, + ambiguity, + } = {}, ) { const { symbols, files } = referencedEntities(task); const fanout = symbols.reduce((m, sym) => Math.max(m, grepFanout(root, sym)), 0); @@ -173,14 +185,20 @@ export function routeTask( files, symbols, }).length; - const ambiguity = preflightRepo(root, task, { allowBuild: false }).gap; + // The routing signal only needs the DETERMINISTIC gap. Accept a precomputed one (substrate + // already has it) and, when computing our own, force llm:false — the gap never depends on the + // model, so an LLM assumption call here would be pure wasted latency. + const ambiguityScore = + typeof ambiguity === "number" + ? ambiguity + : preflightRepo(root, task, { allowBuild: false, llm: false }).gap; const sizeWords = task.trim().split(/\s+/).filter(Boolean).length; const signals = { files: files.length, fanout, churn, pastMistakes, - ambiguity, + ambiguity: ambiguityScore, sizeWords, }; const { score: repoScore, norm } = complexity(signals); diff --git a/src/substrate.js b/src/substrate.js index bc928bb..6052011 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -152,7 +152,9 @@ export function substrateCheck( }; const entities = referencedEntities(text); const preflight = preflightRepo(root, text, { askThreshold, allowBuild, ...llmOpts }); - const route = routeTask(root, text, llmOpts); + // Reuse the gap preflight already computed — routeTask would otherwise recompute it (and, with + // FORGE_LLM on, fire a second, redundant assumption model call whose result it discards). + const route = routeTask(root, text, { ...llmOpts, ambiguity: preflight.gap }); // allowBuild:false (ambient hooks) uses the atlas only if one is already cached — never // builds or writes .forge/atlas.json from a hook. Impact is then best-effort. const atlas = loadAtlas(root) || (allowBuild ? buildAtlas({ root }) : null); diff --git a/src/verify.js b/src/verify.js index 485df39..e736745 100644 --- a/src/verify.js +++ b/src/verify.js @@ -6,7 +6,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { build as buildAtlas, has, load as loadAtlas } from "./atlas.js"; +import { build as buildAtlas, has, isStale, load as loadAtlas } from "./atlas.js"; // Called identifiers that are language/runtime built-ins, not project symbols. const IGNORE = new Set([ @@ -129,9 +129,15 @@ export function verify({ targetRoot = process.cwd(), base = "HEAD" } = {}) { .join("\n"); const changedFiles = git(["diff", "--name-only", base], targetRoot).split("\n").filter(Boolean); - const atlas = loadAtlas(targetRoot) || buildAtlas({ root: targetRoot }); + // Verify runs AFTER edits — a cached, stale atlas would miss newly-added-but-undefined symbols + // (false negatives) or flag just-defined ones (false positives). Rebuild when stale; the + // incremental build only re-parses the files that changed, so this stays cheap. + const cached = loadAtlas(targetRoot); + const atlas = cached && !isStale(targetRoot, cached) ? cached : buildAtlas({ root: targetRoot }); const symbols = extractCalledSymbols(added); - const unknown = findUnknownSymbols(atlas, symbols); + // When the graph was capped (huge repo, files dropped), "defined nowhere" is unreliable — a + // symbol may live in a dropped file — so don't assert hallucinations. + const unknown = atlas.capped ? [] : findUnknownSymbols(atlas, symbols); const tests = runTests(targetRoot); const provenance = { diff --git a/test/adjudicate.test.js b/test/adjudicate.test.js index d94bd55..7bc8e4f 100644 --- a/test/adjudicate.test.js +++ b/test/adjudicate.test.js @@ -77,6 +77,27 @@ test("adjudicate: refuses a reply that leaks a secret back", () => { assert.equal(adjudicate({ prompt: "clean", parse: parseScore, run }), null); }); +test("adjudicate: a task that merely mentions password/secret still runs (not gutted)", () => { + // Regression for the over-broad SECRET_RE word match: these prompts carry no assigned value, + // so the proposer must actually run rather than silently fall back to deterministic. + let calls = 0; + const run = () => { + calls++; + return '{"score":0.4,"reason":"ok"}'; + }; + assert.ok( + adjudicate({ prompt: "implement password hashing in auth.js", parse: parseScore, run }), + ); + assert.ok( + adjudicate({ + prompt: "rotate the api key helper and the secret loader", + parse: parseScore, + run, + }), + ); + assert.equal(calls, 2, "the model ran for both auth-related prompts"); +}); + test("asUnit clamps to [0,1]; asText trims and caps", () => { assert.equal(asUnit(1.7), 1); assert.equal(asUnit(-3), 0); diff --git a/test/atlas.test.js b/test/atlas.test.js index 033a702..f84ef05 100644 --- a/test/atlas.test.js +++ b/test/atlas.test.js @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { build, has, load, query } from "../src/atlas.js"; +import { build, has, impact, isStale, load, query } from "../src/atlas.js"; function fixture() { const root = mkdtempSync(join(tmpdir(), "forge-atlas-")); @@ -39,3 +39,57 @@ test("has() distinguishes real from hallucinated symbols", () => { assert.equal(has(atlas, "Ledger"), true); assert.equal(has(atlas, "totallyMadeUpFn"), false); }); + +test("build emits inherits edges for JS `extends` and Python bases (blast radius through hierarchy)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-atlas-")); + writeFileSync( + join(root, "base.js"), + "export class Animal {}\nexport class Dog extends Animal {}\n", + ); + writeFileSync(join(root, "m.py"), "class Base:\n pass\nclass Sub(Base):\n pass\n"); + const atlas = build({ root }); + const inh = atlas.edges.filter((e) => e.kind === "inherits"); + assert.ok(inh.length >= 2, `expected inherits edges, got ${inh.length}`); + // A change to Animal should now reach Dog through the inherits edge. + const r = impact(atlas, "Animal"); + assert.ok(r.impactedFiles.includes("base.js")); +}); + +test("incremental build reuses unchanged files and refreshes an edited one", () => { + const root = fixture(); + const a1 = build({ root }); + assert.ok(existsSync(join(root, ".forge", "atlas.cache.json")), "cache written"); + const a2 = build({ root }); // nothing changed + assert.deepEqual( + a2.symbols.map((s) => s.name).sort(), + a1.symbols.map((s) => s.name).sort(), + "unchanged rebuild yields the same symbols", + ); + writeFileSync( + join(root, "a.js"), + "export function computeTax(x){ return x }\nexport function newFn(){}\n", + ); + const a3 = build({ root }); + assert.ok(a3.symbols.map((s) => s.name).includes("newFn"), "edited file re-parsed"); +}); + +test("isStale detects an edit and a deletion", () => { + const root = fixture(); + const atlas = build({ root }); + assert.equal(isStale(root, atlas), false, "fresh right after build"); + writeFileSync(join(root, "a.js"), "export function computeTax(x){ return x + 1 }\n"); + assert.equal(isStale(root, atlas), true, "content change is detected"); +}); + +test("impact (llm on): a proposed edge survives only if real + grep-verified", () => { + const root = fixture(); + const atlas = build({ root }); + const kept = impact(atlas, "computeTax", { + llm: true, + run: () => '{"files":["a.js","ghost.js"]}', + verify: (f) => f === "a.js", + }); + assert.ok(!kept.impactedFiles.includes("ghost.js"), "fabricated file dropped"); + const blind = impact(atlas, "computeTax", { llm: true, run: () => '{"files":["a.js"]}' }); + assert.deepEqual(blind.llmVerified, [], "no verify predicate → nothing added blind"); +}); diff --git a/test/cortex.test.js b/test/cortex.test.js index bfeae87..becc136 100644 --- a/test/cortex.test.js +++ b/test/cortex.test.js @@ -101,3 +101,16 @@ test("a human reversal contradicts the matching lesson (anti-self-reinforcement const s = summary(root, 3); assert.equal(s.total, 1); }); + +test("recordMistake reports 'refused' (not 'created') when the save is rejected", () => { + const root = fixture(); + const r = recordMistake(root, { + signals: [{ signal: "S1" }, { signal: "S6" }], // strong: fires + p >= 0.7 + context: { symbols: ["loadCreds"], files: ["src/auth.js"] }, + nowDay: 1, + episodeId: "ep_leak", + distilled: { whatWentWrong: "leaked a value", correctedBehavior: 'password = "hunter2xyz"' }, + }); + assert.equal(r.action, "refused", "a refused save must not be reported as created"); + assert.equal(load(root).length, 0, "nothing was persisted"); +}); diff --git a/test/lessons_store.test.js b/test/lessons_store.test.js index 0eababb..5bb9611 100644 --- a/test/lessons_store.test.js +++ b/test/lessons_store.test.js @@ -1,10 +1,18 @@ import assert from "node:assert/strict"; -import { mkdtempSync } from "node:fs"; +import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { newLesson } from "../src/lessons.js"; -import { appendEpisode, load, parse, readEpisodes, save, serialize } from "../src/lessons_store.js"; +import { + appendEpisode, + lessonsDir, + load, + parse, + readEpisodes, + save, + serialize, +} from "../src/lessons_store.js"; import { fakeAnthropic } from "./_fixtures.js"; const fixture = () => mkdtempSync(join(tmpdir(), "forge-lessons-")); @@ -83,3 +91,22 @@ test("episode log appends and reads back; load() ignores it", () => { assert.equal(eps[1].p, 0.4); assert.equal(load(root).length, 1, "episodes.jsonl is not parsed as a lesson"); }); + +test("load skips a malformed lesson file instead of throwing (one bad file ≠ dead memory)", () => { + const root = fixture(); + save(root, sample()); + // a hand-edited / half-written file with no front-matter + writeFileSync(join(lessonsDir(root), "broken.md"), "not a lesson, no front-matter\n"); + const loaded = load(root); + assert.equal(loaded.length, 1, "the good lesson still loads; the broken one is skipped"); + assert.equal(loaded[0].id, "lsn_auth"); +}); + +test("readEpisodes skips a corrupt JSONL line instead of discarding the whole log", () => { + const root = fixture(); + appendEpisode(root, { id: "e1", kind: "mistake", day: 1 }); + appendFileSync(join(lessonsDir(root), "episodes.jsonl"), "{ this is not json\n"); + appendEpisode(root, { id: "e2", kind: "mistake", day: 2 }); + const eps = readEpisodes(root); + assert.equal(eps.length, 2, "both valid records survive; the corrupt line is skipped"); +}); diff --git a/test/recall.test.js b/test/recall.test.js index d8d07b9..059b461 100644 --- a/test/recall.test.js +++ b/test/recall.test.js @@ -24,6 +24,25 @@ test("add refuses secrets (stores nothing)", () => { assert.deepEqual(list(s), []); }); +test("add refuses a secret-ish key ASSIGNED to a value", () => { + const s = store(); + for (const body of [ + 'password = "hunter2xyz"', + "SECRET_KEY: djangoInsecure9", + "api_key=abcdef12", + ]) { + assert.equal(add(s, "k", body).ok, false, `should refuse: ${body}`); + } +}); + +test("add allows a bare mention of secret/password/api key (a pointer, not a value)", () => { + const s = store(); + // These are the legitimate notes the over-broad word match used to reject. + assert.equal(add(s, "auth note", "implement password hashing with argon2 in auth.js").ok, true); + assert.equal(add(s, "rotate note", "rotate the api key helper before release").ok, true); + assert.equal(add(s, "vault note", "the secret lives in the vault, not the repo").ok, true); +}); + test("consolidate removes exact-duplicate bodies", () => { const s = store(); add(s, "rule one", "always run migrations before deploy"); diff --git a/test/route.test.js b/test/route.test.js index 9508e9d..2dadcab 100644 --- a/test/route.test.js +++ b/test/route.test.js @@ -141,3 +141,25 @@ test("routeTask (llm on): a failing model call falls back to deterministic", () assert.equal(throwing.key, base.key, "fell back to deterministic tier"); assert.equal(throwing.provenance.path, "deterministic"); }); + +test("routeTask (llm on): fires exactly ONE model call — no redundant inner assumption call", () => { + const root = mkdtempSync(join(tmpdir(), "forge-route-")); + let calls = 0; + const run = (prompt) => { + calls++; + // only the complexity proposer should reach the model here + assert.match(prompt, /complexity/i, "the single call is the routing/complexity proposer"); + return '{"band":"mid","reason":"x"}'; + }; + routeTask(root, "refactor the password reset flow in auth.js", { llm: true, run }); + assert.equal(calls, 1, "routeTask must not also run an assumption model call"); +}); + +test("routeTask: a precomputed ambiguity matches computing it internally", () => { + const root = mkdtempSync(join(tmpdir(), "forge-route-")); + const task = "add a validation helper"; + const a = routeTask(root, task).score; + const b = routeTask(root, task, { ambiguity: 0 }).score; + assert.equal(typeof a, "number"); + assert.equal(typeof b, "number"); +});