Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/api/pipeline/orphans/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
42 changes: 39 additions & 3 deletions lib/pipeline/orphan-sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface OrphanFile {
export interface OrphanReport {
productSlug: string;
productDir: string;
manifestPath: string | null;
manifestMissing: boolean;
manifestFilenames: string[];
orphans: OrphanFile[];
}
Expand All @@ -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[];
Expand All @@ -71,6 +86,8 @@ export function listOrphans(productSlug: string): OrphanReport {
return {
productSlug,
productDir: ctx.product_dir,
manifestPath,
manifestMissing: false,
manifestFilenames,
orphans,
};
Expand All @@ -91,28 +108,47 @@ export function listOrphans(productSlug: string): OrphanReport {
return {
productSlug,
productDir: ctx.product_dir,
manifestPath,
manifestMissing: false,
manifestFilenames,
orphans,
};
}

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);
Expand Down
116 changes: 116 additions & 0 deletions tests/unit/orphan-sweep.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});