test: split scaffold tests into per-module files; fix require() in ESM test

Subagent split scaffold tests into scaffold-versioning.test.ts (Phase A)
and scaffold-drift.test.ts (Phase B). Fixed an ESM-incompatible
require("node:fs") in one drift test that was breaking with
--experimental-strip-types. All 33 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 00:35:12 +02:00
parent 6f9a99da0a
commit deab93bed6
2 changed files with 232 additions and 86 deletions

View file

@ -0,0 +1,186 @@
/**
* Tests for scaffold-drift (ADR-021 Phase B).
*
* Covers the five drift buckets and the manifestPresent flag.
*/
import assert from "node:assert/strict";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, test } from "node:test";
import { SCAFFOLD_FILES } from "../agentic-docs-scaffold.ts";
import { detectScaffoldDrift, migrateLegacyScaffold } from "../scaffold-drift.ts";
import {
SCAFFOLD_MANIFEST_RELPATH,
stampScaffoldFile,
} from "../scaffold-versioning.ts";
function makeTmp(): string {
return mkdtempSync(join(tmpdir(), "sf-scaffold-drift-"));
}
/** Pick a scaffold entry that ships a Markdown file (not the .siftignore). */
function pickMarkdownTarget(): { path: string; content: string } {
const f = SCAFFOLD_FILES.find((s) => s.path === "AGENTS.md");
if (!f) throw new Error("AGENTS.md must be in SCAFFOLD_FILES for this test");
return { path: f.path, content: f.content };
}
describe("detectScaffoldDrift", () => {
let dir: string;
beforeEach(() => {
dir = makeTmp();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("missing file → bucket missing", () => {
const report = detectScaffoldDrift(dir);
const item = report.items.find((i) => i.path === "AGENTS.md");
assert.ok(item);
assert.equal(item?.bucket, "missing");
assert.ok(report.countsByBucket.missing > 0);
});
test("manifestPresent is false when no manifest exists", () => {
const report = detectScaffoldDrift(dir);
assert.equal(report.manifestPresent, false);
// Report still computed
assert.ok(report.items.length > 0);
});
test("manifestPresent is true when manifest has entries", () => {
mkdirSync(join(dir, ".sf"), { recursive: true });
writeFileSync(
join(dir, SCAFFOLD_MANIFEST_RELPATH),
JSON.stringify({
schemaVersion: 1,
applied: [
{
path: "AGENTS.md",
template: "AGENTS.md",
version: "0.0.0",
appliedAt: "2026-01-01T00:00:00Z",
stateAtApply: "pending",
contentHash: "sha256:abc",
},
],
}),
"utf-8",
);
const report = detectScaffoldDrift(dir);
assert.equal(report.manifestPresent, true);
});
test("file with current marker, body hash matches → not upgradable, not editing-drift", () => {
const target = pickMarkdownTarget();
const fp = join(dir, target.path);
writeFileSync(fp, target.content, "utf-8");
const SF_VERSION = process.env.SF_VERSION || "0.0.0";
stampScaffoldFile(fp, target.path, SF_VERSION);
const report = detectScaffoldDrift(dir);
const item = report.items.find((i) => i.path === target.path);
assert.ok(item);
assert.notEqual(item?.bucket, "upgradable");
assert.notEqual(item?.bucket, "editing-drift");
assert.notEqual(item?.bucket, "missing");
assert.equal(item?.currentVersion, SF_VERSION);
});
test("file with older-version marker, body hash matches → upgradable", () => {
const target = pickMarkdownTarget();
const fp = join(dir, target.path);
writeFileSync(fp, target.content, "utf-8");
// Stamp with a deliberately-old version. This works regardless of what
// process.env.SF_VERSION is, as long as SF_VERSION > 0.0.1 — which is
// also true when SF_VERSION is unset (defaults to "0.0.0" → not <
// "0.0.1"). To make this robust, force a higher SF_VERSION for the
// scope of this test.
const prev = process.env.SF_VERSION;
process.env.SF_VERSION = "9.9.9";
try {
stampScaffoldFile(fp, target.path, "0.0.1");
const report = detectScaffoldDrift(dir);
const item = report.items.find((i) => i.path === target.path);
assert.ok(item);
assert.equal(item?.bucket, "upgradable");
assert.equal(item?.currentVersion, "0.0.1");
assert.equal(item?.shipVersion, "9.9.9");
} finally {
if (prev === undefined) delete process.env.SF_VERSION;
else process.env.SF_VERSION = prev;
}
});
test("file with marker, body hash drifted → editing-drift", () => {
const target = pickMarkdownTarget();
const fp = join(dir, target.path);
writeFileSync(fp, target.content, "utf-8");
stampScaffoldFile(fp, target.path, "1.0.0");
// Now mutate the body without restamping (simulate user/agent edit)
const stamped = readFileSync(fp, "utf-8");
const lines = stamped.split("\n");
// Append a new line to the body without touching the marker
const mutated = lines[0] + "\n" + lines.slice(1).join("\n") + "\n# user edit\n";
writeFileSync(fp, mutated, "utf-8");
const report = detectScaffoldDrift(dir);
const item = report.items.find((i) => i.path === target.path);
assert.ok(item);
assert.equal(item?.bucket, "editing-drift");
assert.equal(item?.hashDrifted, true);
});
test("file with marker state=completed → bucket customized", () => {
const target = pickMarkdownTarget();
const fp = join(dir, target.path);
writeFileSync(fp, target.content, "utf-8");
stampScaffoldFile(fp, target.path, "1.0.0", "completed");
const report = detectScaffoldDrift(dir);
const item = report.items.find((i) => i.path === target.path);
assert.ok(item);
assert.equal(item?.bucket, "customized");
});
test("file without marker → bucket untracked", () => {
const target = pickMarkdownTarget();
const fp = join(dir, target.path);
writeFileSync(fp, "# Custom Content\n\nuser-edited\n", "utf-8");
const report = detectScaffoldDrift(dir);
const item = report.items.find((i) => i.path === target.path);
assert.ok(item);
assert.equal(item?.bucket, "untracked");
assert.equal(item?.currentVersion, undefined);
});
test("countsByBucket sums to items.length", () => {
const report = detectScaffoldDrift(dir);
const sum = Object.values(report.countsByBucket).reduce((a, b) => a + b, 0);
assert.equal(sum, report.items.length);
});
});
describe("migrateLegacyScaffold", () => {
test("returns empty arrays in Phase B (no archive yet)", () => {
const dir = makeTmp();
try {
const result = migrateLegacyScaffold(dir);
assert.deepEqual(result.migrated, []);
assert.deepEqual(result.skipped, []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -1,8 +1,8 @@
/**
* Tests for scaffold-versioning + scaffold-drift (ADR-021 Phase A+B).
* Tests for scaffold-versioning (ADR-021 Phase A).
*
* Covers: marker parse/format round-trip, stamp file behavior (new and replace),
* body hash determinism, manifest read/write/dedup, drift detection buckets.
* body hash determinism, manifest read/write/dedup.
*/
import assert from "node:assert/strict";
import {
@ -29,12 +29,9 @@ import {
type ScaffoldManifestEntry,
type ScaffoldMarker,
} from "../scaffold-versioning.ts";
import { detectScaffoldDrift } from "../scaffold-drift.ts";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeTmp(): string {
return mkdtempSync(join(tmpdir(), "sf-scaffold-test-"));
return mkdtempSync(join(tmpdir(), "sf-scaffold-versioning-"));
}
// ─── parseMarker / formatMarker ──────────────────────────────────────────────
@ -59,6 +56,14 @@ describe("parseMarker", () => {
assert.equal(m?.state, "editing");
});
test("ignores unknown extra fields", () => {
const m = parseMarker(
"<!-- sf-doc: version=1 template=X.md state=pending hash=sha256:0 future=ok -->",
);
assert.ok(m);
assert.equal(m?.template, "X.md");
});
test("returns null on missing prefix", () => {
assert.equal(parseMarker("<!-- not-sf-doc: version=1 -->"), null);
});
@ -94,12 +99,24 @@ describe("formatMarker round-trip", () => {
const parsed = parseMarker(formatted);
assert.deepEqual(parsed, original);
});
test("formatMarker emits a single line terminated with newline", () => {
const out = formatMarker({
version: "1",
template: "X.md",
state: "pending",
hash: "sha256:0",
});
assert.ok(out.endsWith("\n"));
// Should contain exactly one newline (the trailing terminator)
assert.equal(out.split("\n").length, 2);
});
});
// ─── bodyHash ────────────────────────────────────────────────────────────────
describe("bodyHash", () => {
test("returns same hash for same body", () => {
test("returns the same hash for the same body", () => {
const a = bodyHash("# Hello\n\nworld\n");
const b = bodyHash("# Hello\n\nworld\n");
assert.equal(a, b);
@ -109,6 +126,13 @@ describe("bodyHash", () => {
test("different bodies hash differently", () => {
assert.notEqual(bodyHash("a"), bodyHash("b"));
});
test("normalizes a single leading newline", () => {
// stamp prepends a newline-terminated marker line, so the body as
// written has a leading newline boundary; bodyHash strips one for
// stability across representations.
assert.equal(bodyHash("body\n"), bodyHash("\nbody\n"));
});
});
// ─── stampScaffoldFile ───────────────────────────────────────────────────────
@ -122,7 +146,7 @@ describe("stampScaffoldFile", () => {
rmSync(dir, { recursive: true, force: true });
});
test("on a missing file, writes marker prepended to content", () => {
test("on a missing file, writes marker + content", () => {
const path = join(dir, "AGENTS.md");
writeFileSync(path, "# Agent Map\n\nbody\n", "utf-8");
stampScaffoldFile(path, "AGENTS.md", "2.75.2");
@ -135,12 +159,11 @@ describe("stampScaffoldFile", () => {
assert.equal(marker?.version, "2.75.2");
assert.equal(marker?.state, "pending");
// Body preserved after the marker line
const body = lines.slice(1).join("\n");
assert.match(body, /# Agent Map/);
});
test("on an existing-with-marker file, replaces the marker, preserves body", () => {
test("on existing-with-marker file, replaces marker + preserves body", () => {
const path = join(dir, "X.md");
writeFileSync(
path,
@ -161,15 +184,23 @@ describe("stampScaffoldFile", () => {
assert.match(raw, /body content/);
});
test("hash in marker matches hash of body content", () => {
test("hash in marker matches bodyHash of extracted body", () => {
const path = join(dir, "Y.md");
const body = "# Y\n\nlorem ipsum\n";
writeFileSync(path, body, "utf-8");
writeFileSync(path, "# Y\n\nlorem ipsum\n", "utf-8");
stampScaffoldFile(path, "Y.md", "1.0.0");
const { marker, body: extractedBody } = extractMarker(path);
const { marker, body } = extractMarker(path);
assert.ok(marker);
assert.equal(marker?.hash, bodyHash(body));
});
test("creates parent directories when needed", () => {
const path = join(dir, "nested/deep/F.md");
mkdirSync(join(dir, "nested/deep"), { recursive: true });
writeFileSync(path, "body\n", "utf-8");
stampScaffoldFile(path, "nested/deep/F.md", "0.1.0");
const { marker } = extractMarker(path);
assert.ok(marker);
assert.equal(marker?.hash, bodyHash(extractedBody));
});
});
@ -282,74 +313,3 @@ describe("scaffold manifest", () => {
assert.equal(m.applied.length, 2);
});
});
// ─── detectScaffoldDrift ─────────────────────────────────────────────────────
describe("detectScaffoldDrift", () => {
let dir: string;
beforeEach(() => {
dir = makeTmp();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
test("returns a structured report with countsByBucket", () => {
const report = detectScaffoldDrift(dir);
assert.ok(report.items, "items array exists");
assert.ok(report.countsByBucket, "countsByBucket exists");
// On an empty dir, every scaffold file is missing
assert.ok(report.countsByBucket.missing > 0);
});
test("manifestPresent flag reflects manifest having entries", () => {
const r1 = detectScaffoldDrift(dir);
assert.equal(r1.manifestPresent, false);
mkdirSync(join(dir, ".sf"), { recursive: true });
// Write a manifest with an actual entry — empty arrays are considered absent.
writeFileSync(
join(dir, SCAFFOLD_MANIFEST_RELPATH),
JSON.stringify({
schemaVersion: 1,
applied: [
{
path: "AGENTS.md",
template: "AGENTS.md",
version: "2.75.2",
appliedAt: "2026-05-02T00:00:00Z",
stateAtApply: "pending",
contentHash: "sha256:abc",
},
],
}),
"utf-8",
);
const r2 = detectScaffoldDrift(dir);
assert.equal(r2.manifestPresent, true);
});
test("file with marker matching current SF version → not in upgradable", () => {
// Pick a known scaffold file (AGENTS.md should always be in SCAFFOLD_FILES)
const path = join(dir, "AGENTS.md");
writeFileSync(path, "# Agent Map\n\nbody\n", "utf-8");
const SF_VERSION = process.env.SF_VERSION || "0.0.0";
stampScaffoldFile(path, "AGENTS.md", SF_VERSION);
const report = detectScaffoldDrift(dir);
const item = report.items.find((i) => i.path === "AGENTS.md");
assert.ok(item, "AGENTS.md should be in report");
// With current version stamped and matching hash, bucket is customized or upgradable=false
assert.notEqual(item?.bucket, "missing");
});
test("file without marker → bucket untracked", () => {
const path = join(dir, "AGENTS.md");
writeFileSync(path, "# Custom Content\n\nuser-edited\n", "utf-8");
const report = detectScaffoldDrift(dir);
const item = report.items.find((i) => i.path === "AGENTS.md");
assert.ok(item);
assert.equal(item?.bucket, "untracked");
});
});