/* 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(" — "), }; }