diff --git a/docs/dev/generated-artifact-policy.md b/docs/dev/generated-artifact-policy.md new file mode 100644 index 000000000..235bbd3f8 --- /dev/null +++ b/docs/dev/generated-artifact-policy.md @@ -0,0 +1,35 @@ +# Generated Artifact Policy + +SF keeps operational generated artifacts out of the project root unless a human +explicitly promotes them into durable project documentation. + +## Default Locations + +- `.sf/` stores SF-local operational state, generated harness notes, scaffold + manifests, runtime caches, locks, and temporary agent files. +- `docs/plans/`, `docs/specs/`, and `docs/adr/` store promoted, reviewed, + durable project artifacts. +- Root files such as `AGENTS.md`, `ARCHITECTURE.md`, and `.siftignore` are + allowed only when they are part of the versioned scaffold contract. + +## Harness + +Generated harness material belongs under `.sf/harness/`. + +Top-level `harness/` is not created by SF by default. If a project intentionally +owns a top-level harness, SF leaves it alone unless the files still carry +pending `sf-doc` markers proving they are old SF-generated residue. + +## Doctor Cleanup + +`/sf doctor --fix` may remove root-level generated residue only when all of the +following are true: + +- the file is under a legacy SF-generated path such as `harness/specs/`; +- the file has an `sf-doc` marker; +- the marker template matches the file path; +- the marker state is `pending`; +- the current body hash still matches the marker hash. + +Anything edited, completed, unmarked, or project-owned is reported conservatively +or left untouched. diff --git a/src/resources/extensions/sf/doctor.js b/src/resources/extensions/sf/doctor.js index 94e480792..e657d6486 100644 --- a/src/resources/extensions/sf/doctor.js +++ b/src/resources/extensions/sf/doctor.js @@ -4,8 +4,9 @@ import { mkdirSync, readdirSync, readFileSync, + rmSync, } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { invalidateAllCaches } from "./cache.js"; import { checkEngineHealth, @@ -42,6 +43,7 @@ import { sfRoot, } from "./paths.js"; import { loadEffectiveSFPreferences } from "./preferences.js"; +import { bodyHash, extractMarker } from "./scaffold-versioning.js"; import { readAllSelfFeedback, recordSelfFeedback } from "./self-feedback.js"; import { getMilestoneSlices, getSliceTasks, isDbAvailable } from "./sf-db.js"; import { deriveState, isMilestoneComplete } from "./state.js"; @@ -53,6 +55,79 @@ const DEFAULT_STALE_PROGRESS_MS = 20 * 60 * 1000; const DEFAULT_OPTIONAL_CHILD_BUDGET_MS = 30 * 60 * 1000; const REPEATED_FAILURE_THRESHOLD = 3; const FLOW_AUDIT_ROLLUP_KIND = "flow-audit:repeated-milestone-failure"; +const LEGACY_ROOT_HARNESS_PATHS = [ + "harness/AGENTS.md", + "harness/specs/AGENTS.md", + "harness/specs/bootstrap.md", + "harness/evals/AGENTS.md", + "harness/graders/AGENTS.md", +]; + +function pruneEmptyDir(path) { + try { + if (existsSync(path) && readdirSync(path).length === 0) { + rmSync(path, { recursive: false }); + } + } catch { + // Best-effort cleanup only. + } +} + +function collectOwnedLegacyRootHarnessFiles(basePath) { + const owned = []; + for (const relPath of LEGACY_ROOT_HARNESS_PATHS) { + const target = join(basePath, relPath); + if (!existsSync(target)) continue; + const { marker, body } = extractMarker(target); + if (!marker) continue; + if (marker.template !== relPath) continue; + if (marker.state !== "pending") continue; + if (bodyHash(body) !== marker.hash) continue; + owned.push(relPath); + } + return owned; +} + +function removeOwnedLegacyRootHarnessFiles(basePath, relPaths) { + for (const relPath of relPaths) { + rmSync(join(basePath, relPath), { force: true }); + } + for (const relPath of relPaths) { + let dir = dirname(join(basePath, relPath)); + while (dir.startsWith(join(basePath, "harness"))) { + pruneEmptyDir(dir); + if (dir === join(basePath, "harness")) break; + dir = dirname(dir); + } + } + pruneEmptyDir(join(basePath, "harness")); +} + +function checkGeneratedArtifactResidue( + basePath, + issues, + fixesApplied, + shouldFix, +) { + const ownedRootHarness = collectOwnedLegacyRootHarnessFiles(basePath); + if (ownedRootHarness.length === 0) return; + issues.push({ + severity: "warning", + code: "generated_root_harness_residue", + scope: "project", + unitId: "project", + message: `Found ${ownedRootHarness.length} SF-owned generated harness file(s) under root harness/. Generated operational harness belongs under .sf/harness/; promote durable contracts to docs/specs/ explicitly.`, + file: "harness/", + fixable: true, + }); + if (shouldFix("generated_root_harness_residue")) { + removeOwnedLegacyRootHarnessFiles(basePath, ownedRootHarness); + fixesApplied.push( + `removed ${ownedRootHarness.length} SF-owned root harness file(s)`, + ); + } +} + function parseEpochMs(value, fallbackMs) { if (typeof value === "number" && Number.isFinite(value)) { return value < 10_000_000_000 ? value * 1000 : value; @@ -1024,6 +1099,7 @@ export async function runSFDoctor(basePath, options) { }); } } + checkGeneratedArtifactResidue(basePath, issues, fixesApplied, shouldFix); // Git health checks — timed const t0git = Date.now(); const isolationMode = diff --git a/src/resources/extensions/sf/tests/doctor-generated-artifacts.test.mjs b/src/resources/extensions/sf/tests/doctor-generated-artifacts.test.mjs new file mode 100644 index 000000000..0f215abbe --- /dev/null +++ b/src/resources/extensions/sf/tests/doctor-generated-artifacts.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, test } from "vitest"; +import { runSFDoctor } from "../doctor.js"; +import { stampScaffoldFile } from "../scaffold-versioning.js"; + +const tmpDirs = []; + +afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-doctor-generated-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf"), { recursive: true }); + return dir; +} + +function writeOwnedLegacyHarnessFile(root, relPath) { + const target = join(root, relPath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, `# ${relPath}\n`, "utf-8"); + stampScaffoldFile(target, relPath, "2.75.0", "pending"); +} + +describe("doctor generated artifact hygiene", () => { + test("runSFDoctor_reports_sf_owned_root_harness_residue", async () => { + const project = makeProject(); + writeOwnedLegacyHarnessFile(project, "harness/specs/bootstrap.md"); + + const report = await runSFDoctor(project, { scope: "project" }); + + const issue = report.issues.find( + (i) => i.code === "generated_root_harness_residue", + ); + assert.equal(issue?.fixable, true); + assert.equal(existsSync(join(project, "harness/specs/bootstrap.md")), true); + }); + + test("runSFDoctor_fix_removes_only_sf_owned_pending_root_harness", async () => { + const project = makeProject(); + writeOwnedLegacyHarnessFile(project, "harness/specs/bootstrap.md"); + + const report = await runSFDoctor(project, { + fix: true, + fixLevel: "all", + scope: "project", + }); + + assert.ok( + report.fixesApplied.includes("removed 1 SF-owned root harness file(s)"), + ); + assert.equal(existsSync(join(project, "harness")), false); + }); + + test("runSFDoctor_ignores_user_owned_root_harness", async () => { + const project = makeProject(); + mkdirSync(join(project, "harness/specs"), { recursive: true }); + writeFileSync( + join(project, "harness/specs/project-contract.md"), + "# Project-owned harness\n", + ); + + const report = await runSFDoctor(project, { + fix: true, + fixLevel: "all", + scope: "project", + }); + + assert.equal( + report.issues.some((i) => i.code === "generated_root_harness_residue"), + false, + ); + assert.equal( + existsSync(join(project, "harness/specs/project-contract.md")), + true, + ); + }); +});