/*
RFC-1120: feature disclosure for notausgang.export — builds a dynamic-feature inventory from cache-clone-readable sources (system.md frontmatter, system-config.yaml, content-tree markers, release dist markers, opportunistic entitlements.generated.yaml) and renders feature-notes.md with static-ok / frozen-at-export / needs-backend verdicts.
- Do not read gitignored generated files as authoritative — entitlements.generated.yaml is opportunistic only (absent in cache clones).
- Do not produce a false static-ok — unknown or disagreeing sources default to needs-backend.
- RFC-1120: initial feature inventory + verdict mapping + feature-notes.md rendering.
*/
import path from "node:path";
import { parse as parseYaml } from "yaml";
import { parseMarkdownFrontmatter } from "@warpgogol/werkstatt-shared/content";
import type { NotausgangFeatureNote } from "@warpgogol/werkstatt-engine/schemas";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export type FeatureVerdict = NotausgangFeatureNote["verdict"];
export interface FeatureInventoryInput {
/** Directory of the resolved site workspace (cache clone or mission workpiece). */
siteDir: string;
/** Release dist directory (releases//dist) — may not exist. */
distDir: string;
/** system-config.yaml parsed content (already loaded by the caller). */
systemConfig: Record;
}
interface FeatureSignal {
feature: string;
verdict: FeatureVerdict;
detail: string;
}
/** Known feature-id → verdict mapping. Unknown ids default to needs-backend. */
const FEATURE_VERDICTS: Record = {
"portal-routes": {
verdict: "needs-backend",
detail:
"Portal pages are served by a Cloudflare Worker with bearer-token authorization; they are not part of the static dist output.",
},
"worker-serving": {
verdict: "needs-backend",
detail:
"The site is served through a Cloudflare Worker (dist/_worker.js). Static hosting serves the pre-built pages, but worker-level behavior (routing, headers, negotiation) is not reproduced.",
},
"markdown-negotiation": {
verdict: "needs-backend",
detail:
"Content negotiation for markdown/JSON variants runs in the Worker; static hosting serves HTML only.",
},
"currency-auto-refresh": {
verdict: "frozen-at-export",
detail:
"Currency conversion rates were refreshed at build time; the exported snapshot freezes the rates as of the release build.",
},
"freshness-checks": {
verdict: "frozen-at-export",
detail:
"Programmatic freshness/staleness checks ran at build time; the exported content is frozen as of the release build.",
},
};
const INTEGRATION_VERDICT = {
verdict: "needs-backend" as const,
detail:
"Declared integration requires a live backend or third-party service; the static export does not include it.",
};
function signalForFeatureId(featureId: string): FeatureSignal {
const known = FEATURE_VERDICTS[featureId];
if (known) return { feature: featureId, ...known };
if (featureId.startsWith("integrations.") || featureId.startsWith("integration-")) {
return { feature: featureId, ...INTEGRATION_VERDICT };
}
return {
feature: featureId,
verdict: "needs-backend",
detail:
"Entitlement or feature not in the known-mapping list; conservatively marked as requiring a backend (fail-honest).",
};
}
async function readSystemMdFrontmatter(
siteDir: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise> {
const systemMd = path.join(siteDir, "src", "content", "system.md");
if (!(await io.exists(systemMd))) return {};
const raw = await io.readFile(systemMd);
return parseMarkdownFrontmatter(raw).data;
}
async function readEntitlements(
siteDir: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
// Opportunistic only: entitlements.generated.yaml is gitignored in cache clones.
const p = path.join(siteDir, "src", "entitlements.generated.yaml");
if (!(await io.exists(p))) return [];
try {
const parsed = parseYaml(await io.readFile(p)) as { features?: unknown };
return Array.isArray(parsed.features) ? parsed.features.map(String) : [];
} catch {
return [];
}
}
/**
* Build the feature inventory as the union of cache-clone-readable sources.
* Any disagreement between declared and detected signals resolves to needs-backend.
*/
export async function buildFeatureInventory(
input: FeatureInventoryInput,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const detected = new Map();
// Source 1: system.md frontmatter — authored source of truth
const frontmatter = await readSystemMdFrontmatter(input.siteDir);
const entitlementsOverride = frontmatter.entitlementsOverride;
const overrideRaw = Array.isArray(entitlementsOverride)
? entitlementsOverride
: ((entitlementsOverride as { features?: unknown } | undefined)?.features ?? []);
for (const f of Array.isArray(overrideRaw) ? overrideRaw.map(String) : []) {
detected.set(f, signalForFeatureId(f));
}
const integrations = frontmatter.integrations as Record | undefined;
for (const key of Object.keys(integrations ?? {})) {
const id = `integrations.${key}`;
if (!detected.has(id)) detected.set(id, signalForFeatureId(id));
}
// Source 2: system-config.yaml deployment — platform-managed fact
const deployment = input.systemConfig.deployment as
{ adapter?: string; channels?: Record } | undefined;
const workerAdapter =
typeof deployment?.adapter === "string" && deployment.adapter.includes("worker");
// Source 3: content-tree markers
const portalContent = await io.exists(path.join(input.siteDir, "src", "content", "portal"));
// Source 4: release dist markers — _worker.js proves worker-level serving
const distHasWorker = await io.exists(path.join(input.distDir, "_worker.js"));
if (workerAdapter || distHasWorker) {
if (!detected.has("worker-serving")) {
detected.set("worker-serving", signalForFeatureId("worker-serving"));
}
}
if (portalContent && !detected.has("portal-routes")) {
detected.set("portal-routes", signalForFeatureId("portal-routes"));
}
// Source 5: opportunistic entitlements.generated.yaml (workpiece only)
for (const f of await readEntitlements(input.siteDir)) {
if (!detected.has(f)) detected.set(f, signalForFeatureId(f));
}
return [...detected.values()].sort((a, b) => a.feature.localeCompare(b.feature));
}
/** Render feature-notes.md content. Always non-empty, even with zero features. */
export function renderFeatureNotes(params: {
systemId: string;
releaseId: string;
exportedAt: string;
features: NotausgangFeatureNote[];
}): string {
const { systemId, releaseId, exportedAt, features } = params;
const lines: string[] = [
`# Feature notes — ${systemId}`,
"",
`Export of release \`${releaseId}\`, generated ${exportedAt}.`,
"",
"This file discloses dynamic features of the site that affect static-hosting viability.",
"Verdicts: `static-ok` (works as static files), `frozen-at-export` (shipped as a fixed snapshot), `needs-backend` (requires a server or third-party service not included in this package).",
"",
];
if (features.length === 0) {
lines.push("No dynamic features detected — the exported site is fully static.");
lines.push("");
return lines.join("\n");
}
lines.push("| Feature | Verdict | Notes |");
lines.push("| --- | --- | --- |");
for (const f of features) {
lines.push(`| \`${f.feature}\` | ${f.verdict} | ${f.detail} |`);
}
lines.push("");
return lines.join("\n");
}