From deab93bed6e281dccefc768b672231e68e598a80 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 00:35:12 +0200 Subject: [PATCH] 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 --- .../sf/tests/scaffold-drift.test.ts | 186 ++++++++++++++++++ .../sf/tests/scaffold-versioning.test.ts | 132 +++++-------- 2 files changed, 232 insertions(+), 86 deletions(-) create mode 100644 src/resources/extensions/sf/tests/scaffold-drift.test.ts diff --git a/src/resources/extensions/sf/tests/scaffold-drift.test.ts b/src/resources/extensions/sf/tests/scaffold-drift.test.ts new file mode 100644 index 000000000..3615d483f --- /dev/null +++ b/src/resources/extensions/sf/tests/scaffold-drift.test.ts @@ -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 }); + } + }); +}); diff --git a/src/resources/extensions/sf/tests/scaffold-versioning.test.ts b/src/resources/extensions/sf/tests/scaffold-versioning.test.ts index 8583bca6a..6b0826371 100644 --- a/src/resources/extensions/sf/tests/scaffold-versioning.test.ts +++ b/src/resources/extensions/sf/tests/scaffold-versioning.test.ts @@ -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( + "", + ); + assert.ok(m); + assert.equal(m?.template, "X.md"); + }); + test("returns null on missing prefix", () => { assert.equal(parseMarker(""), 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"); - }); -});