/* Shared GENERATED_MARKER constant and detection utility for RFC-0081 generated-file governance protocol. generated marker, RFC-0081, governance, file detection, RFC-0336, advisory header Export a single canonical GENERATED_MARKER string used by all generators. Export hasGeneratedMarker() for consistent detection across all generator modules. Export buildGeneratedHeader() — the single, comment-syntax-aware advisory block every generator should emit around the canonical marker line (RFC-0336), naming the owner command, optional template path, and the regenerate command. Export stripGeneratedMarker() covering both the single-line marker and the full advisory block, so handoff/absorb round-trips and template diffing stay clean either way. Do not perform file I/O — detection operates on in-memory content strings. Do not define per-file marker placement — callers decide where in the file the header goes (e.g. top-of-file for most generators, inside an Astro frontmatter fence for route templates). The canonical marker string constant. Check whether a content string contains the marker. Build the comment-syntax-aware advisory block (RFC-0336). Strip the marker line or the full advisory block from content. Moved canonical source from site-kernel-codegen to site-kernel to break circular dependency. RFC-0336: added buildGeneratedHeader() and extended stripGeneratedMarker() for the advisory block. Added editable flag to GeneratedHeaderInput — when true, emits a permissive marker and advisory that encourages agents to edit the file. AGENTS.md files use editable: true; other generated files keep the restrictive marker. */ export const GENERATED_MARKER = "GENERATED. Do not change this line unless the file contains project specific changes."; export const EDITABLE_GENERATED_MARKER = "GENERATED. Safe to edit — project-specific changes are expected and encouraged."; export function hasGeneratedMarker(content: string): boolean { return content.includes(GENERATED_MARKER) || content.includes(EDITABLE_GENERATED_MARKER); } /** * Extract the owning kernel command from a generated file's RFC-0336 advisory * header (`Owner command: ` in any comment syntax). Null when the file * carries a bare legacy marker without the advisory block — callers doing * owner-aware garbage collection must treat null as "not mine, leave it". */ export function generatedOwnerCommand(content: string): string | null { const match = /Owner command:[ \t]*([^\r\n*]+)/.exec(content); return match ? match[1].trim() : null; } export interface StripGeneratedMarkerResult { changed: boolean; content: string; } function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // RFC-0336: the fixed advisory lines buildGeneratedHeader() emits after the // canonical marker line. Order is fixed by buildGeneratedHeader; stripGeneratedMarker // mirrors that order so the whole block can be recognized and removed as one unit. const DO_NOT_EDIT_LINE = "DO NOT EDIT THIS FILE. Changes are overwritten on the next build."; const SAFE_TO_EDIT_LINE = "This file is generated but editable — your changes are preserved on regeneration."; export function stripGeneratedMarker(content: string): StripGeneratedMarkerResult { let result = content; let changed = false; const applyAll = (patterns: RegExp[]): void => { for (const pattern of patterns) { const next = result.replace(pattern, ""); if (next !== result) { changed = true; result = next; } } }; for (const [raw, advisory] of [ [GENERATED_MARKER, DO_NOT_EDIT_LINE], [EDITABLE_GENERATED_MARKER, SAFE_TO_EDIT_LINE], ] as const) { // RFC-0336: strip the full block-comment advisory header (.md / .css) first — // marker plus DO NOT EDIT / Owner command / Edit instead / Regenerate lines, // all wrapped in one or /* */ comment. applyAll([ new RegExp( `)[\\s\\S])*?${escapeRegex(raw)}(?:(?!-->)[\\s\\S])*?-->\\n?`, "g", ), new RegExp( `/\\*[ \\t]*\\n(?:(?!\\*/)[\\s\\S])*?${escapeRegex(raw)}(?:(?!\\*/)[\\s\\S])*?\\*/\\n?`, "g", ), ]); // RFC-0336: strip the line-comment advisory header (// or #) — the marker // line immediately followed by zero or more of the fixed advisory lines // using the same comment token, in buildGeneratedHeader's emission order. for (const token of ["//", "#"]) { const t = escapeRegex(token); const advisoryLine = (suffix: string): string => `(?:[ \\t]*${t}[ \\t]*${suffix}\\n)?`; const pattern = new RegExp( `^[ \\t]*${t}[ \\t]*${escapeRegex(raw)}\\n` + advisoryLine(escapeRegex(advisory)) + advisoryLine("Owner command:.*") + advisoryLine("Edit instead:.*") + advisoryLine("Regenerate:.*"), "gm", ); applyAll([pattern]); } // Legacy single-line marker forms (no advisory block). applyAll([ new RegExp(`^\\s*\\n?`, "gm"), new RegExp(`^\\s*//\\s*${escapeRegex(raw)}\\n?`, "gm"), new RegExp(`^\\s*/\\*\\s*${escapeRegex(raw)}\\s*\\*/\\n?`, "gm"), new RegExp(`^\\s*#\\s*${escapeRegex(raw)}\\n?`, "gm"), new RegExp(`^\\s*${escapeRegex(raw)}\\n?`, "gm"), ]); // Fallback: remove the raw marker text wherever it appears (e.g. embedded // inside a JSON string value like "_generated": "// GENERATED. ..."). if (result.includes(raw)) { const next = result.split(raw).join(""); if (next !== result) { changed = true; result = next; } } } return { changed, content: result }; } export interface GeneratedHeaderInput { /** Target file path (or just its name/extension) — selects the comment syntax. */ filePath: string; /** Owning kernel command name, e.g. "routes.generate". */ ownerCommand: string; /** Repo-relative template path, when the generator is template-backed. */ templatePath?: string; /** Site id, when the owner command is site-scoped, used to build the regenerate hint. */ site?: string; /** * When true, emits a permissive marker and advisory lines that encourage agents * to edit the file. Used for AGENTS.md files that are generated but should be * freely editable by agents working on the project. Defaults to false. */ editable?: boolean; } type GeneratedHeaderCommentStyle = "line-slash" | "line-hash" | "block-html" | "block-css"; function commentStyleForPath(filePath: string): GeneratedHeaderCommentStyle { const lower = filePath.toLowerCase().replace(/\\/g, "/"); const base = lower.slice(lower.lastIndexOf("/") + 1); if ( base === ".gitattributes" || base === "_headers" || base === "_redirects" || base.endsWith(".txt") || base.endsWith(".yml") || base.endsWith(".yaml") ) { return "line-hash"; } if (base.endsWith(".md")) return "block-html"; if (base.endsWith(".css")) return "block-css"; return "line-slash"; } /** * Build the RFC-0336 advisory header: the canonical GENERATED_MARKER line plus * a "DO NOT EDIT" line, the owner command, where to edit instead, and the exact * regenerate command — in the comment syntax appropriate for filePath's extension. * Callers place the returned block wherever the marker belongs in that file type * (normally the very first line(s); Astro route templates place it just inside * the opening frontmatter fence). */ export function buildGeneratedHeader(input: GeneratedHeaderInput): string { const style = commentStyleForPath(input.filePath); const editable = input.editable ?? false; const marker = editable ? EDITABLE_GENERATED_MARKER : GENERATED_MARKER; const advisoryLine = editable ? SAFE_TO_EDIT_LINE : DO_NOT_EDIT_LINE; const regenerateCommand = input.site ? `pnpm exec werkstatt run ${input.ownerCommand} --site ${input.site}` : `pnpm exec werkstatt run ${input.ownerCommand}`; const editInstead = input.templatePath ? `Edit instead: ${input.templatePath}` : `Edit instead: the ${input.ownerCommand} generator source (not this file).`; const lines = [ marker, advisoryLine, `Owner command: ${input.ownerCommand}`, editInstead, `Regenerate: ${regenerateCommand}`, ]; switch (style) { case "line-slash": return `${lines.map((line) => `// ${line}`).join("\n")}\n`; case "line-hash": return `${lines.map((line) => `# ${line}`).join("\n")}\n`; case "block-css": return `/*\n${lines.map((line) => ` ${line}`).join("\n")}\n*/\n`; case "block-html": return `\n`; } } export function isGeneratedMarkerTextCandidate(filePath: string): boolean { const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); const TEXT_EXTS = new Set([ ".txt", ".xml", ".md", ".yml", ".yaml", ".css", ".js", ".mjs", ".ts", ".json", ".html", ".astro", ]); return TEXT_EXTS.has(ext); }