/*
Shared reader + write-guard verdict for the generated-path registry
`.werkstatt/generated-paths.generated.yaml` (RFC-1262). The registry is
materialized per site by `generated.paths.generate` (build.prepare) as the
union of command `generates[]` declarations, RFC-0081 marker headers, and
`.gitattributes` linguist-generated marks. Consumed by both engine
(workpiece.write) and site (content.* ops) — keeping one reader prevents two
divergent ownership projections.
- Do not derive ownership — the site-side derivation module consumes buildGeneratorOwnership (RFC-0960).
- Do not fail closed when the registry is absent — absence means "inert" (GENW-02 advisory).
- RFC-1260: shared generated-path registry reader for the write guards
- RFC-1262: contract shape (`paths`, `sources`, `generatedAt`, `modulePath`) with legacy `entries`/`generatedBy` read-compat; added generatedWriteGuard verdict shared by workpiece.write and content ops.
RFC-1260, RFC-1262
*/
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import picomatch from "picomatch";
import { createDefaultIO, type WorkspaceIO } from "./workspace-io.ts";
export const GENERATED_PATHS_REGISTRY = ".werkstatt/generated-paths.generated.yaml";
export const GENERATED_PATHS_REGISTRY_VERSION = 1;
export type GeneratedPathSource = "command-generates" | "file-marker" | "gitattributes";
export interface GeneratedPathEntry {
/** Site-relative glob (e.g. "src/content/pages/de/cosmic/passport.md"). */
path: string;
/** Owning generator command name (e.g. "overlay.pages.generate"). */
owner: string;
/** Derivation sources that contributed this entry (RFC-1262 union). */
sources?: GeneratedPathSource[];
/** Marker-file escape hatch — editable entries are not guarded (RFC-1153). */
editable?: boolean;
/** Generator source module — feeds the sanctioned-edit hint. */
modulePath?: string;
}
export interface GeneratedPathRegistry {
version: number;
generatedAt?: string;
paths: GeneratedPathEntry[];
}
/**
* Loads the registry for a site directory. Returns null when absent or
* unreadable — the guard stays fail-open until `generated.paths.generate`
* materializes the file. Reads the RFC-1262 `paths` shape; legacy `entries`
* arrays (with `owner`/`generatedBy`) normalize into the same structure.
*/
export async function loadGeneratedPathRegistry(
siteDir: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const registryPath = join(siteDir, GENERATED_PATHS_REGISTRY);
try {
if (!(await io.exists(registryPath))) return null;
const parsed = parseYaml(await io.readFile(registryPath)) as {
version?: number;
generatedAt?: unknown;
paths?: unknown;
entries?: unknown;
} | null;
if (!parsed) return null;
const raw = Array.isArray(parsed.paths)
? parsed.paths
: Array.isArray(parsed.entries)
? parsed.entries
: null;
if (!raw) return null;
return {
version: typeof parsed.version === "number" ? parsed.version : 1,
generatedAt: typeof parsed.generatedAt === "string" ? parsed.generatedAt : undefined,
paths: raw
.filter((e): e is Record => typeof e === "object" && e !== null)
.filter((e) => typeof e.path === "string")
.map((e) => ({
path: e.path as string,
owner:
typeof e.owner === "string"
? e.owner
: typeof e.generatedBy === "string"
? e.generatedBy
: "unknown",
sources: Array.isArray(e.sources)
? e.sources.filter((s): s is GeneratedPathSource => typeof s === "string")
: undefined,
editable: e.editable === true,
modulePath: typeof e.modulePath === "string" ? e.modulePath : undefined,
})),
};
} catch {
return null;
}
}
/**
* Returns the matching non-editable entry when `relPath` (posix, site-relative)
* is covered by the registry, else undefined.
*/
export function generatedPathEntry(
registry: GeneratedPathRegistry | null,
relPath: string,
opts?: { includeEditable?: boolean },
): GeneratedPathEntry | undefined {
if (!registry) return undefined;
for (const entry of registry.paths) {
if (entry.editable === true && !opts?.includeEditable) continue;
if (picomatch.isMatch(relPath, entry.path, { dot: true })) return entry;
}
return undefined;
}
/**
* Returns the owning generator when `relPath` is covered by a non-editable
* registry entry, else undefined.
*/
export function generatedPathOwner(
registry: GeneratedPathRegistry | null,
relPath: string,
): string | undefined {
return generatedPathEntry(registry, relPath)?.owner;
}
export interface GeneratedWriteVerdict {
allowed: boolean;
owner?: string;
/**
* Sanctioned edit path (GENW-01): where the durable change belongs —
* the generator template/source — plus the regenerate command.
*/
hint?: string;
}
/**
* GENW-01 verdict shared by `workpiece.write` and the RFC-1260 content-op
* pipeline (aliased there as COPS-03). Editable entries pass; a missing
* registry fails open (absence is advisory-only under the GENW-02 rollout).
*/
export function generatedWriteGuard(
registry: GeneratedPathRegistry | null,
relPath: string,
): GeneratedWriteVerdict {
const entry = generatedPathEntry(registry, relPath);
if (!entry) return { allowed: true };
const editInstead = entry.modulePath
? `Edit the template in ${entry.modulePath} and re-run 'pnpm exec werkstatt run ${entry.owner}'.`
: entry.owner === "unknown"
? "Edit the owning generator's source or the file's declared input, then regenerate."
: `Edit the generator source and re-run 'pnpm exec werkstatt run ${entry.owner}'.`;
return {
allowed: false,
owner: entry.owner,
hint: `${editInstead} Direct edits are overwritten on the next build.`,
};
}