/*
RFC-1263: fast-lane eligibility for mission.content-edit — decides whether
a mission's touched set resolves entirely inside the editable content
surface. Two input shapes: a parsed ops file (new missions) or the
workpiece's git-status file list (open-mission reuse). Re-evaluates the
live system manifest on every call — eligibility is never cached.
- Do not apply or plan ops — content-ops/apply.ts owns mutation.
- Do not loosen the conservative surface: block.* ops, system.md
selectors, render-affecting props (background/density/effects),
platform-scope files, and non-editable generated paths all route to the
full lane.
- RFC-1263: initial eligibility module — CAM-aware file surface via
resolveContentAuthority (legacy clientEditable projection when no
authority block), generated-path guard, structural-op exclusion.
- RFC-1263
*/
import path from "node:path";
import { loadSystemManifest } from "@warpgogol/werkstatt-shared/content";
import type { CamFieldClass, SystemManifest } from "@warpgogol/werkstatt-shared/ontology/schemas";
import {
loadGeneratedPathRegistry,
generatedWriteGuard,
type GeneratedPathRegistry,
} from "@warpgogol/werkstatt-shared/kernel/generated-paths";
import { resolveContentAuthority } from "@warpgogol/werkstatt-shared/authority";
import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
const CONTENT_PREFIX = "src/content/";
const PLATFORM_PREFIXES = ["packages/", "services/", "integrations/"];
/**
* Block props that drive rendering — the RFC-1263 risk carve-out: mutating
* them can break the visual contract in ways scoped validators cannot see,
* so they always route to the full lane.
*/
const RENDER_AFFECTING_PROPS = new Set(["background", "density", "effects"]);
/** Field classes that never qualify for the fast lane. */
const FULL_LANE_CLASSES: ReadonlySet = new Set(["structural", "shell"]);
/** The mutation-scope level a fast-lane op requires (matches content-ops). */
const FAST_LANE_OP_LEVEL = 2;
export interface ContentEditOpRef {
/** Ops-file op kind: set | patch | block.insert | block.move | block.remove. */
kind: string;
collection: string;
file: string;
fieldPath: string;
/** Explicit ops-file fieldClass override (content-ops honors it first). */
fieldClass?: string;
}
export interface EligibilityResult {
eligible: boolean;
reasons: string[];
}
/** Mirrors content-ops/apply.ts deriveFieldClass — kept in sync by contract. */
function opFieldClass(op: ContentEditOpRef): CamFieldClass {
if (
op.fieldClass === "structural" ||
op.fieldClass === "shell" ||
op.fieldClass === "navigation" ||
op.fieldClass === "narrative" ||
op.fieldClass === "claims" ||
op.fieldClass === "product" ||
op.fieldClass === "legal" ||
op.fieldClass === "media"
) {
return op.fieldClass;
}
if (op.collection === "navigation") return "navigation";
if (op.collection === "system") return "shell";
if (op.kind.startsWith("block.")) return "structural";
return "narrative";
}
/** Terminal prop name of an op's field path (last `.seg`, pred stripped). */
function terminalProp(fieldPath: string): string {
const last = fieldPath.split(".").pop() ?? "";
return last.replace(/\[.*\]$/, "");
}
/**
* Path-level editability for one site-relative file: under src/content/,
* inside the CAM-projected agent surface when an authority block exists,
* else inside clientEditable[] (+ the .client.ts/assets content rules).
*/
function fileEditable(
manifest: SystemManifest,
registry: GeneratedPathRegistry | null,
relPath: string,
): { editable: boolean; reason?: string } {
if (PLATFORM_PREFIXES.some((p) => relPath.startsWith(p))) {
return { editable: false, reason: "platform-scope path" };
}
if (!relPath.startsWith(CONTENT_PREFIX)) {
return { editable: false, reason: "outside src/content/" };
}
const verdict = generatedWriteGuard(registry, relPath);
if (!verdict.allowed) {
return {
editable: false,
reason: `non-editable generated path${verdict.owner ? ` (owner: ${verdict.owner})` : ""}`,
};
}
if (manifest.authority) {
const cam = resolveContentAuthority(manifest, {
subject: "agent-session",
fieldClass: "narrative",
path: relPath,
level: FAST_LANE_OP_LEVEL,
});
return cam.allowed
? { editable: true }
: { editable: false, reason: `CAM denies agent-session (source: ${cam.source})` };
}
const stripped = relPath.slice(CONTENT_PREFIX.length).replace(/\.(md|yaml|yml|json)$/i, "");
const entries = manifest.clientEditable ?? [];
if (
entries.some((e) => {
const n = e.replace(/^\//, "");
return stripped === n || stripped.startsWith(`${n}/`);
})
) {
return { editable: true };
}
if (relPath.endsWith(".client.ts") || relPath.includes("/assets/")) {
return { editable: true };
}
return { editable: false, reason: "outside clientEditable[]" };
}
/**
* Evaluates fast-lane eligibility for a touched set.
* - `files`: workpiece-relative changed paths (open-mission reuse).
* - `ops`: parsed ops-file ops (selectors already split into collection/file/fieldPath).
* Either or both may be given; the set is the union.
*/
export async function evaluateContentEditEligibility(input: {
siteDir: string;
files?: string[];
ops?: ContentEditOpRef[];
io: WorkspaceIO;
}): Promise {
const { siteDir, io } = input;
const reasons: string[] = [];
const contentDir = path.join(siteDir, "src", "content");
let manifest: SystemManifest;
try {
manifest = (await loadSystemManifest(contentDir, io)).manifest;
} catch (err) {
return {
eligible: false,
reasons: [
`cannot read ${contentDir}/system.md: ${err instanceof Error ? err.message : String(err)}`,
],
};
}
const registry = await loadGeneratedPathRegistry(siteDir, io);
for (const relPath of input.files ?? []) {
const normalized = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
const check = fileEditable(manifest, registry, normalized);
if (!check.editable) reasons.push(`${normalized}: ${check.reason}`);
}
for (const op of input.ops ?? []) {
const cls = opFieldClass(op);
const label = `${op.collection}/${op.file}${op.fieldPath ? `#${op.fieldPath}` : ""}`;
if (FULL_LANE_CLASSES.has(cls)) {
reasons.push(`${label}: ${op.kind} resolves field class "${cls}" — full lane`);
continue;
}
if (RENDER_AFFECTING_PROPS.has(terminalProp(op.fieldPath))) {
reasons.push(`${label}: render-affecting prop "${terminalProp(op.fieldPath)}" — full lane`);
continue;
}
// `system` ops already rejected above via the "shell" field class.
const relPath = `${CONTENT_PREFIX}${op.collection}/${op.file}`;
const check = fileEditable(manifest, registry, relPath);
if (!check.editable) reasons.push(`${label}: ${check.reason}`);
}
return { eligible: reasons.length === 0, reasons };
}