/*
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);
}