/*
RFC-1316: blocking-validator debt baseline — schema, loader, partition helper,
and canonical digest functions. `docs/validation-baseline.generated.yaml`
records the current violation set of `baselineEligible` commands so delta-mode
gates fail only on violations absent from the recorded set. Canonical
`maintenanceDebtKey`/`normalizeMaintenanceDebtMessage` live here (rehomed from
werkstatt-site checks/ecosystem/debt.ts) — engine validators cannot reach site
code (site → engine+shared, engine → shared).
- Do not run validators or write the baseline — that is the validation.baseline.update command's job in werkstatt-site.
- Do not absorb the RFC-0251 advisory maintenance baseline — that ledger is human-acknowledged (owner/rationale), this file is machine-ratcheted.
- RFC-1316: initial module — ValidationBaseline zod schema, loader, render/serialize, partitionByBaseline, validationBaselineKey; maintenanceDebtKey/normalizeMaintenanceDebtMessage rehomed from site debt.ts.
*/
import { join } from "node:path";
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
import { z } from "zod";
import { byteHash } from "../fingerprint/primitives.ts";
export const VALIDATION_BASELINE_PATH = join("docs", "validation-baseline.generated.yaml");
const HASH_PREFIX = "sha" + "256:";
export function digestHex(value: string): string {
return byteHash(value).slice(HASH_PREFIX.length);
}
// ---------------------------------------------------------------------------
// Canonical maintenance-debt digest helpers (rehomed from werkstatt-site
// checks/ecosystem/debt.ts — the RFC-0251 advisory ledger keeps its key shape;
// this module is the single implementation both ledgers share).
// ---------------------------------------------------------------------------
export function normalizeMaintenanceDebtMessage(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
export interface MaintenanceDebtKeyable {
sourceCommand: string;
app?: string;
ruleId?: string;
file?: string;
line?: number;
message: string;
}
export function maintenanceDebtKey(item: MaintenanceDebtKeyable): string {
return digestHex(
JSON.stringify({
sourceCommand: item.sourceCommand,
app: item.app ?? "",
ruleId: item.ruleId ?? "",
file: item.file ?? "",
line: item.line ?? null,
message: normalizeMaintenanceDebtMessage(item.message),
}),
);
}
// ---------------------------------------------------------------------------
// Validation baseline (RFC-1316) — machine-ratcheted blocking-debt ledger.
// Key = sha256 of {ruleId, file, normalizedMessage}; line is advisory-only so
// drift under edits never mints a fresh violation.
// ---------------------------------------------------------------------------
export const validationBaselineViolationSchema = z.object({
key: z.string(),
ruleId: z.string(),
file: z.string(),
line: z.number().optional(),
message: z.string(),
});
export const validationBaselineSchema = z.object({
meta: z.object({
schemaVersion: z.literal(1),
deterministic: z.literal(true),
generatedAt: z.null(),
contentHash: z.string(),
growthAllowed: z.boolean().optional(),
}),
commands: z.record(
z.string(),
z.object({ violations: z.array(validationBaselineViolationSchema) }),
),
});
export type ValidationBaselineViolation = z.infer;
export type ValidationBaseline = z.infer;
export interface ValidationBaselineKeyable {
ruleId: string;
file?: string;
line?: number;
message: string;
}
export function validationBaselineKey(item: ValidationBaselineKeyable): string {
return digestHex(
JSON.stringify({
ruleId: item.ruleId,
file: item.file ?? "",
message: normalizeMaintenanceDebtMessage(item.message),
}),
);
}
export function validationBaselineEntry(
item: ValidationBaselineKeyable,
): ValidationBaselineViolation {
return {
key: validationBaselineKey(item),
ruleId: item.ruleId,
file: item.file ?? "",
...(typeof item.line === "number" ? { line: item.line } : {}),
message: normalizeMaintenanceDebtMessage(item.message),
};
}
/**
* Deterministic render: violations sorted by key, commands sorted by name,
* contentHash = sha256 over the document with the hash field blanked.
*/
export function renderValidationBaseline(
commands: Record,
options: { growthAllowed?: boolean } = {},
): string {
const sorted: Record = {};
for (const name of Object.keys(commands).sort()) {
sorted[name] = {
violations: [...commands[name]!.violations].sort((a, b) => a.key.localeCompare(b.key)),
};
}
const withoutHash = {
meta: {
schemaVersion: 1 as const,
deterministic: true as const,
generatedAt: null,
contentHash: "",
...(options.growthAllowed ? { growthAllowed: true } : {}),
},
commands: sorted,
};
const baseline: ValidationBaseline = {
...withoutHash,
meta: {
...withoutHash.meta,
contentHash: digestHex(JSON.stringify(withoutHash)),
},
};
return yamlStringify(baseline);
}
export async function loadValidationBaseline(
workspaceRoot: string,
io: { readFile(path: string): Promise },
): Promise {
let raw: string;
try {
raw = await io.readFile(join(workspaceRoot, VALIDATION_BASELINE_PATH));
} catch {
return undefined;
}
const parsed = validationBaselineSchema.safeParse(yamlParse(raw));
return parsed.success ? parsed.data : undefined;
}
/**
* Partition a validator's diagnostics into fresh (not baseline-listed → still
* blocking) and debt (baseline-listed → downgraded to info by the caller).
* Missing/invalid baseline yields the empty set for `commands[command]` —
* callers treat an absent baseline as full-debt mode where everything fails,
* so they MUST pass `undefined` only when the file is genuinely missing and
* keep diagnostics blocking; partitioning happens only against a parsed set.
*/
export function partitionByBaseline(
command: string,
items: T[],
baseline: ValidationBaseline,
): { fresh: T[]; debt: T[] } {
const recorded = new Set((baseline.commands[command]?.violations ?? []).map((v) => v.key));
const fresh: T[] = [];
const debt: T[] = [];
for (const item of items) {
(recorded.has(validationBaselineKey(item)) ? debt : fresh).push(item);
}
return { fresh, debt };
}
export interface BaselinePartition {
/** Debt items downgraded to info severity; fresh items untouched. */
diagnostics: T[];
/** Count of baseline-listed (debt) diagnostics. */
debt: number;
/** Baseline file consulted — set whenever delta mode ran. */
baselineRef?: string;
}
/**
* The one delta-mode contract every `baselineEligible` validator applies:
* `--strict` or a missing/unreadable baseline → everything stays blocking;
* otherwise baseline-listed diagnostics downgrade to `info` (known-debt).
*/
export function applyValidationBaseline<
T extends ValidationBaselineKeyable & { severity?: string },
>(
command: string,
diagnostics: T[],
options: { strict?: boolean; baseline?: ValidationBaseline },
): BaselinePartition {
if (options.strict === true || options.baseline === undefined) {
return { diagnostics, debt: 0 };
}
const { debt } = partitionByBaseline(command, diagnostics, options.baseline);
const partition: BaselinePartition = {
diagnostics,
debt: debt.length,
baselineRef: VALIDATION_BASELINE_PATH,
};
if (debt.length === 0) return partition;
const known = new Set(debt);
partition.diagnostics = diagnostics.map((d) =>
known.has(d) ? { ...d, severity: "info" as const } : d,
);
return partition;
}