diff --git a/CHANGELOG.md b/CHANGELOG.md index 917c5f7..9bd09ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Context assembly + completeness gate (P4 of the substrate-v2 plan).** `forge context + ""` makes what goes into the window a budgeted optimization and makes + *sufficiency* a computed set. The required-knowledge set `R(edit)` — the target's + definitions, its hop-1 dependents from the atlas, sibling tests, and team lessons + trusted past val ≥ 0.8 — is derived, then covered by pinned items with a **compression + ladder** (full → head → pointer): a tight budget downgrades granularity instead of + silently dropping coverage. Optional items (trusted facts) fill remaining budget + greedily with per-source diminishing returns. `missing = R \ covered` becomes derived + clarifying questions ("the task names `X` but the repo doesn't define it — which file + implements it?"), shown in `forge substrate` and — under `FORGE_ENFORCE=1` — blocking: + acting on missing context is acting on a guess. Incomplete context stops being a + feeling and starts being a set difference. + - **Proof-carrying reuse cache (P3 of the substrate-v2 plan).** `forge reuse` turns "reuse already-generated code" from prose into a deterministic system: verified code becomes an `artifact` claim keyed by a normalized task fingerprint (volatile literals → diff --git a/src/cli.js b/src/cli.js index 64fc182..e90fb0d 100755 --- a/src/cli.js +++ b/src/cli.js @@ -21,6 +21,7 @@ const COMMANDS = { cortex: "self-correcting project memory — status / why ", ledger: "proof-carrying memory — stats / verify / show / blame / query / merge / import", reuse: "proof-carrying code cache — query / mint --file / stats", + context: "budgeted context assembly + completeness gate — what an edit NEEDS known", preflight: "assumption check — what a task names that the repo doesn't define", route: "recommend the cheapest capable model for a task (+ gateway config)", impact: "predict blast radius for a symbol or file from the atlas graph", @@ -427,6 +428,34 @@ async function run(argv) { process.exitCode = 1; return; } + if (cmd === "context") { + const { assemble, renderContext } = await import("./context.js"); + const { load: loadAtlas } = await import("./atlas.js"); + const { epochDay } = await import("./util.js"); + const json = argv.includes("--json"); + const bi = argv.indexOf("--budget"); + const budget = bi >= 0 ? Number(argv[bi + 1]) || undefined : undefined; + const task = argv + .filter((a, i) => i > 0 && !a.startsWith("--") && argv[i - 1] !== "--budget") + .join(" "); + if (!task) { + console.error('usage: forge context "" [--budget ] [--json]'); + process.exitCode = 1; + return; + } + const r = assemble(process.cwd(), task, { + atlas: loadAtlas(process.cwd()), + nowDay: epochDay(), + ...(budget ? { budget } : {}), + }); + if (json) { + const { block, ...rest } = r; + return console.log(JSON.stringify(rest, null, 2)); + } + console.log(renderContext(r)); + if (!r.ok) process.exitCode = 1; + return; + } if (cmd === "atlas") { const a = await import("./atlas.js"); const sub = argv[1] || "build"; diff --git a/src/context.js b/src/context.js new file mode 100644 index 0000000..1fc1fb9 --- /dev/null +++ b/src/context.js @@ -0,0 +1,273 @@ +// forge context — context assembly as a budgeted optimization with a completeness +// gate (docs/plans/substrate-v2/04-context-assembly.md). Two failures die here: +// over-stuffing (everything competes for the window on equal terms — P3 of the +// paper) and under-supplying (the agent edits a symbol without its callers, tests, +// or the team's lessons, then "assumes"). Selection gets an objective function +// (greedy knapsack by value density, with a compression ladder instead of silent +// drops) and sufficiency becomes a COMPUTED SET: required knowledge R(edit) from +// the atlas, missing = R \ covered — auto-fetched when resolvable, asked as a +// derived M2 question when not. Context insufficiency stops being a feeling. +import { existsSync, readFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { has as atlasHas, query as atlasQuery, impact } from "./atlas.js"; +import { claimText, val } from "./ledger.js"; +import { loadClaims, repoLedger } from "./ledger_store.js"; +import { referencedEntities } from "./preflight.js"; + +/** chars → tokens heuristic (calibrated in P8; consistent with the reuse estimator). */ +export const tokensOf = (text) => Math.ceil(String(text).length / 3.6); + +/** Lessons must be THIS trusted to enter the required set (spec §3: lessons*(S)). */ +export const LESSON_REQUIRED_VAL = 0.8; +/** Per-source diminishing returns for optional items (spec §2). */ +const SOURCE_DISCOUNT = 0.7; +/** Default assembly budget in tokens (callers pass the real per-tool cap). */ +export const DEFAULT_BUDGET = 6000; + +const readRel = (root, rel) => { + try { + return readFileSync(join(root, rel), "utf8"); + } catch { + return null; + } +}; + +// Sibling-test detection (same heuristics family as substrate's predictFailingTests, +// local here to keep the import graph one-directional: substrate → context, never back). +const isTestFile = (f) => /(\.|_)(test|spec)\.[jt]sx?$|(^|\/)(tests?|__tests__)\//.test(f); +function siblingTests(root, file) { + const dir = dirname(file); + const base = basename(file).replace(/\.[^.]+$/, ""); + const ext = file.match(/\.[^.]+$/)?.[0] ?? ".js"; + const candidates = [ + join(dir, `${base}.test${ext}`), + join(dir, `${base}.spec${ext}`), + join(dir, "__tests__", `${base}.test${ext}`), + join("test", `${base}.test${ext}`), + join("tests", `${base}.test${ext}`), + ]; + return candidates.filter((c) => existsSync(join(root, c))); +} + +/** + * The required-knowledge set R(edit) — computed, not vibes (spec §3): + * defs(S) ∪ blast₁(S) ∪ tests(S) ∪ lessons*(S) + * Each entry: { key, kind, name, resolvable } — unresolvable entries are exactly the + * derived clarifying questions. + */ +export function requiredSet(root, task, { atlas = null, claims = [], nowDay = 0 } = {}) { + const entities = referencedEntities(String(task || "")); + const R = []; + const targetFiles = new Set(); + + for (const s of entities.symbols) { + const known = atlas ? atlasHas(atlas, s) : false; + R.push({ key: `def:${s}`, kind: "def", name: s, resolvable: known }); + if (known) + for (const hit of atlasQuery(atlas, s)) + if (hit.name === s || hit.qname === s) targetFiles.add(hit.file); + if (known) R.push({ key: `deps:${s}`, kind: "deps", name: s, resolvable: true }); + } + for (const f of entities.files) { + const onDisk = existsSync(join(root, f)); + R.push({ key: `file:${f}`, kind: "file", name: f, resolvable: onDisk }); + if (onDisk) targetFiles.add(f); + } + for (const f of targetFiles) { + if (isTestFile(f)) continue; + for (const t of siblingTests(root, f)) + R.push({ key: `tests:${t}`, kind: "tests", name: t, resolvable: true }); + } + // Team lessons trusted past the floor, scope-matching the targets — required context: + // an agent editing without them repeats a mistake the ledger already paid for. + const names = new Set([...entities.symbols, ...entities.files.map((f) => basename(f))]); + for (const c of claims) { + if (c.kind !== "lesson" || c.tombstone || val(c, nowDay) < LESSON_REQUIRED_VAL) continue; + const trig = [...(c.body.trigger?.symbols ?? []), ...(c.body.trigger?.files ?? [])]; + if (trig.some((t) => names.has(t) || names.has(basename(String(t))))) + R.push({ key: `lesson:${c.id.slice(0, 8)}`, kind: "lesson", name: c.id, resolvable: true }); + } + // Dedupe by key, deterministic order. + const seen = new Set(); + return R.filter((r) => (seen.has(r.key) ? false : seen.add(r.key))).sort((a, b) => + a.key < b.key ? -1 : 1, + ); +} + +// An item is one injectable unit with a COMPRESSION LADDER: granularity variants from +// full text down to a one-line pointer. The optimizer may downgrade an item instead of +// dropping it — compression is a lossy move with a known cost, chosen explicitly, +// never by scroll-off (spec §2). +function fileItem(root, rel, { covers, source, score }) { + const text = readRel(root, rel); + if (text === null) return null; + const head = text.split("\n").slice(0, 25).join("\n"); + const variants = [ + { gran: "full", text: `// ${rel}\n${text}`, tokens: tokensOf(text) }, + { gran: "head", text: `// ${rel} (first 25 lines)\n${head}`, tokens: tokensOf(head) }, + { gran: "pointer", text: `- read ${rel}`, tokens: 8 }, + ]; + return { id: `${source}:${rel}`, source, covers, score, variants }; +} + +/** + * Assemble the context for a task: pinned required items (downgraded before dropped), + * optional items greedily by value density, and the missing set as derived questions. + * @param {string} root + * @param {string} task + * @param {{budget?:number, atlas?:any, claims?:any[], nowDay?:number}} [opts] + */ +export function assemble( + root, + task, + { budget = DEFAULT_BUDGET, atlas = null, claims, nowDay = 0 } = {}, +) { + const ledgerDir = repoLedger(root); + const allClaims = claims ?? (existsSync(join(ledgerDir, "claims")) ? loadClaims(ledgerDir) : []); + const required = requiredSet(root, task, { atlas, claims: allClaims, nowDay }); + + // --- build candidate items, keyed by what they cover ------------------------------- + const items = []; + for (const r of required) { + if (!r.resolvable) continue; + if (r.kind === "def") { + const hit = atlasQuery(atlas, r.name).find((s) => s.name === r.name || s.qname === r.name); + if (hit?.file) { + const it = fileItem(root, hit.file, { covers: [r.key], source: "def", score: 1 }); + if (it) items.push(it); + } + } else if (r.kind === "file") { + const it = fileItem(root, r.name, { covers: [r.key], source: "def", score: 1 }); + if (it) items.push(it); + } else if (r.kind === "tests") { + const it = fileItem(root, r.name, { covers: [r.key], source: "tests", score: 0.9 }); + if (it) items.push(it); + } else if (r.kind === "deps" && atlas) { + const hop1 = impact(atlas, r.name, { maxHops: 1 }) + .impacted.filter((x) => x.hopDistance === 1) + .slice(0, 12); + const text = hop1.length + ? [ + `direct dependents of ${r.name} (edit these with it or verify them):`, + ...hop1.map((x) => ` - ${x.node.name} (${x.node.file}, via ${x.edgeKinds[0]})`), + ].join("\n") + : `no direct dependents of ${r.name} found in the atlas`; + items.push({ + id: `deps:${r.name}`, + source: "deps", + covers: [r.key], + score: 1, + variants: [{ gran: "full", text, tokens: tokensOf(text) }], + }); + } else if (r.kind === "lesson") { + const c = allClaims.find((x) => x.id === r.name); + if (c) { + const text = `lesson (val ${val(c, nowDay).toFixed(2)}): ${c.body.correctedBehavior}`; + items.push({ + id: `lesson:${c.id.slice(0, 8)}`, + source: "lesson", + covers: [r.key], + score: 0.95, + variants: [{ gran: "full", text, tokens: tokensOf(text) }], + }); + } + } + } + // Optional extras: trusted scope-matching facts (nice-to-have, never required). + for (const c of allClaims) { + if (c.kind !== "fact" || c.tombstone) continue; + const v = val(c, nowDay); + if (v < 0.5) continue; + const text = `fact: ${claimText(c)}`; + items.push({ + id: `fact:${c.id.slice(0, 8)}`, + source: "fact", + covers: [], + score: 0.3 + 0.4 * v, + variants: [{ gran: "full", text, tokens: tokensOf(text) }], + }); + } + + // A symbol's definition and an explicitly named file often resolve to the SAME file — + // merge items by id (union of covered keys) so one span never gets injected twice. + const byId = new Map(); + for (const it of items) { + const prev = byId.get(it.id); + if (prev) { + prev.covers = [...new Set([...prev.covers, ...it.covers])]; + prev.score = Math.max(prev.score, it.score); + } else byId.set(it.id, it); + } + const merged = [...byId.values()]; + + // --- selection: pin required coverage, downgrade before dropping ------------------- + const pinned = merged.filter((i) => i.covers.length); + const optional = merged + .filter((i) => !i.covers.length) + .sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : 1)); + const chosen = pinned.map((i) => ({ item: i, v: 0 })); // v = variant index + const used = () => chosen.reduce((n, c) => n + c.item.variants[c.v].tokens, 0); + // Downgrade the largest pinned item one rung at a time until the pins fit the budget. + while (used() > budget) { + const cand = chosen + .filter((c) => c.v < c.item.variants.length - 1) + .sort((a, b) => b.item.variants[b.v].tokens - a.item.variants[a.v].tokens)[0]; + if (!cand) break; // everything is already a pointer — required coverage beats budget + cand.v++; + } + // Greedy fill by value density with per-source diminishing returns. + const perSource = {}; + for (const item of optional) { + const variant = item.variants[0]; + const discount = SOURCE_DISCOUNT ** (perSource[item.source] ?? 0); + if (used() + variant.tokens > budget) continue; + chosen.push({ item, v: 0 }); + perSource[item.source] = (perSource[item.source] ?? 0) + 1; + if (discount < 0.2) break; // fourth+ item from one source: value has decayed away + } + + const covered = new Set(chosen.flatMap((c) => c.item.covers)); + const missing = required.filter((r) => !r.resolvable || !covered.has(r.key)); + const questions = missing + .filter((r) => !r.resolvable) + .map((r) => + r.kind === "def" + ? `The task names \`${r.name}\` but the repo doesn't define it — which file implements it (or is it new)?` + : `The task names \`${r.name}\` but that file doesn't exist — where should this live?`, + ); + + return { + ok: missing.length === 0, + budget, + tokens: used(), + required: required.map((r) => r.key), + covered: [...covered].sort(), + missing: missing.map((r) => r.key), + questions, + selection: chosen.map((c) => ({ + id: c.item.id, + source: c.item.source, + gran: c.item.variants[c.v].gran, + tokens: c.item.variants[c.v].tokens, + })), + block: chosen.map((c) => c.item.variants[c.v].text).join("\n\n"), + }; +} + +/** Human rendering for `forge context`. */ +export function renderContext(r) { + const lines = ["Forge context — budgeted assembly + completeness gate", ""]; + lines.push( + ` budget: ${r.tokens}/${r.budget} tokens · required ${r.required.length} · ${r.ok ? "COMPLETE" : "INCOMPLETE"}`, + ); + for (const s of r.selection) lines.push(` + ${s.id} [${s.gran}] ${s.tokens}t`); + if (r.missing.length) { + lines.push("", " missing (computed, not a feeling):"); + for (const m of r.missing) lines.push(` - ${m}`); + } + if (r.questions.length) { + lines.push("", " ask before acting:"); + for (const q of r.questions) lines.push(` ? ${q}`); + } + return lines.join("\n"); +} diff --git a/src/substrate.js b/src/substrate.js index 00814ca..6d864e9 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; 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 { assemble as assembleContext } from "./context.js"; import { matchingLessons } from "./cortex.js"; import { leanRepo } from "./lean.js"; import { load as loadLessons } from "./lessons_store.js"; @@ -234,6 +235,18 @@ export function substrateCheck( return { tier: "miss" }; // cache trouble must never block the gate } })(); + // P4 context assembly: what the edit REQUIRES to be known (defs, dependents, tests, + // trusted lessons) vs what can be supplied — missing becomes derived questions, not + // assumptions. Explicit gate only (file reads are too heavy for the per-prompt hook). + const context = allowBuild + ? (() => { + try { + return assembleContext(root, text, { atlas, nowDay: epochDay() }); + } catch { + return null; // assembly trouble must never block the gate + } + })() + : null; const scopedFiles = [...new Set([...entities.files, ...impactedFiles])]; const scope = scopedFiles.length ? decompose(root, scopedFiles) @@ -250,6 +263,14 @@ export function substrateCheck( route, entities, reuse, + context: context && { + ok: context.ok, + tokens: context.tokens, + budget: context.budget, + required: context.required.length, + missing: context.missing, + questions: context.questions, + }, impact: { targets: impactTargets, reports: impacts, impactedFiles, predictedTests }, scope, memory: { @@ -339,6 +360,15 @@ export function enforceDecision(result, { enforce, blastThreshold = 25 } = {}) { reason: `Forge gate (enforcing): this task has no concrete anchor to act on — clarify before I start:\n${qs}${tail}`, }; } + // P4 completeness gate: the task names things the repo cannot supply — the questions + // are DERIVED from the missing-knowledge set, so acting now means acting on a guess. + if (result.context && !result.context.ok && result.context.questions.length) { + const qs = result.context.questions.map((q) => ` • ${q}`).join("\n"); + return { + block: true, + reason: `Forge gate (enforcing): the required context can't be assembled from this repo — resolve before I edit:\n${qs}${tail}`, + }; + } const blast = result.impact?.impactedFiles?.length ?? 0; if (blast >= blastThreshold) { return { @@ -371,6 +401,13 @@ export function renderSubstrate(result) { ` reuse: ${result.reuse.tier.toUpperCase()} hit — verified ${a?.form ?? "artifact"}${a?.path ? ` at ${a.path}` : ""} (\`forge ledger show ${a?.id.slice(0, 8)}\`) — start from it, don't regenerate`, ); } + if (result.context) { + lines.push( + "", + ` context: ${result.context.ok ? "complete" : "INCOMPLETE"} — ${result.context.required} required item(s), ${result.context.tokens}/${result.context.budget} tokens (\`forge context\` for the assembly)`, + ); + for (const q of result.context.questions ?? []) lines.push(` ? ${q}`); + } 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) diff --git a/test/context.test.js b/test/context.test.js new file mode 100644 index 0000000..5600f04 --- /dev/null +++ b/test/context.test.js @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { build as buildAtlas } from "../src/atlas.js"; +import { assemble, renderContext, requiredSet, tokensOf } from "../src/context.js"; +import { mintClaim, outcomeRecord } from "../src/ledger.js"; +import { appendEvidence, putClaim, repoLedger } from "../src/ledger_store.js"; +import { enforceDecision } from "../src/substrate.js"; + +// A small repo where the required-knowledge set is unambiguous: computeTax is defined +// in src/tax.js, called from src/checkout.js (the hop-1 dependent), covered by a +// sibling test — and one team lesson about it is trusted past the floor. +function fixture() { + const root = mkdtempSync(join(tmpdir(), "forge-context-")); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync( + join(root, "src", "tax.js"), + "export function computeTax(amount) {\n return amount * 0.2;\n}\n", + ); + writeFileSync( + join(root, "src", "checkout.js"), + 'import { computeTax } from "./tax.js";\nexport function checkout(cart) {\n return cart.total + computeTax(cart.total);\n}\n', + ); + writeFileSync( + join(root, "src", "tax.test.js"), + 'import { computeTax } from "./tax.js";\n// asserts the 20% rate\n', + ); + const atlas = buildAtlas({ root }); + return { root, atlas }; +} + +const trustedLesson = (root) => { + const dir = repoLedger(root); + const minted = mintClaim({ + kind: "lesson", + body: { + correctedBehavior: "Never change the tax rate without updating the checkout snapshot tests.", + trigger: { symbols: ["computeTax"], files: [], keywords: [], action: "edit" }, + whatWentWrong: "A rate change silently broke checkout totals.", + }, + scope: { level: "symbol" }, + t: 0, + }); + putClaim(dir, minted.claim); + // Four confirmations: val = (1 + 4·0.9)/(2 + 4·0.9) ≈ 0.82 — past the 0.8 floor. + // (Three lands at 0.787 and is correctly NOT trusted enough to be required.) + for (const ref of ["run:1", "run:2", "pr:7", "pr:9"]) + appendEvidence( + dir, + minted.claim.id, + outcomeRecord({ oracle: "human.accept", result: "confirm", ref, t: 0 }).outcome, + ); + return minted.claim; +}; + +test("requiredSet: defs, hop-1 dependents, sibling tests, and trusted lessons — computed", () => { + const { root, atlas } = fixture(); + const lesson = trustedLesson(root); + const R = requiredSet(root, "update computeTax in src/tax.js to support regional rates", { + atlas, + claims: [ + { + ...lesson, + evidence: ["h1", "h2", "h3", "h4"].map((h) => ({ + oracle: "human.accept", + result: "confirm", + ref: `r:${h}`, + t: 0, + w: 0.9, + h, + })), + }, + ], + nowDay: 0, + }); + const keys = R.map((r) => r.key); + assert.ok(keys.includes("def:computeTax"), "the symbol's definition is required"); + assert.ok(keys.includes("deps:computeTax"), "its direct dependents are required"); + assert.ok(keys.includes("file:src/tax.js"), "the named file is required"); + assert.ok( + keys.some((k) => k.startsWith("tests:")), + "the sibling test is required", + ); + assert.ok( + keys.some((k) => k.startsWith("lesson:")), + "a trusted team lesson about the target is required context", + ); + assert.ok( + R.every((r) => r.resolvable), + "everything here is supplyable from the repo", + ); +}); + +test("requiredSet: an untrusted lesson (val at the prior) is NOT required", () => { + const { root, atlas } = fixture(); + const lesson = trustedLesson(root); + const R = requiredSet(root, "update computeTax", { + atlas, + claims: [{ ...lesson, evidence: [] }], // no oracle history → val 0.5 < 0.8 + nowDay: 0, + }); + assert.ok(!R.some((r) => r.kind === "lesson")); +}); + +test("assemble: complete context — everything required is covered within budget", () => { + const { root, atlas } = fixture(); + trustedLesson(root); + const r = assemble(root, "update computeTax in src/tax.js", { atlas, nowDay: 0 }); + assert.equal(r.ok, true); + assert.deepEqual(r.missing, []); + assert.ok(r.tokens <= r.budget); + assert.ok(r.selection.some((s) => s.id.startsWith("deps:"))); + assert.ok(r.block.includes("checkout"), "the dependent is IN the assembled context"); + assert.ok(r.block.includes("Never change the tax rate"), "the team lesson rides along"); +}); + +test("assemble: a tight budget downgrades granularity instead of dropping coverage", () => { + const { root, atlas } = fixture(); + const roomy = assemble(root, "update computeTax in src/tax.js", { atlas, nowDay: 0 }); + const tight = assemble(root, "update computeTax in src/tax.js", { + atlas, + nowDay: 0, + budget: 60, + }); + assert.equal(tight.ok, true, "coverage survives the squeeze"); + assert.deepEqual(tight.missing, []); + assert.ok(tight.tokens < roomy.tokens); + assert.ok( + tight.selection.some((s) => s.gran !== "full"), + "compression ladder engaged (a lossy move chosen explicitly, not by scroll-off)", + ); + assert.deepEqual(roomy.covered, tight.covered, "same coverage either way"); +}); + +test("assemble: an unknown symbol becomes a DERIVED question, and the gate can block on it", () => { + const { root, atlas } = fixture(); + const r = assemble(root, "refactor the applyDiscountMatrix flow", { atlas, nowDay: 0 }); + assert.equal(r.ok, false); + assert.ok(r.missing.includes("def:applyDiscountMatrix")); + assert.match(r.questions[0], /applyDiscountMatrix.*doesn't define it/); + // The enforcing gate turns the computed missing-set into a block: + const decision = enforceDecision( + { context: { ok: false, missing: r.missing, questions: r.questions } }, + { enforce: true }, + ); + assert.equal(decision.block, true); + assert.match(decision.reason, /applyDiscountMatrix/); + const advisory = enforceDecision( + { context: { ok: false, missing: r.missing, questions: r.questions } }, + { enforce: false }, + ); + assert.equal(advisory.block, false, "advisory by default — enforcing is opt-in"); +}); + +test("assemble: deterministic — same repo, same task, same bytes", () => { + const { root, atlas } = fixture(); + const a = assemble(root, "update computeTax in src/tax.js", { atlas, nowDay: 0 }); + const b = assemble(root, "update computeTax in src/tax.js", { atlas, nowDay: 0 }); + assert.deepEqual(a, b); +}); + +test("renderContext + tokensOf: sane output surface", () => { + const { root, atlas } = fixture(); + const r = assemble(root, "update computeTax in src/tax.js", { atlas, nowDay: 0 }); + const out = renderContext(r); + assert.match(out, /COMPLETE/); + assert.match(out, /\+ deps:computeTax/); + assert.equal(tokensOf("x".repeat(36)), 10); +});