/*
RFC-0505: ratgeber extended claim registry migrator — transforms
existing ratgeber article claim sidecars (surface/articles/{lang}/*.claims.yaml)
into structured claim records (surface/claims/{lang}/{claimId}.md) and deletes
the sidecar files after transformation. Idempotent: if no sidecars exist, the
migrator is a no-op. Advances the migratorCursor for RFC-0505.
- Do not auto-generate claimText, limitations, or reviewStatus — those are
human-authored editorial fields. The migrator sets safe defaults: claimText
is a placeholder, limitations is empty, reviewStatus is "unverified".
- Do not modify article records — article sources[].claimIds remain unchanged.
- RFC-0505: initial migrator — transform claim sidecars to claim records, delete sidecars.
*/
import path from "node:path";
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
import type { Migrator, SternsystemData, MigrationContext } from "./types.ts";
import { MigrationError } from "./types.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export const RFC_0505_MIGRATOR_ID = "rfc-0505";
interface SidecarClaim {
provenance?: string;
asOf?: string;
sourceId?: string;
url?: string;
title?: string;
[key: string]: unknown;
}
interface ParsedSidecar {
[claimId: string]: SidecarClaim;
}
function buildClaimRecordFrontmatter(
claimId: string,
articleSlug: string,
lang: string,
claim: SidecarClaim,
): string {
const sourceRefs: Array> = [];
if (claim.sourceId || claim.url) {
sourceRefs.push({
sourceId: claim.sourceId ?? "unknown",
url: claim.url ?? "https://example.com",
title: claim.title ?? "Migrated from sidecar",
retrievedAt: claim.asOf ?? "2026-01-01",
});
}
const frontmatter: Record = {
claimId,
articleId: articleSlug,
claimText: "Migrated from claim sidecar — editorial review required.",
claimType: "factual",
sourceRefs,
calculationInputs: [],
limitations: [],
verifiedAt: claim.asOf ?? "2026-01-01",
reviewStatus: "unverified",
};
const yamlContent = yamlStringify(frontmatter, { indent: 2 });
return `---\n${yamlContent}---\n`;
}
export const rfc0505Migrator: Migrator = {
id: RFC_0505_MIGRATOR_ID,
fromVersion: "4.15.0",
toVersion: "4.16.0",
description:
"Transform ratgeber article claim sidecars (surface/articles/{lang}/*.claims.yaml) into structured claim records (surface/claims/{lang}/{claimId}.md) and delete sidecars. Sets reviewStatus to 'unverified' — editorial review required. Advances migratorCursor for RFC-0505.",
transform: async (
data: SternsystemData,
ctx: MigrationContext,
io: WorkspaceIO = createDefaultIO().io,
) => {
const articlesBaseDir = path.join(data.rootPath, "src", "content", "surface", "articles");
const claimsBaseDir = path.join(data.rootPath, "src", "content", "surface", "claims");
let langDirs: string[];
try {
langDirs = (await io.readdir(articlesBaseDir)).map((e) => e.name);
} catch {
ctx.logger.info(`[rfc-0505] no articles directory at ${articlesBaseDir} — skipping`);
return data;
}
for (const lang of langDirs) {
const langPath = path.join(articlesBaseDir, lang);
const stat = await io.stat(langPath).catch(() => null);
if (!stat?.isDirectory) continue;
const files = await io.readdir(langPath).catch(() => []);
const sidecarFiles = files.filter((f) => f.name.endsWith(".claims.yaml"));
if (sidecarFiles.length === 0) {
ctx.logger.info(`[rfc-0505] no claim sidecars in ${lang} — skipping`);
continue;
}
const claimsLangDir = path.join(claimsBaseDir, lang);
await io.mkdir(claimsLangDir);
for (const sidecarFile of sidecarFiles) {
const sidecarPath = path.join(langPath, sidecarFile.name);
const articleSlug = sidecarFile.name.replace(/\.claims\.yaml$/, "");
let raw: string;
try {
raw = await io.readFile(sidecarPath);
} catch (err) {
throw new MigrationError(
RFC_0505_MIGRATOR_ID,
sidecarPath,
"",
`Failed to read claim sidecar: ${(err as Error).message}`,
);
}
let parsed: ParsedSidecar;
try {
const result = yamlParse(raw) as ParsedSidecar;
parsed = result && typeof result === "object" && !Array.isArray(result) ? result : {};
} catch (err) {
throw new MigrationError(
RFC_0505_MIGRATOR_ID,
sidecarPath,
"",
`Failed to parse claim sidecar YAML: ${(err as Error).message}`,
);
}
for (const [claimId, claim] of Object.entries(parsed)) {
const claimRecordPath = path.join(claimsLangDir, `${claimId}.md`);
const exists = await io
.stat(claimRecordPath)
.then(() => true)
.catch(() => false);
if (exists) {
ctx.logger.info(`[rfc-0505] claim record already exists: ${lang}/${claimId}.md`);
continue;
}
const content = buildClaimRecordFrontmatter(claimId, articleSlug, lang, claim);
try {
await io.writeFile(claimRecordPath, content);
ctx.logger.info(`[rfc-0505] created claim record: ${lang}/${claimId}.md`);
} catch (err) {
throw new MigrationError(
RFC_0505_MIGRATOR_ID,
claimRecordPath,
"",
`Failed to create claim record: ${(err as Error).message}`,
);
}
}
// Delete the sidecar after transformation
try {
await io.rm(sidecarPath);
ctx.logger.info(`[rfc-0505] deleted sidecar: ${lang}/${sidecarFile}`);
} catch (err) {
throw new MigrationError(
RFC_0505_MIGRATOR_ID,
sidecarPath,
"",
`Failed to delete claim sidecar: ${(err as Error).message}`,
);
}
}
}
return data;
},
};