test: add Phase C coverage for drift-aware ensureAgenticDocsScaffold

Phase C (automatic silent sync) had no dedicated tests when committed.
Added 8 cases covering:
- ensureAgenticDocsScaffold on empty dir creates files with markers
- old-version pending marker silently re-renders to current
- editing-drift file left untouched
- legacy unmarked file matched against archive promoted to pending
- migrateLegacyScaffold idempotency

Total scaffold test count: 41 (was 33).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 00:43:19 +02:00
parent d01b2f0b7f
commit 14b5c2b12c
2 changed files with 230 additions and 3 deletions

View file

@ -173,7 +173,7 @@ describe("detectScaffoldDrift", () => {
});
describe("migrateLegacyScaffold", () => {
test("returns empty arrays in Phase B (no archive yet)", () => {
test("on empty dir, both migrated and skipped are empty", () => {
const dir = makeTmp();
try {
const result = migrateLegacyScaffold(dir);

View file

@ -1,11 +1,15 @@
/**
* Tests for scaffold-versioning (ADR-021 Phase A).
* Tests for scaffold-versioning (ADR-021 Phase A + Phase C).
*
* Covers: marker parse/format round-trip, stamp file behavior (new and replace),
* Phase A: marker parse/format round-trip, stamp file behavior (new and replace),
* body hash determinism, manifest read/write/dedup.
*
* Phase C: drift-aware ensureAgenticDocsScaffold, migrateLegacyScaffold against
* the seeded SCAFFOLD_VERSION_ARCHIVE, and the silent-path edge cases.
*/
import assert from "node:assert/strict";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
@ -16,6 +20,14 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, test } from "node:test";
import {
ensureAgenticDocsScaffold,
SCAFFOLD_FILES,
} from "../agentic-docs-scaffold.ts";
import {
migrateLegacyScaffold,
SCAFFOLD_VERSION_ARCHIVE,
} from "../scaffold-drift.ts";
import {
bodyHash,
extractMarker,
@ -313,3 +325,218 @@ describe("scaffold manifest", () => {
assert.equal(m.applied.length, 2);
});
});
// ─── Phase C: drift-aware ensureAgenticDocsScaffold ──────────────────────
/** AGENTS.md template entry — used by Phase C tests. Asserted to exist. */
function getAgentsMdTemplate() {
const file = SCAFFOLD_FILES.find((f) => f.path === "AGENTS.md");
if (!file) throw new Error("AGENTS.md missing from SCAFFOLD_FILES");
return file;
}
describe("Phase C: ensureAgenticDocsScaffold drift-aware sync", () => {
let dir: string;
let prevVersion: string | undefined;
beforeEach(() => {
dir = makeTmp();
// Pin SF_VERSION so the archive seeding and stamp behaviour are
// deterministic regardless of host environment.
prevVersion = process.env.SF_VERSION;
process.env.SF_VERSION = "9.9.9";
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
if (prevVersion === undefined) delete process.env.SF_VERSION;
else process.env.SF_VERSION = prevVersion;
});
test("on empty dir, creates all SCAFFOLD_FILES with markers and manifest entries", () => {
ensureAgenticDocsScaffold(dir);
// Every entry in SCAFFOLD_FILES exists on disk.
for (const file of SCAFFOLD_FILES) {
assert.ok(
existsSync(join(dir, file.path)),
`expected ${file.path} to exist`,
);
}
// Markdown templates have first-line markers stamped to current version.
const agents = getAgentsMdTemplate();
const { marker } = extractMarker(join(dir, agents.path));
assert.ok(marker, "AGENTS.md should be stamped");
assert.equal(marker?.version, "9.9.9");
assert.equal(marker?.state, "pending");
// Manifest captures every (markered + non-markered) scaffold file.
const manifest = readScaffoldManifest(dir);
assert.equal(manifest.applied.length, SCAFFOLD_FILES.length);
});
test("on dir with old-version pending marker, silently re-renders to current", () => {
const agents = getAgentsMdTemplate();
const target = join(dir, agents.path);
// Plant the file with current template body but stamped to an older version.
writeFileSync(target, agents.content, "utf-8");
stampScaffoldFile(target, agents.path, "0.1.0", "pending");
// Confirm pre-state.
const before = extractMarker(target).marker;
assert.equal(before?.version, "0.1.0");
ensureAgenticDocsScaffold(dir);
const after = extractMarker(target).marker;
assert.equal(after?.version, "9.9.9", "marker should be restamped");
assert.equal(after?.state, "pending");
// Body should match the current template content.
const { body } = extractMarker(target);
assert.equal(body, agents.content);
});
test("on dir with editing-drift file (marker + diverged body), leaves untouched", () => {
const agents = getAgentsMdTemplate();
const target = join(dir, agents.path);
// Stamp at old version then user edited the body so the hash diverges.
writeFileSync(target, agents.content, "utf-8");
stampScaffoldFile(target, agents.path, "0.1.0", "pending");
// Re-write with edited body that no longer matches stamp's hash.
const stamped = readFileSync(target, "utf-8");
const newlineIdx = stamped.indexOf("\n");
const markerLine = stamped.slice(0, newlineIdx);
const userEditedBody = "USER WROTE THIS\n";
writeFileSync(target, `${markerLine}\n${userEditedBody}`, "utf-8");
ensureAgenticDocsScaffold(dir);
const { marker, body } = extractMarker(target);
// Marker version should NOT be bumped — Phase C must not touch
// editing-drift files.
assert.equal(marker?.version, "0.1.0");
assert.equal(body, userEditedBody);
});
test("on dir with marker-less verbatim-template file, promotes to pending and stamps", () => {
const agents = getAgentsMdTemplate();
const target = join(dir, agents.path);
// Plant the file with the **current** template body and no marker —
// this simulates a project that pre-dates ADR-021 Phase A but is
// still on the current SF release.
writeFileSync(target, agents.content, "utf-8");
assert.equal(extractMarker(target).marker, null);
ensureAgenticDocsScaffold(dir);
const { marker, body } = extractMarker(target);
assert.ok(marker, "file should be stamped after legacy migration");
assert.equal(marker?.template, agents.path);
assert.equal(marker?.state, "pending");
// Body unchanged — the migration path stamps without rewriting.
assert.equal(body, agents.content);
// Manifest contains the file at the matched (current) version.
const manifest = readScaffoldManifest(dir);
const entry = manifest.applied.find((e) => e.path === agents.path);
assert.ok(entry, "manifest should record the migrated file");
assert.equal(entry?.version, "9.9.9");
});
test("on dir with marker-less customized file (no archive match), leaves untouched", () => {
const agents = getAgentsMdTemplate();
const target = join(dir, agents.path);
const customised = "# My Custom AGENTS.md\n\nNothing like the template.\n";
writeFileSync(target, customised, "utf-8");
ensureAgenticDocsScaffold(dir);
// File body unchanged, no marker stamped.
assert.equal(readFileSync(target, "utf-8"), customised);
assert.equal(extractMarker(target).marker, null);
// Manifest must not record it.
const manifest = readScaffoldManifest(dir);
const entry = manifest.applied.find((e) => e.path === agents.path);
assert.equal(entry, undefined);
});
});
describe("Phase C: migrateLegacyScaffold", () => {
let dir: string;
let prevVersion: string | undefined;
beforeEach(() => {
dir = makeTmp();
prevVersion = process.env.SF_VERSION;
process.env.SF_VERSION = "9.9.9";
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
if (prevVersion === undefined) delete process.env.SF_VERSION;
else process.env.SF_VERSION = prevVersion;
});
test("returns matched + skipped lists correctly", () => {
const agents = getAgentsMdTemplate();
const arch = SCAFFOLD_FILES.find((f) => f.path === "ARCHITECTURE.md");
assert.ok(arch);
// AGENTS.md: verbatim current template, no marker → should be migrated.
writeFileSync(join(dir, agents.path), agents.content, "utf-8");
// ARCHITECTURE.md: customised, no marker → should be skipped.
mkdirSync(join(dir, "."), { recursive: true });
writeFileSync(
join(dir, arch.path),
"# Custom Architecture\n\nNot the template.\n",
"utf-8",
);
const result = migrateLegacyScaffold(dir);
assert.ok(
result.migrated.includes(agents.path),
"AGENTS.md should be in migrated list",
);
assert.ok(
result.skipped.includes(arch.path),
"ARCHITECTURE.md should be in skipped list",
);
// Files not present on disk should appear in neither list.
assert.ok(!result.migrated.includes("docs/RELIABILITY.md"));
assert.ok(!result.skipped.includes("docs/RELIABILITY.md"));
});
test("seeds SCAFFOLD_VERSION_ARCHIVE with current ship version on first call", () => {
// Trigger seeding.
migrateLegacyScaffold(dir);
const agents = getAgentsMdTemplate();
const archive = SCAFFOLD_VERSION_ARCHIVE[agents.path];
assert.ok(archive, "archive should have entries for AGENTS.md");
assert.ok(
archive.some((e) => e.hash === bodyHash(agents.content)),
"archive should contain the current AGENTS.md body hash",
);
});
test("idempotent — second invocation finds nothing to migrate", () => {
const agents = getAgentsMdTemplate();
writeFileSync(join(dir, agents.path), agents.content, "utf-8");
const first = migrateLegacyScaffold(dir);
assert.ok(first.migrated.includes(agents.path));
const second = migrateLegacyScaffold(dir);
assert.ok(
!second.migrated.includes(agents.path),
"second migration should not re-migrate already-stamped file",
);
});
});