diff --git a/app/api/pipeline/orphans/route.ts b/app/api/pipeline/orphans/route.ts index 8a044cd..58c4887 100644 --- a/app/api/pipeline/orphans/route.ts +++ b/app/api/pipeline/orphans/route.ts @@ -22,5 +22,15 @@ export async function POST(req: NextRequest) { if (!slug) { return NextResponse.json({ error: "productSlug required" }, { status: 400 }); } - return NextResponse.json(deleteOrphans(slug, { dryRun: body.dryRun === true })); + const result = deleteOrphans(slug, { + dryRun: body.dryRun !== false, + confirmDelete: body.confirmDelete === true, + }); + if (result.manifestMissing && body.dryRun === false) { + return NextResponse.json(result, { status: 409 }); + } + if (result.requiresConfirmation) { + return NextResponse.json(result, { status: 400 }); + } + return NextResponse.json(result); } diff --git a/lib/pipeline/orphan-sweep.ts b/lib/pipeline/orphan-sweep.ts index e80842b..158ceac 100644 --- a/lib/pipeline/orphan-sweep.ts +++ b/lib/pipeline/orphan-sweep.ts @@ -34,6 +34,8 @@ export interface OrphanFile { export interface OrphanReport { productSlug: string; productDir: string; + manifestPath: string | null; + manifestMissing: boolean; manifestFilenames: string[]; orphans: OrphanFile[]; } @@ -49,12 +51,25 @@ export function listOrphans(productSlug: string): OrphanReport { return { productSlug, productDir: "", + manifestPath: null, + manifestMissing: true, manifestFilenames: [], orphans: [], }; } const sourceDir = path.join(ctx.product_dir, "source"); - const manifest = readSourcesYaml(path.join(ctx.product_dir, "sources.yaml")); + const manifestPath = path.join(ctx.product_dir, "sources.yaml"); + const manifest = readSourcesYaml(manifestPath); + if (!manifest) { + return { + productSlug, + productDir: ctx.product_dir, + manifestPath, + manifestMissing: true, + manifestFilenames: [], + orphans: [], + }; + } const manifestFilenames = (manifest?.sources ?? []) .map((s) => s.filename) .filter(Boolean) as string[]; @@ -71,6 +86,8 @@ export function listOrphans(productSlug: string): OrphanReport { return { productSlug, productDir: ctx.product_dir, + manifestPath, + manifestMissing: false, manifestFilenames, orphans, }; @@ -91,6 +108,8 @@ export function listOrphans(productSlug: string): OrphanReport { return { productSlug, productDir: ctx.product_dir, + manifestPath, + manifestMissing: false, manifestFilenames, orphans, }; @@ -98,21 +117,38 @@ export function listOrphans(productSlug: string): OrphanReport { export interface DeleteOrphansResult { productSlug: string; + dryRun: boolean; + manifestMissing: boolean; + requiresConfirmation: boolean; + refusedReason: string | null; deleted: string[]; errors: { filename: string; error: string }[]; } export function deleteOrphans( productSlug: string, - options: { dryRun?: boolean } = {} + options: { dryRun?: boolean; confirmDelete?: boolean } = {} ): DeleteOrphansResult { const report = listOrphans(productSlug); + const dryRun = options.dryRun !== false; const result: DeleteOrphansResult = { productSlug, + dryRun, + manifestMissing: report.manifestMissing, + requiresConfirmation: !dryRun && options.confirmDelete !== true, + refusedReason: null, deleted: [], errors: [], }; - if (options.dryRun) return result; + if (report.manifestMissing) { + result.refusedReason = "Refusing to delete orphans because sources.yaml is missing"; + return result; + } + if (dryRun) return result; + if (options.confirmDelete !== true) { + result.refusedReason = "Refusing to delete orphans without confirmDelete=true"; + return result; + } for (const orphan of report.orphans) { try { fs.unlinkSync(orphan.absPath); diff --git a/tests/unit/orphan-sweep.test.ts b/tests/unit/orphan-sweep.test.ts new file mode 100644 index 0000000..80e594f --- /dev/null +++ b/tests/unit/orphan-sweep.test.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { deleteOrphans, listOrphans } from "@/lib/pipeline/orphan-sweep"; + +const cleanupPaths: string[] = []; + +afterEach(() => { + for (const p of cleanupPaths.splice(0)) { + fs.rmSync(p, { recursive: true, force: true }); + } +}); + +function makeProduct(slug: string): { + productDir: string; + sourceDir: string; + sourcesYaml: string; +} { + const lineDir = path.resolve("./data/sample/server/testvendor/orphanline"); + const productDir = path.join(lineDir, slug); + const sourceDir = path.join(productDir, "source"); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(path.join(productDir, `${slug}.md`), `---\n---\n# ${slug}\n`, "utf8"); + cleanupPaths.push(lineDir); + return { + productDir, + sourceDir, + sourcesYaml: path.join(productDir, "sources.yaml"), + }; +} + +describe("orphan sweep safety", () => { + it("does not classify source files as orphans when sources.yaml is missing", () => { + const slug = `orphan-missing-${Date.now()}`; + const { sourceDir } = makeProduct(slug); + const proof = path.join(sourceDir, "manual.pdf"); + fs.writeFileSync(proof, "manual source"); + + const report = listOrphans(slug); + + expect(report.manifestMissing).toBe(true); + expect(report.orphans).toEqual([]); + expect(fs.existsSync(proof)).toBe(true); + }); + + it("refuses to delete files when sources.yaml is missing", () => { + const slug = `orphan-refuse-${Date.now()}`; + const { sourceDir } = makeProduct(slug); + const proof = path.join(sourceDir, "manual.pdf"); + fs.writeFileSync(proof, "manual source"); + + const result = deleteOrphans(slug, { dryRun: false, confirmDelete: true }); + + expect(result.manifestMissing).toBe(true); + expect(result.deleted).toEqual([]); + expect(result.refusedReason).toMatch(/sources\.yaml is missing/); + expect(fs.existsSync(proof)).toBe(true); + }); + + it("dry-runs by default even with a valid manifest", () => { + const slug = `orphan-dry-run-${Date.now()}`; + const { sourceDir, sourcesYaml } = makeProduct(slug); + const orphan = path.join(sourceDir, "orphan.pdf"); + fs.writeFileSync(orphan, "orphan source"); + fs.writeFileSync(sourcesYaml, "sources: []\n", "utf8"); + + const result = deleteOrphans(slug); + + expect(result.dryRun).toBe(true); + expect(result.deleted).toEqual([]); + expect(fs.existsSync(orphan)).toBe(true); + }); + + it("requires explicit confirmation before deleting valid-manifest orphans", () => { + const slug = `orphan-confirm-${Date.now()}`; + const { sourceDir, sourcesYaml } = makeProduct(slug); + const orphan = path.join(sourceDir, "orphan.pdf"); + fs.writeFileSync(orphan, "orphan source"); + fs.writeFileSync(sourcesYaml, "sources: []\n", "utf8"); + + const refused = deleteOrphans(slug, { dryRun: false }); + expect(refused.requiresConfirmation).toBe(true); + expect(refused.deleted).toEqual([]); + expect(fs.existsSync(orphan)).toBe(true); + + const confirmed = deleteOrphans(slug, { + dryRun: false, + confirmDelete: true, + }); + expect(confirmed.deleted).toEqual(["orphan.pdf"]); + expect(fs.existsSync(orphan)).toBe(false); + }); + + it("preserves files listed in sources.yaml and deletes only confirmed orphans", () => { + const slug = `orphan-preserve-${Date.now()}`; + const { sourceDir, sourcesYaml } = makeProduct(slug); + const kept = path.join(sourceDir, "kept.pdf"); + const orphan = path.join(sourceDir, "orphan.pdf"); + fs.writeFileSync(kept, "kept source"); + fs.writeFileSync(orphan, "orphan source"); + fs.writeFileSync( + sourcesYaml, + "sources:\n - filename: kept.pdf\n type: spec-sheet\n", + "utf8" + ); + + const result = deleteOrphans(slug, { + dryRun: false, + confirmDelete: true, + }); + + expect(result.deleted).toEqual(["orphan.pdf"]); + expect(fs.existsSync(kept)).toBe(true); + expect(fs.existsSync(orphan)).toBe(false); + }); +});