/*
validate-declarations — declaration-time validation for kernel commands and components (RFC-1038).
- Does not validate generated file existence or staleness — that is the validators' job.
- Does not validate pipeline membership — that is ownership.sync.validate's job.
- RFC-1173: declare mutatesState on all kernel commands — collectDeclarationDiagnostics emits error-severity MUTATES-STATE-DECLARED, command.manifest.validate is the blocking consumer in packages.check, sweep declares the flag on every command definition (factories hardcode false for read-only check specs)
- RFC-1177: GENERATOR_OWNERSHIP_EXEMPT += csp.hashes.generate (check) — rewrites emitted dist/client/_headers in place, owns no generated artifact.
- RFC-1264
- RFC-1324: fix GENERATOR-EXEMPT-STALE — cloudflare.permissions.generate exemption rescoped check → leitstand (its registering module); yaml whitelist += werkstatt.cloudflare.json.
- RFC-1326: single Cloudflare permission registry, token preflight, birth provisioning
RFC-1038, RFC-1097, RFC-1138
*/
import type { CommandDeclaration } from "@warpgogol/werkstatt-shared/kernel";
/**
* Commands legitimately exempt from declaring `generates` entries despite having
* a `.generate` suffix. Each entry must carry a human-readable reason.
*
* Criteria for exemption:
* 1. The command declares no `writes` (no output files), OR
* 2. All its `writes` are covered by other commands' `generates` declarations.
*/
export const GENERATOR_OWNERSHIP_EXEMPT: ReadonlyArray<{
command: string;
reason: string;
/**
* RFC-1138: module that registers this command. The stale-entry check only
* fires when this module was loaded into the registry being validated —
* site registries load only site-plugin modules, so engine commands would
* otherwise always report as "stale".
*/
module?: string;
}> = [
{
command: "block.id.generate",
reason:
"Backfill migration command (RFC-0914): modifies existing page content files in-place, does not create new generated files.",
module: "check",
},
{
command: "docs.commands.generate",
reason:
"Workspace-scoped documentation generator (RFC-0222): writes docs/COMMANDS.md, not an app-scoped generated artifact.",
module: "check",
},
{
command: "rfc.index.generate",
reason:
"Workspace doc index (RFC-0260): docs/rfcs/index.yaml is written only under --write and carries no GENERATED marker — not a marker-tracked artifact.",
module: "rfc",
},
{
command: "forge.agents.generate",
reason:
"AGENTS.md instruction files are marker-merge documents (RFC-1150 skips hand-written files) — workspace docs, not app-scoped generated artifacts.",
module: "forge-core",
},
{
command: "funnel.statechart.generate",
reason:
"Workspace-scoped documentation generator (RFC-0219): writes docs/specs/visitor-funnel/state-chart.generated.md, not an app-scoped generated artifact.",
module: "check",
},
{
command: "ecosystem.manifest.generate",
reason:
"Workspace-scoped documentation generator (RFC-0245): writes docs/ecosystem.generated.yaml, not an app-scoped generated artifact.",
module: "check",
},
{
command: "maintenance.debt.queue.generate",
reason:
"Workspace-scoped documentation generator (RFC-0256): writes docs/maintenance-debt.queues.generated.yaml, not an app-scoped generated artifact.",
module: "check",
},
{
command: "gate.catalog.generate",
reason:
"Workspace-scoped documentation generator (RFC-0519): writes docs/gate-catalog.generated.yaml, not an app-scoped generated artifact.",
module: "check",
},
{
command: "agent.dns-aid.generate",
reason:
"Site-registered DNS record generator (RFC-0786): writes to systems-cache//dns-records.yaml outside the app directory, not an app-scoped generated artifact.",
module: "check",
},
{
command: "check.report.generate",
reason:
"Workspace-scoped report generator (RFC-0297): writes to .check-warpgogol/runs/**, not an app-scoped generated artifact.",
module: "check",
},
{
command: "check.action-pack.generate",
reason:
"Workspace-scoped report generator (RFC-0297): writes to .check-warpgogol/runs/**, not an app-scoped generated artifact.",
module: "check",
},
{
command: "public.artifact.generate",
reason:
"Aggregate generation boundary (RFC-0307): delegates to dedicated generators that own the actual files, does not produce new files itself.",
module: "check",
},
{
command: "headers.security.generate",
reason:
"Security header generation boundary (RFC-0315): baseline headers are emitted by public.infrastructure.generate, this command does not produce new files itself.",
module: "check",
},
{
command: "not-found.generate",
reason:
"Generation boundary (RFC-0310): routes.generate owns src/pages/404.astro, this command delegates and does not produce new files itself.",
module: "check",
},
{
command: "ai.generate",
reason:
"Legacy AI policy generator (RFC-0051): ai.policy.generate (RFC-0313) now owns public/ai.txt, this command delegates and does not produce new files itself.",
module: "check",
},
{
command: "gitattributes.generate",
reason:
"Workspace-scoped config generator (RFC-0336): rewrites the managed block in .gitattributes from ownership map data, not an app-scoped generated artifact.",
module: "check",
},
{
command: "fleet.sites.generate",
reason:
"Workspace-scoped fleet config generator (RFC-0964): writes fleet/fleet.sites.yaml from systems-cache discovery, not an app-scoped generated artifact.",
module: "fleet",
},
{
command: "nachweis.manifest.generate",
reason:
"Workspace-scoped nachweis generator (RFC-0707): writes to /public/nachweise/manifest.json outside the app directory, not an app-scoped generated artifact.",
module: "nachweis",
},
{
command: "sichtpass.generate",
reason:
"Workspace-scoped visibility snapshot generator (RFC-0947): appends Bordbuch entries, does not produce generated files in the app directory.",
module: "sichtpass",
},
{
command: "signing.key.generate",
reason:
"Workspace-scoped key generator (RFC-0921): writes to a parameterized --output-dir, not an app-scoped generated artifact.",
module: "signing",
},
{
command: "sternsystem.passport.generate",
reason:
"Workspace-scoped passport generator (RFC-0966): writes signed passport.json to systems-cache/{id}/, not an app-scoped generated artifact.",
module: "sternsystem",
},
{
command: "site.exports.generate",
reason:
"Workspace-scoped exports-map generator (RFC-1101): rewrites packages/werkstatt-site/package.json exports in place — the manifest itself, not an app-scoped generated artifact.",
module: "check",
},
{
command: "csp.hashes.generate",
reason:
"Build-post CSP mutator (RFC-1177): rewrites the emitted dist/client/_headers script-src in place — public.infrastructure.generate owns the file, this command produces no new generated artifact.",
module: "check",
},
{
command: "mission.digest.generate",
reason:
"Mission evidence emitter (RFC-1264): writes missions//evidence/mission-digest/* — operator-review evidence, not an app-scoped generated artifact.",
module: "check",
},
{
command: "cloudflare.permissions.generate",
reason:
"Registry surface renderer (RFC-1326): rewrites marked cloudflare-permissions sections inside operator-owned .env.example and docs/engineering/workshop-setup.md — partial-file rewrites, no app-scoped generated artifact.",
module: "leitstand",
},
];
const EXEMPT_COMMANDS = new Set(GENERATOR_OWNERSHIP_EXEMPT.map((e) => e.command));
function isGlobPath(path: string): boolean {
return path.includes("*") || path.includes("{") || path.includes("?");
}
/**
* RFC-1173: structured declaration diagnostic — machine-consumable form of
* the checks below. `severity: "error"` entries are blocking where a
* consumer chooses to block (command.manifest.validate); "warn" entries keep
* the historical warn-only behavior.
*/
export interface DeclarationDiagnostic {
ruleId: string;
severity: "error" | "warn";
command: string;
message: string;
}
/**
* RFC-1173: collect declaration diagnostics for all registered commands.
* Structured replacement for the former plain-string collection — callers
* decide how to surface each entry (registry build logs them as warnings;
* command.manifest.validate blocks on severity "error").
*
* Checks:
* 1. Every command with a modulePath MUST have non-empty value. (warn)
* 2. Every command with `writes` OR name ending `.generate` MUST have `generates`
* (>=1 entry OR `generates: []` for non-generating writes). (warn)
* `.generate` commands with `generates: []` must appear in GENERATOR_OWNERSHIP_EXEMPT.
* 3. Non-glob path uniqueness across all `generates` entries. (warn)
* 4. RFC-0963: Validator commands (name matching *.validate|*.check|*.lint) SHOULD
* declare `contract` and `rules`. (warn)
* 5. RFC-1173: Every command MUST declare `mutatesState` explicitly —
* undeclared ⇒ conservatively treated as mutating by the scheduler.
* (error — consumed as blocking by command.manifest.validate)
* 6. RFC-1173: A command declaring non-empty `writes`/`generates` MUST NOT
* declare `mutatesState: false` — declared outputs imply mutation; `false`
* marks the step safe to run tainted under collect-errors, the unsafe
* direction. (warn — 19 commands misclassified this way in the initial
* sweep, fixed in 6.320.0)
*/
export function collectDeclarationDiagnostics(
commands: ReadonlyMap,
opts?: { loadedModules?: ReadonlySet },
): DeclarationDiagnostic[] {
const diagnostics: DeclarationDiagnostic[] = [];
const seenNonGlobPaths = new Map();
for (const [commandName, command] of commands) {
if (command.modulePath === undefined) continue;
if (command.modulePath.trim().length === 0) {
diagnostics.push({
ruleId: "MODULE-PATH-MISSING",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` is missing required \`modulePath\` (repo-relative path to implementation source file).`,
});
}
if (command.mutatesState === undefined) {
diagnostics.push({
ruleId: "MUTATES-STATE-DECLARED",
severity: "error",
command: commandName,
message: `Command \`${commandName}\` does not declare \`mutatesState\` (RFC-1173). Add \`mutatesState: false\` for read-only commands or \`mutatesState: true\` for mutating ones.`,
});
}
const isGenerateCommand = commandName.endsWith(".generate");
const hasWrites = command.writes && command.writes.length > 0;
const hasGenerates = command.generates !== undefined && command.generates.length > 0;
if (command.mutatesState === false && (hasWrites || hasGenerates)) {
diagnostics.push({
ruleId: "MUTATES-STATE-WRITES-MISMATCH",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` declares ${hasWrites ? "writes" : "generates"} but \`mutatesState: false\` (RFC-1173). Declared outputs imply mutation — \`false\` marks the step safe to run tainted under collect-errors. Either set \`mutatesState: true\` or remove the declared outputs if they are stale.`,
});
}
// RFC-1303: declaration-shape checks for envInputs / cacheWrites. The
// sweep (undeclared reads) and git-ignore verification live in
// command.manifest.validate's site layer; these cover malformed entries.
const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
const seenEnvInputs = new Set();
for (const envName of command.envInputs ?? []) {
if (!ENV_NAME_RE.test(envName)) {
diagnostics.push({
ruleId: "ENV-INPUT-01",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` declares envInputs entry \`${envName}\` that is not a valid env var name (^[A-Z_][A-Z0-9_]*$).`,
});
}
if (seenEnvInputs.has(envName)) {
diagnostics.push({
ruleId: "ENV-INPUT-01",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` declares envInputs entry \`${envName}\` twice.`,
});
}
seenEnvInputs.add(envName);
}
for (const prefix of command.cacheWrites ?? []) {
if (command.mutatesState !== false) {
diagnostics.push({
ruleId: "CACHE-WRITE-01",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` declares cacheWrites but is not \`mutatesState: false\` — the carve-out only applies to read-only commands (mutating commands write freely).`,
});
}
if (command.scope !== "app" && prefix.startsWith("/")) {
diagnostics.push({
ruleId: "CACHE-WRITE-01",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` is workspace-scoped but declares cacheWrites entry \`${prefix}\` — \`/\` prefixes only resolve for app-scope commands.`,
});
}
if (!prefix.endsWith("/")) {
diagnostics.push({
ruleId: "CACHE-WRITE-01",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` declares cacheWrites entry \`${prefix}\` that is not a directory prefix (must end with "/") — the carve-out is prefix-shaped, not file-shaped.`,
});
}
}
if (isGenerateCommand || hasWrites) {
if (command.generates === undefined) {
diagnostics.push({
ruleId: "GENERATES-MISSING",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` must declare \`generates\` (it ${isGenerateCommand ? "has a .generate suffix" : "has writes"}. Add generates: [...] or generates: [] for non-generating writes${isGenerateCommand ? " (and list in GENERATOR_OWNERSHIP_EXEMPT with a reason if generates: [])" : ""}).`,
});
} else if (isGenerateCommand && command.generates.length === 0) {
if (!EXEMPT_COMMANDS.has(commandName)) {
diagnostics.push({
ruleId: "GENERATOR-EXEMPT-MISSING",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` has \`.generate\` suffix and \`generates: []\` but is not in GENERATOR_OWNERSHIP_EXEMPT. Add it with a reason or declare actual generates entries.`,
});
}
}
}
const isValidatorCommand =
commandName.endsWith(".validate") ||
commandName.endsWith(".check") ||
commandName.endsWith(".lint");
if (isValidatorCommand) {
if (command.contract === undefined) {
diagnostics.push({
ruleId: "VALIDATOR-CONTRACT-MISSING",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` is a validator but does not declare \`contract\` (RFC-0963). Add a contract tag to group this validator with others enforcing the same surface.`,
});
}
if (command.rules === undefined) {
diagnostics.push({
ruleId: "VALIDATOR-RULES-MISSING",
severity: "warn",
command: commandName,
message: `Command \`${commandName}\` is a validator but does not declare \`rules\` (RFC-0963). Add the rule IDs this validator enforces.`,
});
}
}
if (command.generates) {
for (const spec of command.generates) {
if (!isGlobPath(spec.path)) {
const existingOwner = seenNonGlobPaths.get(spec.path);
if (existingOwner) {
diagnostics.push({
ruleId: "GENERATES-PATH-COLLISION",
severity: "warn",
command: commandName,
message: `Non-glob path \`${spec.path}\` is claimed by both \`${existingOwner}\` and \`${commandName}\`. Generated file ownership must be unique.`,
});
} else {
seenNonGlobPaths.set(spec.path, commandName);
}
}
}
}
}
for (const exempt of GENERATOR_OWNERSHIP_EXEMPT) {
// RFC-1138: module-scoped entries are only stale when their owning module
// was loaded — site registries never load engine modules, so checking
// them there produces false "stale" warnings on every registry build.
if (exempt.module && opts?.loadedModules && !opts.loadedModules.has(exempt.module)) {
continue;
}
if (!commands.has(exempt.command)) {
diagnostics.push({
ruleId: "GENERATOR-EXEMPT-STALE",
severity: "warn",
command: exempt.command,
message: `GENERATOR_OWNERSHIP_EXEMPT entry \`${exempt.command}\` does not match any registered command.`,
});
}
}
return diagnostics;
}
/**
* RFC-1038: Validate command declarations after all modules are loaded.
* Collects all violations and logs them as warnings (same behavior as
* validateRegistration — warn-only, does not throw). RFC-1173: diagnostics
* are now produced by collectDeclarationDiagnostics; the error-severity
* MUTATES-STATE-DECLARED entries additionally block via
* command.manifest.validate (PACKAGES_CHECK_PIPELINE).
*/
export function validateDeclarations(
commands: ReadonlyMap,
opts?: { loadedModules?: ReadonlySet },
): void {
const diagnostics = collectDeclarationDiagnostics(commands, opts);
if (diagnostics.length > 0) {
console.warn(
`Declaration validation (${diagnostics.length} violation(s)):\n${diagnostics
.map((d) => ` - [${d.ruleId}] ${d.message}`)
.join("\n")}`,
);
}
}