/* RFC-0266/RFC-1147: generates the single machine-readable command manifest — docs/command-manifest.generated.yaml — from the static command surface (workspace registry + canonical site module set, SITE_MODULE_MAP). Every other command-surface projection (docs/COMMANDS.md, the ecosystem projection's command section, CLI --help) should read from this manifest instead of independently re-walking the registry, so a command's own declaration is the single source of truth for its documentation, IO, and pipeline membership. Do not enforce reads/writes at runtime — that is rfc-0267 (WorkspaceIO port). Do not regenerate turbo.json — that is rfc-0259 Step 2. The manifest is generated, not hand-maintained — docs/command-manifest.generated.yaml is the single output. Command metadata comes from the static command surface (listStaticCommandSurface), not from doc parsing or discovered site instances. Deduplication is fail-closed (CMD-MAN-04): same-name commands across providers merge only when semantic fields agree. RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1254: drop unused io-migration leftovers RFC-1306 review-r1: manifest write migrated ambient writeFileAtomic to context.io via writeFileIfChanged — dry-run/readonly interception + honest filesModified. RFC-1320: dry-run now returns renderedFiles (canonical io-native dry-run contract) — generated.drift.validate --target=workspace can byte-verify the manifest instead of reporting render-unavailable. RFC-1334: steps 1-3 — shared structural reachability, reachableVia in the command manifest, coverage validator on the shared scan CMD-MAN-04, RFC-0266, RFC-1097, RFC-1147 */ import { createHash } from "node:crypto"; import { join } from "node:path"; import { parse as yamlParse, stringify as yamlStringify } from "yaml"; import { buildGeneratedHeader } from "./generated-marker.ts"; import { computeCommandReachability, INVOKER_SOURCE_GLOBS, isInvokerSource, writeFileIfChanged, type ReachabilityEdge, } from "@warpgogol/werkstatt-shared/kernel"; import { globSync, readFileSync } from "node:fs"; import { listStaticCommandSurface } from "./runtime.ts"; import type { CheckResult, Diagnostic, KernelCommandCoverage, KernelCommandInput, KernelCommandResult, KernelFlagSpec, KernelRegisteredCommandInfo, KernelRuntimeContext, ModuleExport, } from "@warpgogol/werkstatt-shared/kernel"; import { failResult } from "@warpgogol/werkstatt-shared/checks/result-helpers"; export interface CommandManifestEntry { name: string; description: string; scope: "app" | "workspace"; /** Sorted unique providers that register this command (workspace, site-module). */ providers: string[]; /** True when the command is registered by the canonical site module set (RFC-1147). */ siteScope: boolean; module: string | null; mutatesState: boolean; requiresNetwork: boolean; timeoutMs: number | null; /** RFC-1243: reachability class declaration, null when undeclared. */ coverage: KernelCommandCoverage | null; /** RFC-1243: invocation-surface note paired with `coverage`, null when absent. */ coverageNote: string | null; flags: Record; reads: string[]; writes: string[]; validatesOutputs: string[]; pipelines: string[]; /** RFC-1334: structural reachability (pipeline | invoker | coverage), sorted. */ reachableVia: ReachabilityEdge[]; } export interface CommandManifest { meta: { schemaVersion: 1; deterministic: true; generatedAt: null; contentHash: string }; commands: CommandManifestEntry[]; } /** Thrown by buildCommandManifest when same-name commands diverge across providers (CMD-MAN-04). */ export class CommandManifestDedupError extends Error { readonly diagnostics: Diagnostic[]; constructor(diagnostics: Diagnostic[]) { super( `Command manifest deduplication failed: ${diagnostics.length} conflict(s). ` + "Same-name commands across providers must agree on semantic fields.", ); this.name = "CommandManifestDedupError"; this.diagnostics = diagnostics; } } const MANIFEST_RELATIVE_PATH = join("docs", "command-manifest.generated.yaml"); export function manifestFilePath(workspaceRoot: string): string { return join(workspaceRoot, MANIFEST_RELATIVE_PATH); } export function collectCommandManifestDiagnostics( current: CommandManifest, committedRaw: string | undefined, ): Diagnostic[] { const diagnostics: Diagnostic[] = []; if (committedRaw === undefined) { diagnostics.push({ ruleId: "CMD-MAN-01", severity: "error", file: MANIFEST_RELATIVE_PATH.replace(/\\/g, "/"), message: "docs/command-manifest.generated.yaml is missing.", fixHint: "Run: pnpm exec werkstatt run command.manifest.generate", }); return diagnostics; } let committed: CommandManifest; try { committed = yamlParse(committedRaw); } catch { diagnostics.push({ ruleId: "CMD-MAN-01", severity: "error", file: MANIFEST_RELATIVE_PATH.replace(/\\/g, "/"), message: "docs/command-manifest.generated.yaml is not valid YAML.", fixHint: "Run: pnpm exec werkstatt run command.manifest.generate", }); return diagnostics; } if (committed.meta?.contentHash !== current.meta.contentHash) { diagnostics.push({ ruleId: "CMD-MAN-01", severity: "error", file: MANIFEST_RELATIVE_PATH.replace(/\\/g, "/"), message: "docs/command-manifest.generated.yaml is stale vs the live command registry.", fixHint: "Run: pnpm exec werkstatt run command.manifest.generate", }); } for (const entry of current.commands) { if (entry.mutatesState && entry.writes.length === 0) { diagnostics.push({ ruleId: "CMD-MAN-02", severity: "warning", message: `Command "${entry.name}" declares mutatesState: true but no writes.`, fixHint: "Declare the writes: [] glob(s) this command produces, or set mutatesState: false if it does not mutate the filesystem.", data: { command: entry.name }, }); } } // RFC-1243 (CMD-MAN-05): coverage declarations must stay inside the enum. // Checked on the committed file — it is the parsed (untyped) "manifest // declaration"; code-side illegal values land here via generation anyway. for (const entry of (committed.commands ?? []).map((command) => ({ command: command.name, coverage: (command as { coverage?: unknown }).coverage, }))) { if ( entry.coverage !== null && entry.coverage !== undefined && !COMMAND_COVERAGE_VALUES.has(entry.coverage as KernelCommandCoverage) ) { diagnostics.push({ ruleId: "CMD-MAN-05", severity: "error", message: `Command "${entry.command}" declares coverage "${String(entry.coverage)}" — ` + `legal values are: ${[...COMMAND_COVERAGE_VALUES].join(", ")}.`, fixHint: "Set coverage to one of operator | ci | delegated, or remove it and wire the command into a reachable surface (RFC-1243).", data: { command: entry.command, coverage: entry.coverage }, }); } } return diagnostics; } const COMMAND_COVERAGE_VALUES: ReadonlySet = new Set([ "operator", "ci", "delegated", ]); /** Builds the deterministic CommandManifest from the static command surface (RFC-1147). */ export async function buildCommandManifest( workspaceRoot: string, siteModules?: Record Promise>, ): Promise { const surface = await listStaticCommandSurface(workspaceRoot, siteModules); const pipelinesByCommand = new Map>(); for (const [pipelineName, memberCommands] of Object.entries(surface.pipelines)) { for (const commandName of memberCommands) { const set = pipelinesByCommand.get(commandName) ?? new Set(); set.add(pipelineName); pipelinesByCommand.set(commandName, set); } } const entries = dedupCommandEntries(surface.commands, pipelinesByCommand); // RFC-1334: invocation literals in source decide invoker edges — reading // sources makes the manifest drift (CMD-MAN-01) when an invocation changes. const sources = new Map(); for (const pattern of INVOKER_SOURCE_GLOBS) { for (const rel of globSync(pattern, { cwd: workspaceRoot })) { const normalized = String(rel).replace(/\\/g, "/"); if (!isInvokerSource(normalized) || sources.has(normalized)) continue; sources.set(normalized, readFileSync(join(workspaceRoot, normalized), "utf8")); } } const reachability = computeCommandReachability({ commands: entries, sources }); for (const entry of entries) entry.reachableVia = reachability.get(entry.name) ?? []; const withoutHash: Omit & { meta: Omit; } = { meta: { schemaVersion: 1, deterministic: true, generatedAt: null }, commands: entries, }; const contentHash = createHash("sha256").update(yamlStringify(withoutHash)).digest("hex"); return { ...withoutHash, meta: { ...withoutHash.meta, contentHash } }; } /** * Semantic fields compared across providers for same-name commands (CMD-MAN-04). * Provenance fields (module, provider, siteName) are excluded — they differ by * construction. */ const SEMANTIC_FIELDS = [ "description", "scope", "mutatesState", "requiresNetwork", "timeoutMs", "coverage", "coverageNote", "flags", "reads", "writes", "validatesOutputs", ] as const; type SemanticField = (typeof SEMANTIC_FIELDS)[number]; /** Canonicalize a value for cross-provider comparison: sort object keys recursively, keep array order. */ function canonicalize(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonicalize); if (value !== null && typeof value === "object") { const sorted: Record = {}; for (const key of Object.keys(value as Record).sort()) { sorted[key] = canonicalize((value as Record)[key]); } return sorted; } return value; } function semanticFieldValue(command: KernelRegisteredCommandInfo, field: SemanticField): unknown { switch (field) { case "mutatesState": return command.mutatesState === true; case "requiresNetwork": return command.requiresNetwork === true; case "timeoutMs": return command.timeoutMs ?? null; case "coverage": return command.coverage ?? null; case "coverageNote": return command.coverageNote ?? null; case "flags": return command.flags ?? {}; case "reads": return command.reads ?? []; case "writes": return command.writes ?? []; case "validatesOutputs": return command.validatesOutputs ?? []; default: return command[field]; } } /** * Merge per-provider command entries into one manifest entry per name. * Fail-closed (CMD-MAN-04): throws CommandManifestDedupError when same-name * commands disagree on any semantic field. */ function dedupCommandEntries( commands: KernelRegisteredCommandInfo[], pipelinesByCommand: Map>, ): CommandManifestEntry[] { const byName = new Map(); for (const command of commands) { const group = byName.get(command.name) ?? []; group.push(command); byName.set(command.name, group); } const diagnostics: Diagnostic[] = []; const entries: CommandManifestEntry[] = []; for (const [name, group] of byName) { const divergentFields = SEMANTIC_FIELDS.filter((field) => { const fingerprints = new Set( group.map((command) => JSON.stringify(canonicalize(semanticFieldValue(command, field)))), ); return fingerprints.size > 1; }); const providers = [...new Set(group.map((command) => command.provider))].sort(); if (divergentFields.length > 0) { diagnostics.push({ ruleId: "CMD-MAN-04", severity: "error", message: `Command "${name}" is registered by ${providers.length} providers ` + `(${providers.join(", ")}) with divergent declarations: ${divergentFields.join(", ")}.`, fixHint: "Align the command declarations across providers, or rename one of the commands. " + "The manifest is a stable surface — same-name commands must be semantically identical.", data: { command: name, providers, fields: divergentFields }, }); continue; } // All providers agree — merge. Module provenance: prefer the site-module // registration (canonical site surface), fall back to any non-null module. const siteModuleEntry = group.find((command) => command.provider === "site-module"); const representative = siteModuleEntry ?? group[0]; const module = siteModuleEntry?.module ?? group.find((command) => command.module)?.module ?? null; entries.push({ name, description: representative.description, scope: representative.scope, providers, siteScope: providers.includes("site-module"), module, mutatesState: representative.mutatesState === true, requiresNetwork: representative.requiresNetwork === true, timeoutMs: representative.timeoutMs ?? null, coverage: representative.coverage ?? null, coverageNote: representative.coverageNote ?? null, flags: representative.flags ?? {}, reads: representative.reads ?? [], writes: representative.writes ?? [], validatesOutputs: representative.validatesOutputs ?? [], pipelines: [...(pipelinesByCommand.get(name) ?? [])].sort(), reachableVia: [], }); } if (diagnostics.length > 0) { throw new CommandManifestDedupError(diagnostics); } entries.sort((a, b) => a.name.localeCompare(b.name)); return entries; } export async function runCommandManifestGenerate( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise< KernelCommandResult<{ command: "command.manifest.generate"; commandCount: number; written: boolean; renderedFiles?: Record; diagnostics?: Diagnostic[]; }> > { const dryRun = context.dryRun || input.flags["dry-run"] === true; let manifest: CommandManifest; try { manifest = await buildCommandManifest(context.workspaceRoot); } catch (err) { if (err instanceof CommandManifestDedupError) { return { ...failResult("command.manifest.generate", [ `aborted — ${err.diagnostics.length} dedup conflict(s) (CMD-MAN-04)`, ]), data: { command: "command.manifest.generate", commandCount: 0, written: false, diagnostics: err.diagnostics, }, }; } throw err; } const content = `${buildGeneratedHeader({ filePath: MANIFEST_RELATIVE_PATH, ownerCommand: "command.manifest.generate" })}${yamlStringify(manifest)}\n`; // RFC-1320: dry-run must surface renderedFiles — generated.drift.validate // byte-compares the manifest through them (emit's EVIDENCE-FRESH gate). if (dryRun) { return { data: { command: "command.manifest.generate", commandCount: manifest.commands.length, written: false, renderedFiles: { [MANIFEST_RELATIVE_PATH.replace(/\\/g, "/")]: content }, }, exitCode: 0, summary: `[command.manifest.generate] dry-run — ${manifest.commands.length} command(s)`, }; } const outputPath = manifestFilePath(context.workspaceRoot); // RFC-1306 review: write through the io port (atomic + mkdir built in) so // the artifact write lands in filesModified, and skip byte-identical // rewrites so a synced tree reports zero changes. await writeFileIfChanged(context.io, outputPath, content); return { data: { command: "command.manifest.generate", commandCount: manifest.commands.length, written: !dryRun, }, exitCode: 0, summary: dryRun ? `[command.manifest.generate] dry-run — ${manifest.commands.length} command(s)` : `[command.manifest.generate] wrote ${manifest.commands.length} command(s)`, }; } export async function runCommandManifestValidate( _input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { let current: CommandManifest; try { current = await buildCommandManifest(context.workspaceRoot); } catch (err) { if (err instanceof CommandManifestDedupError) { return diagnosticsResultLocal(err.diagnostics); } throw err; } let committedRaw: string | undefined; try { committedRaw = await context.io.readFile(manifestFilePath(context.workspaceRoot)); } catch { committedRaw = undefined; } const diagnostics = collectCommandManifestDiagnostics(current, committedRaw); return diagnosticsResultLocal(diagnostics); } function diagnosticsResultLocal(diagnostics: Diagnostic[]): KernelCommandResult { const summary = { error: diagnostics.filter((d) => d.severity === "error").length, warning: diagnostics.filter((d) => d.severity === "warning").length, info: diagnostics.filter((d) => d.severity === "info").length, }; const status: CheckResult["status"] = summary.error > 0 ? "fail" : summary.warning > 0 ? "warn" : "pass"; return { data: { command: "command.manifest.validate", status, diagnostics, summary }, exitCode: summary.error > 0 ? 1 : 0, summary: `[command.manifest.validate] ${summary.error} error${summary.error === 1 ? "" : "s"}, ${summary.warning} warning${summary.warning === 1 ? "" : "s"}`, }; }