/*
RFC-0963: emits docs/validator-inventory.generated.yaml — a mapping of
validation contracts to their validators, rules, pipeline phase, and p95
timing. Enforces fail-closed on missing contract/rules tags. Identifies
consolidation candidates (contracts with more than one validator).
- Do not import pipeline step arrays from stack plugins — use registry.pipelines instead.
- Do not auto-consolidate validators — only identify candidates.
- The inventory is generated from the registry — docs/validator-inventory.generated.yaml is the single output.
- RFC-1200: VALIDATOR_NAME_PATTERN gains .probe — a11y.axe.probe is a blocking read-only gate whose declared rules must appear in the inventory (AC-7).
- RFC-1144: run read-only steps to completion in collect-errors pipeline mode
Scheduler: collectErrors option on executeScheduledSteps — read-only steps (ScheduledStep.readOnly, executor-resolved from mutatesState === false) run after dependency failures with ranAfterFailedDependency annotation; ranTainted set propagates taint transitively so mutating dependents still skip. Executor: mergeCollectErrorsOption OR-merges PipelineDeclaration.collectErrors with the CLI flag at registry resolution; readOnly annotated post-buildSchedule; taint flag copied to step reports; tainted failures marked in pretty output. Contracts: PipelineDeclaration.collectErrors, KernelExecutionReport.ranAfterFailedDependency, pipelines maps store full declarations. Declarations: collectErrors on validation-gate registrations (build.post, check, build.check, compass, integrity.release in the workpiece template; packages.check in checks module). Tests: 10 new cases covering AC-1..AC-6 plus transitive taint.
- RFC-1200: add a11y.axe.probe pre-deploy axe probe and register in postbuild pipeline
- RFC-1254: drop unused io-migration leftovers
- RFC-1306 review-r1: inventory write migrated ambient writeFileAtomic to context.io via writeFileIfChanged — dry-run/readonly interception + honest filesModified.
CONTRACT-02, CONTRACT-03, CS-07, PURPOSE-02, RFC-0963, RFC-1097
*/
// @ai-invariant: The inventory is generated from the live registry only — never hand-edit the generated.yaml or seed entries from static lists.
import { join } from "node:path";
import { stringify as yamlStringify } from "yaml";
import { buildGeneratedHeader } from "./generated-marker.ts";
import { writeFileIfChanged } from "@warpgogol/werkstatt-shared/kernel";
import { loadPipelineBudgets } from "./pipeline-budgets.ts";
import { getOrBuildWorkspaceRegistry } from "./runtime/registry-cache.ts";
import type {
KernelCommandInput,
KernelCommandDefinition,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-shared/kernel";
const INVENTORY_RELATIVE_PATH = join("docs", "validator-inventory.generated.yaml");
// RFC-1200: .probe joined the validator suffixes — a11y.axe.probe is a
// blocking read-only gate that emits ruleIds like any *.validate command.
const VALIDATOR_NAME_PATTERN = /\.validate$|\.check$|\.lint$|\.probe$/;
export interface ValidatorEntry {
command: string;
rules: string[];
pipelinePhase: string | null;
p95Ms: number | null;
}
export interface ContractEntry {
contract: string;
validators: ValidatorEntry[];
}
export interface ConsolidationCandidate {
contract: string;
validatorCount: number;
validators: string[];
}
export interface ValidatorInventoryFile {
meta: {
schemaVersion: 1;
deterministic: true;
generatedAt: null;
};
contracts: ContractEntry[];
consolidationCandidates: ConsolidationCandidate[];
untaggedCommands: string[];
}
export interface ValidatorInventoryGenerateResult {
command: "validator.inventory.generate";
written: boolean;
contractCount: number;
validatorCount: number;
consolidationCandidateCount: number;
untaggedCount: number;
}
function inventoryFilePath(workspaceRoot: string): string {
return join(workspaceRoot, INVENTORY_RELATIVE_PATH);
}
/**
* Search registry.pipelines for a command name and return the pipeline name
* if found. Returns null when the command is not in any registered pipeline.
*/
function derivePipelinePhase(
registry: KernelRuntimeContext["actualState"],
commandName: string,
): string | null {
for (const [pipelineName, decl] of registry.pipelines) {
if (decl.steps.some((step) => step.command === commandName)) {
return pipelineName;
}
}
return null;
}
/**
* Look up p95 timing for a validator from the committed budgets file.
*/
async function lookupP95(workspaceRoot: string, commandName: string): Promise {
const budgets = await loadPipelineBudgets(workspaceRoot);
if (!budgets) return null;
const entry = budgets.budgets.find((b) => b.command === commandName);
return entry?.p95Ms ?? null;
}
export async function runValidatorInventoryGenerate(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const dryRun = context.dryRun || input.flags["dry-run"] === true;
// The manifest fast-path hands this command a single-module actualState
// (only validator-inventory itself). The inventory needs the FULL workspace
// registry, so build it explicitly — cached, so cheap inside pipelines too.
const registry =
(await getOrBuildWorkspaceRegistry(context.workspaceRoot)) ?? context.actualState;
const allCommands = [...registry.commands.values()] as KernelCommandDefinition[];
const validatorCommands = allCommands.filter((cmd) => VALIDATOR_NAME_PATTERN.test(cmd.name));
// Check for untagged validators
const untaggedCommands: string[] = [];
for (const cmd of validatorCommands) {
if (cmd.contract === undefined || cmd.rules === undefined) {
untaggedCommands.push(cmd.name);
}
}
// Fail-closed: if any validator lacks contract/rules, return error
// (unless --dry-run, which emits warnings instead)
if (untaggedCommands.length > 0 && !dryRun) {
return {
data: {
command: "validator.inventory.generate",
written: false,
contractCount: 0,
validatorCount: validatorCommands.length,
consolidationCandidateCount: 0,
untaggedCount: untaggedCommands.length,
},
exitCode: 1,
summary: `[validator.inventory.generate] FAIL-CLOSED: ${untaggedCommands.length} validator(s) missing contract or rules: ${untaggedCommands.slice(0, 10).join(", ")}${untaggedCommands.length > 10 ? ` (and ${untaggedCommands.length - 10} more)` : ""}`,
nextSteps: [
{
action:
"Tag all untagged validators with `contract` and `rules` fields, or re-run with --dry-run for a warning-only report",
kind: "required",
},
],
};
}
// Group validators by contract
const contractsMap = new Map();
for (const cmd of validatorCommands) {
const contract = cmd.contract ?? "__untagged__";
const arr = contractsMap.get(contract) ?? [];
arr.push(cmd);
contractsMap.set(contract, arr);
}
// Build contract entries
const contracts: ContractEntry[] = [];
const consolidationCandidates: ConsolidationCandidate[] = [];
for (const [contract, validators] of contractsMap) {
const entries: ValidatorEntry[] = [];
for (const v of validators) {
const pipelinePhase = derivePipelinePhase(registry, v.name);
const p95Ms = await lookupP95(context.workspaceRoot, v.name);
entries.push({
command: v.name,
rules: v.rules ?? [],
pipelinePhase,
p95Ms,
});
}
entries.sort((a, b) => a.command.localeCompare(b.command));
contracts.push({ contract, validators: entries });
if (validators.length > 1 && contract !== "__untagged__") {
consolidationCandidates.push({
contract,
validatorCount: validators.length,
validators: validators.map((v) => v.name).sort(),
});
}
}
contracts.sort((a, b) => a.contract.localeCompare(b.contract));
consolidationCandidates.sort((a, b) => a.contract.localeCompare(b.contract));
const file: ValidatorInventoryFile = {
meta: {
schemaVersion: 1,
deterministic: true,
generatedAt: null,
},
contracts,
consolidationCandidates,
untaggedCommands: untaggedCommands.sort(),
};
const content = `${buildGeneratedHeader({ filePath: INVENTORY_RELATIVE_PATH, ownerCommand: "validator.inventory.generate" })}${yamlStringify(file)}\n`;
if (!dryRun) {
const outputPath = inventoryFilePath(context.workspaceRoot);
// RFC-1306 review: io-port write (atomic + mkdir built in) so the write is
// recorded in filesModified; skip byte-identical rewrites.
await writeFileIfChanged(context.io, outputPath, content);
}
const summaryParts = [
`[validator.inventory.generate] ${contracts.length} contract(s), ${validatorCommands.length} validator(s)`,
];
if (consolidationCandidates.length > 0) {
summaryParts.push(`${consolidationCandidates.length} consolidation candidate(s)`);
}
if (untaggedCommands.length > 0) {
summaryParts.push(`${untaggedCommands.length} untagged (warning — dry-run mode)`);
}
return {
data: {
command: "validator.inventory.generate",
written: !dryRun,
contractCount: contracts.length,
validatorCount: validatorCommands.length,
consolidationCandidateCount: consolidationCandidates.length,
untaggedCount: untaggedCommands.length,
},
exitCode: 0,
summary: summaryParts.join(" — "),
};
}