/** * * RFC-1296: per-release content warning budget — warning counts per * rule for content contracts plus the delta against the previous * release, recorded at releases/<id>/content-warnings.json. * Release / engine * release-commands.ts (release.prepare), editorial.review.* (EDIT-05) * * Only validators whose command contract is in CONTENT_CONTRACTS * contribute — infrastructure checks are not content warnings. * * Not a contract source of truth — callers depend on the module's exported API, not this file's internals. RFC-1311: Compass header sweep — repairs pre-existing contract debt surfaced by the test-file scaffold rollout. */ /** * Contracts whose diagnostics describe rendered/editorial content quality. * A validator contributes to the budget only when its command's contract is * listed here. */ export const CONTENT_CONTRACTS = [ "text-corpus", "typography", "list-punctuation", "pbp-terms-reference", "pbp", "surface", "content", "content-voice", "dist", "price", "seo", "editorial", ] as const; export interface ContentWarningsRule { warning: number; error: number; previous: number | null; delta: number | null; } export interface ContentWarnings { schema: "werkstatt/content-warnings@1"; releaseId: string; previousReleaseId: string | null; rules: Record; /** ruleIds whose warning count grew versus the previous release. */ increased: string[]; } export interface ValidationReportLike { contractFull?: { validators?: Array<{ name: string; rules?: Record; }>; }; } function collectContentRules( report: ValidationReportLike, contractOf: (command: string) => string | undefined, ): Record { const out: Record = {}; const contentSet = new Set(CONTENT_CONTRACTS); for (const v of report.contractFull?.validators ?? []) { const contract = contractOf(v.name); if (!contract || !contentSet.has(contract)) continue; for (const [ruleId, counts] of Object.entries(v.rules ?? {})) { const bucket = (out[ruleId] ??= { warning: 0, error: 0 }); bucket.warning += counts.warning ?? 0; bucket.error += counts.error ?? 0; } } return out; } export function computeContentWarnings( current: ValidationReportLike, previous: ValidationReportLike | null, contractOf: (command: string) => string | undefined, releaseId: string, previousReleaseId: string | null, ): ContentWarnings { const currentRules = collectContentRules(current, contractOf); const previousRules = previous ? collectContentRules(previous, contractOf) : {}; const rules: Record = {}; const increased: string[] = []; for (const [ruleId, counts] of Object.entries(currentRules)) { const prev = previousRules[ruleId]?.warning ?? null; const delta = prev === null ? null : counts.warning - prev; rules[ruleId] = { warning: counts.warning, error: counts.error, previous: prev, delta }; if (delta !== null && delta > 0) increased.push(ruleId); } return { schema: "werkstatt/content-warnings@1", releaseId, previousReleaseId, rules, increased: increased.sort(), }; } // --------------------------------------------------------------------------- // release.prepare integration — contractOf resolution + content-warnings.json // --------------------------------------------------------------------------- import path from "node:path"; import { parse as parseYaml } from "yaml"; import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel"; import { createDefaultIO } from "@warpgogol/werkstatt-shared/kernel"; import { getOrBuildWorkspaceRegistry } from "../kernel/runtime/registry-cache.ts"; async function readReport(file: string, io: WorkspaceIO): Promise { try { return JSON.parse(await io.readFile(file)) as ValidationReportLike; } catch { return null; } } /** * command → contract. Registry-first (`KernelCommandDefinition.contract`), * falling back to `docs/validator-inventory.generated.yaml` when the registry * cannot be built (the generated manifest does not emit contract fields). */ export async function buildContractOf( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise<(command: string) => string | undefined> { try { const registry = await getOrBuildWorkspaceRegistry(workspaceRoot); if (registry) { const map = new Map(); for (const [name, def] of registry.commands) { const contract = (def as { contract?: string }).contract; if (contract) map.set(name, contract); } if (map.size > 0) return (command) => map.get(command); } } catch { // registry unavailable — manifest fallback } const inventoryPath = path.join(workspaceRoot, "docs", "validator-inventory.generated.yaml"); let map = new Map(); try { const raw = await io.readFile(inventoryPath); const parsed = parseYaml(raw) as { contracts?: Array<{ contract?: string; validators?: Array<{ command?: string }> }>; }; for (const c of parsed.contracts ?? []) { if (!c.contract) continue; for (const v of c.validators ?? []) { if (v.command) map.set(v.command, c.contract); } } } catch { map = new Map(); } return (command) => map.get(command); } /** Highest `releases/-r*` below `releaseId` carrying a validation report. */ export async function findPreviousReleaseWithReport( workspaceRoot: string, systemId: string, releaseId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const releasesDir = path.join(workspaceRoot, "releases"); let entries: Array<{ name: string; isDirectory: boolean }>; try { entries = await io.readdir(releasesDir); } catch { return null; } const candidates = entries .map((e) => e.name) .filter((n) => n.startsWith(`${systemId}-r`) && n < releaseId && !n.includes(".staging-")) .sort() .reverse(); for (const name of candidates) { if (await io.exists(path.join(releasesDir, name, "validation-report.json"))) { return name; } } return null; } /** Write `content-warnings.json` into the release staging dir. */ export async function writeContentWarnings( workspaceRoot: string, stagingDir: string, systemId: string, releaseId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const current = await readReport(path.join(stagingDir, "validation-report.json"), io); if (!current) return; // no report staged — nothing to budget const previousReleaseId = await findPreviousReleaseWithReport( workspaceRoot, systemId, releaseId, io, ); const previous = previousReleaseId ? await readReport( path.join(workspaceRoot, "releases", previousReleaseId, "validation-report.json"), io, ) : null; const contractOf = await buildContractOf(workspaceRoot, io); const warnings = computeContentWarnings( current, previous, contractOf, releaseId, previousReleaseId, ); await io.writeFile( path.join(stagingDir, "content-warnings.json"), JSON.stringify(warnings, null, 2) + "\n", ); }