diff --git a/src/resources/extensions/sf/auto-post-unit.ts b/src/resources/extensions/sf/auto-post-unit.ts index 63413cd7e..b344312e3 100644 --- a/src/resources/extensions/sf/auto-post-unit.ts +++ b/src/resources/extensions/sf/auto-post-unit.ts @@ -1430,6 +1430,24 @@ export async function postUnitPostVerification( } } + // ── Scaffold-keeper dispatch (ADR-021 Phase D) ── + // After milestone completion, fire-and-forget the scaffold-keeper to + // detect editing-drift docs and stage `.proposed` artifacts. Failure + // is non-fatal and must never break the auto loop, hence the broad try. + if (s.currentUnit?.type === "complete-milestone") { + try { + const { dispatchScaffoldKeeperFireAndForget } = await import( + "./scaffold-keeper.js" + ); + dispatchScaffoldKeeperFireAndForget(s.basePath, ctx); + } catch (e) { + debugLog("postUnit", { + phase: "scaffold-keeper-dispatch", + error: e instanceof Error ? e.message : String(e), + }); + } + } + // ── Post-unit hooks ── if (s.currentUnit && !s.stepMode) { const hookUnit = checkPostUnitHooks( diff --git a/src/resources/extensions/sf/auto.ts b/src/resources/extensions/sf/auto.ts index 2de82eb89..02e520a4d 100644 --- a/src/resources/extensions/sf/auto.ts +++ b/src/resources/extensions/sf/auto.ts @@ -946,6 +946,23 @@ export async function stopAuto( }); } + // ── Step 7b: Scaffold-keeper dispatch (ADR-021 Phase D) ── + // At session close, detect editing-drift docs and stage `.proposed` + // artifacts via the scaffold-keeper. Fire-and-forget — must not block + // the cleanup path or break the stop sequence on failure. + try { + if (ctx && s.basePath) { + const { dispatchScaffoldKeeperFireAndForget } = await import( + "./scaffold-keeper.js" + ); + dispatchScaffoldKeeperFireAndForget(s.basePath, ctx); + } + } catch (e) { + debugLog("stop-cleanup-scaffold-keeper", { + error: e instanceof Error ? e.message : String(e), + }); + } + // ── Step 8: Ledger notification ── try { // Tag with structured metadata so headless-events.ts classifies via diff --git a/src/resources/extensions/sf/commands-bootstrap.ts b/src/resources/extensions/sf/commands-bootstrap.ts index a38f3364c..e7025e2d8 100644 --- a/src/resources/extensions/sf/commands-bootstrap.ts +++ b/src/resources/extensions/sf/commands-bootstrap.ts @@ -64,6 +64,10 @@ const TOP_LEVEL_SUBCOMMANDS = [ cmd: "codebase", desc: "Generate, refresh, and inspect the codebase map cache", }, + { + cmd: "scaffold", + desc: "Inspect or refresh ADR-021 versioned scaffold docs", + }, ] as const; function filterStartsWith( @@ -349,6 +353,19 @@ function getSfArgumentCompletions(prefix: string) { ); } + if (parts[0] === "scaffold" && parts.length <= 2) { + return filterStartsWith( + partial, + [ + { + cmd: "sync", + desc: "Refresh ADR-021 scaffold docs (drift report + apply pending upgrades)", + }, + ], + "scaffold", + ); + } + if (parts[0] === "dispatch" && parts.length <= 2) { return filterStartsWith( partial, diff --git a/src/resources/extensions/sf/commands-scaffold-sync.ts b/src/resources/extensions/sf/commands-scaffold-sync.ts new file mode 100644 index 000000000..b92e2af36 --- /dev/null +++ b/src/resources/extensions/sf/commands-scaffold-sync.ts @@ -0,0 +1,258 @@ +/** + * commands-scaffold-sync.ts — `/sf scaffold sync` (ADR-021 Phase E). + * + * Manual escape hatch over the Phase C automatic scaffold sync. Lets the user: + * - Inspect drift without modifying anything (`--dry-run`). + * - Force the same operation that would run on next SF startup (default). + * - Run scaffold-keeper synchronously for editing-drift items + * (`--include-editing`) when Phase D has shipped. + * - Restrict the operation to a path glob (`--only=`). + * + * The command is intentionally thin: it dispatches to + * `ensureAgenticDocsScaffold` and renders `detectScaffoldDrift`. It does not + * reimplement either. + */ + +import type { ExtensionCommandContext } from "@singularity-forge/pi-coding-agent"; + +import { ensureAgenticDocsScaffold } from "./agentic-docs-scaffold.js"; +import { projectRoot } from "./commands/context.js"; +import { + detectScaffoldDrift, + type ScaffoldDriftItem, + type ScaffoldDriftReport, +} from "./scaffold-drift.js"; + +export interface ScaffoldSyncOptions { + dryRun: boolean; + includeEditing: boolean; + only?: string; +} + +/** Parse the args string for `/sf scaffold sync`. Tolerates extra whitespace. */ +export function parseScaffoldSyncArgs(args: string): ScaffoldSyncOptions { + const trimmed = (args || "").trim(); + const tokens = trimmed.length > 0 ? trimmed.split(/\s+/) : []; + const opts: ScaffoldSyncOptions = { + dryRun: false, + includeEditing: false, + }; + for (const tok of tokens) { + if (tok === "--dry-run") { + opts.dryRun = true; + } else if (tok === "--include-editing") { + opts.includeEditing = true; + } else if (tok.startsWith("--only=")) { + const value = tok.slice("--only=".length).trim(); + if (value.length > 0) opts.only = value; + } + } + return opts; +} + +/** + * Match a scaffold path against an `--only=` value. + * + * Supports the simple cases the brief calls out: `*` is treated as a wildcard, + * and as a fallback we accept plain prefix or suffix matches. We deliberately + * do not pull in a glob library — Phase E is the escape hatch, not a + * production globber. + */ +export function matchesOnly(path: string, glob: string | undefined): boolean { + if (!glob) return true; + if (path === glob) return true; + if (glob.includes("*")) { + // Build a forgiving regex: escape regex metachars, then turn `*` into `.*`. + const pattern = glob + .split("*") + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) + .join(".*"); + try { + return new RegExp(`^${pattern}$`).test(path); + } catch { + return false; + } + } + // Plain string: accept prefix or suffix match. Suffix is useful for + // `--only=RELIABILITY.md`; prefix for `--only=harness/`. + return path.startsWith(glob) || path.endsWith(glob); +} + +/** Filter a drift report's items by an --only glob. Counts are recomputed. */ +export function applyOnlyFilter( + report: ScaffoldDriftReport, + only: string | undefined, +): ScaffoldDriftReport { + if (!only) return report; + const items = report.items.filter((i) => matchesOnly(i.path, only)); + const counts: ScaffoldDriftReport["countsByBucket"] = { + missing: 0, + upgradable: 0, + "editing-drift": 0, + untracked: 0, + customized: 0, + }; + for (const item of items) { + counts[item.bucket] += 1; + } + return { + items, + countsByBucket: counts, + manifestPresent: report.manifestPresent, + }; +} + +function formatReportTable(report: ScaffoldDriftReport): string { + const c = report.countsByBucket; + const lines = [ + "Scaffold drift report:", + ` Missing : ${c.missing}`, + ` Upgradable : ${c.upgradable}`, + ` Pending : ${c.upgradable}`, + ` Editing-drift: ${c["editing-drift"]}`, + ` Untracked : ${c.untracked}`, + ` Customized : ${c.customized}`, + ]; + const review = report.items.filter( + (i) => i.bucket === "missing" || i.bucket === "editing-drift", + ); + if (review.length > 0) { + lines.push(""); + lines.push("Items needing review:"); + for (const item of review) { + lines.push(` ${item.path} (${item.bucket})`); + } + } + return lines.join("\n"); +} + +/** + * Format a brief deltas summary comparing pre- and post-sync drift reports. + * Helps the user see at a glance what the sync actually did. + */ +function formatSyncDelta( + before: ScaffoldDriftReport, + after: ScaffoldDriftReport, +): string | null { + const wroteMissing = before.countsByBucket.missing - after.countsByBucket.missing; + const upgraded = before.countsByBucket.upgradable - after.countsByBucket.upgradable; + const promoted = before.countsByBucket.untracked - after.countsByBucket.untracked; + if (wroteMissing <= 0 && upgraded <= 0 && promoted <= 0) return null; + const parts: string[] = []; + if (wroteMissing > 0) parts.push(`wrote ${wroteMissing} missing`); + if (upgraded > 0) parts.push(`refreshed ${upgraded} pending`); + if (promoted > 0) parts.push(`promoted ${promoted} legacy-matched`); + return `Sync complete — ${parts.join(", ")}.`; +} + +/** + * Lazy import for Phase D's scaffold-keeper dispatcher. Returns `null` if + * Phase D has not shipped yet, in which case `--include-editing` reports the + * feature as unavailable rather than crashing. + */ +async function tryLoadScaffoldKeeper(): Promise< + | ((basePath: string, items: ScaffoldDriftItem[]) => Promise<{ proposed: string[] }>) + | null +> { + try { + // Phase D is expected to expose this from a `scaffold-keeper.js` sibling. + const mod: unknown = await import("./scaffold-keeper.js").catch(() => null); + if ( + mod && + typeof (mod as { dispatchScaffoldKeeperIfNeeded?: unknown }) + .dispatchScaffoldKeeperIfNeeded === "function" + ) { + return ( + mod as { + dispatchScaffoldKeeperIfNeeded: ( + basePath: string, + items: ScaffoldDriftItem[], + ) => Promise<{ proposed: string[] }>; + } + ).dispatchScaffoldKeeperIfNeeded; + } + } catch { + // fall through + } + return null; +} + +/** + * Top-level handler for `/sf scaffold sync [args]`. + * + * Always notifies via `ctx.ui.notify` — never throws on the sync paths + * themselves; underlying calls (`ensureAgenticDocsScaffold`, + * `detectScaffoldDrift`) are non-throwing per their contracts. + */ +export async function handleScaffoldSync( + args: string, + ctx: ExtensionCommandContext, +): Promise { + const opts = parseScaffoldSyncArgs(args); + const basePath = projectRoot(); + + // Dry-run: report only, no filesystem mutation. + if (opts.dryRun) { + const report = applyOnlyFilter(detectScaffoldDrift(basePath), opts.only); + ctx.ui.notify(formatReportTable(report), "info"); + return; + } + + // Default: run the same automatic-mode entry point, then report. + const before = applyOnlyFilter(detectScaffoldDrift(basePath), opts.only); + try { + ensureAgenticDocsScaffold(basePath); + } catch (err) { + ctx.ui.notify( + `Scaffold sync failed: ${(err as Error).message}`, + "warning", + ); + return; + } + const after = applyOnlyFilter(detectScaffoldDrift(basePath), opts.only); + + const delta = formatSyncDelta(before, after); + const reportText = formatReportTable(after); + const message = delta ? `${delta}\n\n${reportText}` : reportText; + ctx.ui.notify(message, "info"); + + if (!opts.includeEditing) return; + + // --include-editing: synchronously dispatch Phase D's keeper for editing-drift. + const editingItems = after.items.filter((i) => i.bucket === "editing-drift"); + if (editingItems.length === 0) { + ctx.ui.notify("No editing-drift items to merge.", "info"); + return; + } + + const dispatcher = await tryLoadScaffoldKeeper(); + if (!dispatcher) { + ctx.ui.notify( + "--include-editing: scaffold-keeper not yet available (ADR-021 Phase D pending).", + "warning", + ); + return; + } + + try { + const result = await dispatcher(basePath, editingItems); + const proposed = Array.isArray(result?.proposed) ? result.proposed : []; + if (proposed.length === 0) { + ctx.ui.notify( + "scaffold-keeper completed without producing .proposed files.", + "info", + ); + return; + } + const lines = [ + `Wrote ${proposed.length} .proposed file${proposed.length === 1 ? "" : "s"}:`, + ...proposed.map((p) => ` ${p}`), + ]; + ctx.ui.notify(lines.join("\n"), "info"); + } catch (err) { + ctx.ui.notify( + `scaffold-keeper failed: ${(err as Error).message}`, + "warning", + ); + } +} diff --git a/src/resources/extensions/sf/commands/catalog.ts b/src/resources/extensions/sf/commands/catalog.ts index eea2ae379..df94ba2d1 100644 --- a/src/resources/extensions/sf/commands/catalog.ts +++ b/src/resources/extensions/sf/commands/catalog.ts @@ -18,7 +18,7 @@ export interface SfCommandDefinition { type CompletionMap = Record; export const SF_COMMAND_DESCRIPTION = - "SF — Singularity Forge: /sf help|start|templates|next|autonomous|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|harness|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan"; + "SF — Singularity Forge: /sf help|start|templates|next|autonomous|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|harness|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold"; export const TOP_LEVEL_SUBCOMMANDS: readonly SfCommandDefinition[] = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -155,6 +155,10 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly SfCommandDefinition[] = [ { cmd: "backlog", desc: "Manage backlog items (add, promote, remove, list)" }, { cmd: "pr-branch", desc: "Create clean PR branch filtering .sf/ commits" }, { cmd: "add-tests", desc: "Generate tests for completed slices" }, + { + cmd: "scaffold", + desc: "Inspect or refresh ADR-021 versioned scaffold docs (sync, --dry-run, --include-editing, --only=)", + }, ]; const NESTED_COMPLETIONS: CompletionMap = { @@ -384,6 +388,21 @@ const NESTED_COMPLETIONS: CompletionMap = { { cmd: "--dry-run", desc: "Preview what would be filtered" }, { cmd: "--name", desc: "Custom branch name" }, ], + scaffold: [ + { + cmd: "sync", + desc: "Refresh ADR-021 scaffold docs (drift report + apply pending upgrades)", + }, + { cmd: "sync --dry-run", desc: "Print drift report without modifying files" }, + { + cmd: "sync --include-editing", + desc: "Run scaffold-keeper synchronously for editing-drift items", + }, + { + cmd: "sync --only=", + desc: "Restrict the operation to a path glob (e.g. --only=harness/**)", + }, + ], }; function filterOptions( diff --git a/src/resources/extensions/sf/commands/handlers/ops.ts b/src/resources/extensions/sf/commands/handlers/ops.ts index 73c8043f0..e299199e8 100644 --- a/src/resources/extensions/sf/commands/handlers/ops.ts +++ b/src/resources/extensions/sf/commands/handlers/ops.ts @@ -339,6 +339,23 @@ Examples: await handleAddTests(trimmed.replace(/^add-tests\s*/, "").trim(), ctx, pi); return true; } + if (trimmed === "scaffold sync" || trimmed.startsWith("scaffold sync ")) { + const { handleScaffoldSync } = await import( + "../../commands-scaffold-sync.js" + ); + await handleScaffoldSync( + trimmed.replace(/^scaffold sync\s*/, "").trim(), + ctx, + ); + return true; + } + if (trimmed === "scaffold") { + ctx.ui.notify( + "Usage: /sf scaffold sync [--dry-run] [--include-editing] [--only=]", + "warning", + ); + return true; + } if ( trimmed === "extract-learnings" || trimmed.startsWith("extract-learnings ") diff --git a/src/resources/extensions/sf/doctor-runtime-checks.ts b/src/resources/extensions/sf/doctor-runtime-checks.ts index fa801a2a5..cf9844ca9 100644 --- a/src/resources/extensions/sf/doctor-runtime-checks.ts +++ b/src/resources/extensions/sf/doctor-runtime-checks.ts @@ -711,6 +711,66 @@ export async function checkRuntimeHealth( } catch { // Non-fatal — snapshot ref check failed } + + // ── Scaffold freshness (ADR-021 Phase C) ────────────────────────────── + // Visibility into scaffold drift. Phase C runs the silent path + // automatically on every SF startup, but the doctor finding lets users + // see what was upgraded and what is still pending review (editing-drift, + // untracked-without-archive-match). Severity: warning. Never blocks. + try { + const finding = checkScaffoldFreshness(basePath); + if (finding) issues.push(finding); + } catch { + // Non-fatal — scaffold freshness check failed + } +} + +/** + * ADR-021 Phase C: report scaffold drift bucket counts as a doctor finding. + * + * Returns `null` when there is nothing actionable (everything is current or + * customised by intent). Otherwise returns a single warning summarising the + * bucket counts. The phrase "Run /sf scaffold sync" is forward-looking — + * Phase E adds the command. Phase C runs the silent path automatically on + * every SF startup, so the user does not need to act on most of these. + */ +export function checkScaffoldFreshness(basePath: string): DoctorIssue | null { + let report: ReturnType; + try { + report = detectScaffoldDrift(basePath); + } catch { + return null; + } + + const counts = report.countsByBucket; + const actionable = + counts.missing + + counts.upgradable + + counts["editing-drift"] + + counts.untracked; + if (actionable === 0) return null; + + const parts: string[] = []; + if (counts.missing > 0) parts.push(`${counts.missing} missing`); + if (counts.upgradable > 0) parts.push(`${counts.upgradable} pending upgrade`); + if (counts["editing-drift"] > 0) + parts.push(`${counts["editing-drift"]} editing-drift`); + if (counts.untracked > 0) parts.push(`${counts.untracked} untracked`); + + const summary = parts.join(", "); + const guidance = + counts.upgradable + counts.missing > 0 + ? `Run /sf scaffold sync to refresh ${counts.upgradable + counts.missing} pending docs` + : "Run /sf scaffold sync to inspect drift"; + + return { + severity: "warning", + code: "scaffold_drift", + scope: "project", + unitId: "project", + message: `Scaffold drift: ${summary}. ${guidance}.`, + fixable: false, + }; } /** diff --git a/src/resources/extensions/sf/scaffold-keeper.ts b/src/resources/extensions/sf/scaffold-keeper.ts new file mode 100644 index 000000000..509868485 --- /dev/null +++ b/src/resources/extensions/sf/scaffold-keeper.ts @@ -0,0 +1,184 @@ +/** + * Scaffold-keeper background dispatcher (ADR-021 Phase D). + * + * Runs after milestone completion (and any other place SF chooses to call it). + * Walks the drift report, and for every `editing-drift` item produces a + * `.proposed` artifact and emits a single structured `approval_request` + * notification so the user can review the proposal. + * + * Phase D ships the architecture and a deterministic stub for the proposed + * body: the current scaffold template content with a `` + * preamble. A follow-up phase will replace the body generator with a real + * subagent dispatch that runs the `records-keeper` skill — at that point the + * `.proposed` body will be code-derived (records-keeper's "prefer source + * and tests for implemented behavior" contract) instead of template-only. + * + * Contract: + * - Non-blocking. Caller does not await the body. Failures are non-fatal: + * they log via `logWarning("scaffold", ...)` and never propagate. + * - Never overwrites `` itself — only writes `.proposed`. + * - Emits exactly one `approval_request` notification per call when there is + * at least one editing-drift item; the stable `dedupe_key` prevents the + * same drift from spamming the user across repeated runs. + * - Silent path: when there are zero editing-drift items, no notification, + * no .proposed writes, no logs. + */ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { SCAFFOLD_FILES } from "./agentic-docs-scaffold.js"; +import { detectScaffoldDrift } from "./scaffold-drift.js"; +import type { ScaffoldDriftItem } from "./scaffold-drift.js"; +import { logWarning } from "./workflow-logger.js"; + +/** + * Minimal `notify`-only shape of `ExtensionContext`. Tests pass a stub; the + * real auto loop passes the full context. Keeping this typed locally avoids + * pulling the heavy `@singularity-forge/pi-coding-agent` import surface into a + * module that runs at session edges. + */ +export interface ScaffoldKeeperCtx { + ui: { + notify: ( + message: string, + type?: "info" | "warning" | "error" | "success", + metadata?: { + kind?: "notice" | "approval_request" | "progress" | "terminal"; + blocking?: boolean; + dedupe_key?: string; + source?: string; + }, + ) => void; + }; +} + +/** + * Build the .proposed body shipped by the Phase-D stub: the current scaffold + * template body, prefixed with a structured `` block + * describing source vs target version. This matches ADR-021 §5 ("editing-drift"). + * + * The records-keeper skill is referenced in the preamble so a human reviewing + * the artifact knows which skill the agent will run when wired up. + */ +function buildProposedBody(item: ScaffoldDriftItem, templateBody: string): string { + const sourceVersion = item.currentVersion ?? "unknown"; + const targetVersion = item.shipVersion; + const preamble = + `\n`; + return preamble + templateBody; +} + +/** + * Scaffold-keeper entry point. Fire-and-forget — caller should not await + * meaningful work, only the inevitable I/O completion. + * + * Steps: + * 1. detect drift; bail silently if no editing-drift items. + * 2. for each editing-drift item, write `.proposed` with the stub + * body. Skip files with no matching `SCAFFOLD_FILES` entry (defensive). + * 3. emit a single `approval_request` notification summarizing the count. + * + * Returns the number of `.proposed` files written. Tests use this to assert + * the smoke-check behaviour; production callers ignore the return value. + */ +export async function dispatchScaffoldKeeperIfNeeded( + basePath: string, + ctx: ScaffoldKeeperCtx, +): Promise { + let report: ReturnType; + try { + report = detectScaffoldDrift(basePath); + } catch (err) { + logWarning("scaffold", "scaffold-keeper drift detection failed", { + error: (err as Error).message, + }); + return 0; + } + + if (report.countsByBucket["editing-drift"] === 0) { + return 0; + } + + const editingDrift = report.items.filter((i) => i.bucket === "editing-drift"); + let written = 0; + + for (const item of editingDrift) { + try { + const template = SCAFFOLD_FILES.find((f) => f.path === item.template); + if (!template) { + logWarning("scaffold", "scaffold-keeper: no template for drift item", { + path: item.path, + }); + continue; + } + const proposedPath = join(basePath, `${item.path}.proposed`); + const proposedBody = buildProposedBody(item, template.content); + mkdirSync(dirname(proposedPath), { recursive: true }); + writeFileSync(proposedPath, proposedBody, "utf-8"); + written += 1; + } catch (err) { + logWarning("scaffold", "scaffold-keeper: failed to write .proposed", { + path: item.path, + error: (err as Error).message, + }); + } + } + + // Stub notice — when subagent dispatch is wired, this log line goes away + // and the body becomes code-derived rather than template-only. + logWarning( + "scaffold", + "scaffold-keeper agent dispatch not yet wired — wrote .proposed with template only", + { count: String(written) }, + ); + + if (written > 0) { + try { + ctx.ui.notify( + `Scaffold drift: ${written} doc(s) need review. See .proposed files.`, + "warning", + { + kind: "approval_request", + blocking: false, + dedupe_key: "scaffold-drift", + source: "scaffold-keeper", + }, + ); + } catch (err) { + logWarning("scaffold", "scaffold-keeper: notify failed", { + error: (err as Error).message, + }); + } + } + + return written; +} + +/** + * Sync wrapper: schedules `dispatchScaffoldKeeperIfNeeded` without blocking the + * caller. Errors swallowed and logged so a scaffold-keeper failure cannot break + * the auto loop. Used from auto-post-unit and auto.ts:stopAuto where the + * caller cannot reasonably `await` async work without re-architecting cleanup. + */ +export function dispatchScaffoldKeeperFireAndForget( + basePath: string, + ctx: ScaffoldKeeperCtx, +): void { + // Use queueMicrotask so the dispatch starts after the current sync stack + // unwinds — the caller's flow remains synchronous and unblocked. + queueMicrotask(() => { + dispatchScaffoldKeeperIfNeeded(basePath, ctx).catch((err: unknown) => { + logWarning("scaffold", "scaffold-keeper dispatch threw", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }); +} + +// Test-only helper: not exported via index, but exposed for the smoke test. +export const __test = { + buildProposedBody, +}; diff --git a/src/resources/extensions/sf/tests/scaffold-keeper.test.ts b/src/resources/extensions/sf/tests/scaffold-keeper.test.ts new file mode 100644 index 000000000..5b3a947da --- /dev/null +++ b/src/resources/extensions/sf/tests/scaffold-keeper.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for scaffold-keeper (ADR-021 Phase D). + * + * Covers the four observable behaviors: + * 1. editing-drift produces a .proposed file and one notification + * 2. silent path: no editing-drift → no notification, no .proposed writes + * 3. failures inside the dispatcher do not throw to the caller + * 4. completed/pending state files do not trigger the dispatcher + */ +import assert from "node:assert/strict"; +import { + existsSync, + 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 { dispatchScaffoldKeeperIfNeeded } from "../scaffold-keeper.ts"; +import { stampScaffoldFile } from "../scaffold-versioning.ts"; + +interface NotifyCall { + message: string; + type?: string; + metadata?: Record; +} + +function makeStubCtx(): { ctx: { ui: { notify: (...args: unknown[]) => void } }; calls: NotifyCall[] } { + const calls: NotifyCall[] = []; + const ctx = { + ui: { + notify: ( + message: string, + type?: string, + metadata?: Record, + ) => { + calls.push({ message, type, metadata }); + }, + }, + }; + return { ctx, calls }; +} + +function makeTmp(): string { + return mkdtempSync(join(tmpdir(), "sf-scaffold-keeper-")); +} + +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 }; +} + +/** + * Set up a file in editing-drift state: stamped marker, then body mutated. + */ +function makeEditingDrift(dir: string): string { + const target = pickMarkdownTarget(); + const fp = join(dir, target.path); + writeFileSync(fp, target.content, "utf-8"); + stampScaffoldFile(fp, target.path, "1.0.0"); + const stamped = readFileSync(fp, "utf-8"); + const lines = stamped.split("\n"); + const mutated = + lines[0] + "\n" + lines.slice(1).join("\n") + "\n# user customisation\n"; + writeFileSync(fp, mutated, "utf-8"); + return fp; +} + +describe("dispatchScaffoldKeeperIfNeeded", () => { + let dir: string; + beforeEach(() => { + dir = makeTmp(); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("editing-drift produces .proposed file and one notification", async () => { + makeEditingDrift(dir); + const { ctx, calls } = makeStubCtx(); + + const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx); + + assert.equal(written, 1); + const proposedPath = join(dir, "AGENTS.md.proposed"); + assert.ok(existsSync(proposedPath), ".proposed file must exist"); + const body = readFileSync(proposedPath, "utf-8"); + assert.match(body, /