/* RFC-0488: material credits provenance registry migrator — transforms existing *.credits.yaml sidecars by adding status, usageBasis, and aiUsage fields. Makes existing sidecars compatible with the extended materialCreditSchema. Does not validate credit records — that is the job of material.credits.validate. Does not modify labels.md — labels are operator-authored content. Does not delete or rename sidecar files — only transforms their content. RFC-0488: initial migrator — add status, usageBasis, aiUsage to existing credit sidecars. RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt */ import path from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import type { Migrator, SternsystemData, MigrationContext } from "./types.ts"; import { MigrationError } from "./types.ts"; import { createDefaultIO, type FileStat, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export const RFC_0488_MIGRATOR_ID = "rfc-0488"; const DEFAULT_COPYRIGHT_NOTICE = "Copyright © Warpgogol. All rights reserved."; const DEFAULT_HUMAN_CONTRIBUTION = "Konzeption, Auswahl, Zusammenstellung und Nachbearbeitung durch Warpgogol"; interface CreditParty { role: string; name: string; kind: string; } interface CreditRecord { id: string; sourceType: string; parties: CreditParty[]; license?: { label?: string; copyrightNotice?: string }; status?: string; usageBasis?: unknown; aiUsage?: unknown; [key: string]: unknown; } function hasPersonCreator(record: CreditRecord): boolean { return record.parties.some( (p) => (p.role === "creator" || p.role === "coCreator") && p.kind === "Person", ); } function hasNonDefaultCopyrightNotice(record: CreditRecord): boolean { const notice = record.license?.copyrightNotice; return notice !== undefined && notice !== DEFAULT_COPYRIGHT_NOTICE; } function determineCopyrightClaimed(record: CreditRecord): boolean { // If there's a Person creator or a non-default copyright notice, claim copyright. // Otherwise, don't claim — the default notice was auto-applied and is legally // incorrect for AI-generated materials without human creative contribution. return hasPersonCreator(record) || hasNonDefaultCopyrightNotice(record); } function transformRecord(record: CreditRecord): CreditRecord { // 1. Add status: active if not present if (!record.status) { record.status = "active"; } // 2. For ai-generated records with default copyright notice: add aiUsage if ( (record.sourceType === "ai-generated" || record.sourceType === "ai-assisted") && !record.aiUsage ) { record.aiUsage = { kind: record.sourceType, humanContribution: DEFAULT_HUMAN_CONTRIBUTION, copyrightClaimed: determineCopyrightClaimed(record), }; } // 3. For third-party/screenshot records: add unverified usageBasis // Also detect license.label "screenshot-of-linked-public-source" and map sourceType to "screenshot" if ( record.license?.label === "screenshot-of-linked-public-source" && record.sourceType !== "screenshot" ) { record.sourceType = "screenshot"; } if ( (record.sourceType === "third-party" || record.sourceType === "licensed-third-party" || record.sourceType === "screenshot") && !record.usageBasis ) { record.usageBasis = { type: "unverified", note: "Rights review required", }; } // 4. For commissioned records: add internal-commissioned usageBasis if (record.sourceType === "commissioned" && !record.usageBasis) { record.usageBasis = { type: "internal-commissioned", }; } // 5. For human-made records with Organization creator: rename role to commissionedBy if (record.sourceType === "human-made") { for (const party of record.parties) { if (party.role === "creator" && party.kind === "Organization") { party.role = "commissionedBy"; } } // If no Person creator remains, flag as needs-review if (!hasPersonCreator(record) && record.status === "active") { record.status = "needs-review"; } } return record; } async function findCreditsFiles(rootPath: string): Promise { const results: string[] = []; // fs.walk.lint: allow — walks via WorkspaceIO abstraction (io.readdir) — collectFiles has no io contract async function walk(dir: string, io: WorkspaceIO = createDefaultIO().io): Promise { let entries: string[]; try { entries = (await io.readdir(dir)).map((e) => e.name); } catch { return; } for (const entry of entries) { const fullPath = path.join(dir, entry); let stat: FileStat; try { stat = await io.stat(fullPath); } catch { continue; } if (stat.isDirectory) { await walk(fullPath); } else if (entry.endsWith(".credits.yaml")) { results.push(fullPath); } } } const contentDir = path.join(rootPath, "src", "content"); await walk(contentDir); return results; } async function transformCreditsFiles( data: SternsystemData, ctx: MigrationContext, io: WorkspaceIO = createDefaultIO().io, ): Promise { const files = await findCreditsFiles(data.rootPath); for (const filePath of files) { let raw: string; try { raw = await io.readFile(filePath); } catch { ctx.logger.info(`[migrator rfc-0488] skip unreadable file: ${filePath}`); continue; } let parsed: unknown; try { parsed = parseYaml(raw); } catch (err) { throw new MigrationError( RFC_0488_MIGRATOR_ID, filePath, "", `failed to parse YAML: ${(err as Error).message}`, ); } if (!parsed || typeof parsed !== "object") { ctx.logger.info(`[migrator rfc-0488] skip non-object file: ${filePath}`); continue; } const record = parsed as CreditRecord; const before = JSON.stringify(record); transformRecord(record); const after = JSON.stringify(record); if (before !== after) { const output = stringifyYaml(record); await io.writeFile(filePath, output); ctx.logger.info(`[migrator rfc-0488] transformed: ${path.relative(data.rootPath, filePath)}`); } } return data; } export const rfc0488Migrator: Migrator = { id: RFC_0488_MIGRATOR_ID, fromVersion: "4.6.0", toVersion: "4.7.0", description: "Add status, usageBasis, and aiUsage fields to existing material credit sidecars", transform: async (data, ctx) => { return transformCreditsFiles(data, ctx); }, };