/*
surface.contract.validate — validate generated C-surfaces against their declarative contracts (RFC-0480).
- Does not generate C-surfaces — only validates them against the contract.
- Does not modify the contract — contracts are declarative in @warpgogol/werkstatt-shared/ontology/external-surfaces.
- RFC-0480: initial surface.contract.validate command handler.
- RFC-0498: add per-depth JSON-LD type policy checks — verify surfacePolicy is present and no required/prohibited overlap.
- RFC-0499: add mediaLeakagePolicy checks — verify policy is present and structurally valid.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- RFC-1254: drop unused io-migration leftovers
*/
import path from "node:path";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import {
urlSchema,
jsonldTypes,
sitemapShape,
} from "@warpgogol/werkstatt-shared/ontology/external-surfaces";
import { resolveCacheClonePath } from "../sternsystem/registry-io.ts";
export interface SurfaceContractValidateData {
systemId: string | null;
violations: Array<{ surface: string; rule: string; message: string }>;
validatedSurfaces: string[];
}
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
export async function runSurfaceContractValidate(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const systemId = flagString(input, "app") ?? null;
const violations: Array<{ surface: string; rule: string; message: string }> = [];
const validatedSurfaces: string[] = [];
// Determine the site directory to validate
let siteDir: string;
if (systemId) {
siteDir = await resolveCacheClonePath(workspaceRoot, systemId);
} else {
// Default: validate the first active system or the workspace root
siteDir = workspaceRoot;
}
// 1. Validate URL patterns against route registry
const routeRegistryPath = path.join(siteDir, "src", "surface.generated.json");
const routesPath = path.join(siteDir, "src", "content", "routes.yaml");
if (await context.io.exists(routeRegistryPath)) {
try {
const raw = await context.io.readFile(routeRegistryPath);
const routes = JSON.parse(raw) as Array<{ pattern?: string; path?: string }>;
const contractPatterns = urlSchema.routePatterns.map((p) => p.pattern);
for (const route of routes) {
const routePath = route.path ?? route.pattern;
if (!routePath) continue;
// Check if route matches any contract pattern
const matches = contractPatterns.some((pattern) => {
const regex = patternToRegex(pattern);
return regex.test(routePath);
});
if (!matches) {
violations.push({
surface: "url-schema",
rule: "unmatched-route",
message: `Route '${routePath}' does not match any pattern in url-schema.yaml`,
});
}
}
validatedSurfaces.push("url-schema");
} catch (err) {
violations.push({
surface: "url-schema",
rule: "parse-error",
message: `Failed to parse surface.generated.json: ${(err as Error).message}`,
});
}
} else if (await context.io.exists(routesPath)) {
// Fallback: check routes.yaml if surface.generated.json doesn't exist
validatedSurfaces.push("url-schema (routes.yaml)");
} else {
// No routes — empty system, no violations
validatedSurfaces.push("url-schema (empty)");
}
// 2. Validate JSON-LD types
// Check that any JSON-LD templates use only declared @type values
const contentDir = path.join(siteDir, "src", "content");
if (await context.io.exists(contentDir)) {
const declaredTypes = new Set(jsonldTypes.types.map((t) => t["@type"]));
// This is a lightweight check — full validation would scan all pages
// For now, just verify the contract is loadable and types are declared
if (declaredTypes.size > 0) {
validatedSurfaces.push("jsonld-types");
} else {
violations.push({
surface: "jsonld-types",
rule: "empty-types",
message: `jsonld-types.yaml declares no types`,
});
}
} else {
validatedSurfaces.push("jsonld-types (empty)");
}
// RFC-0498: Validate surfacePolicy section in the C-contract.
if (jsonldTypes.surfacePolicy && jsonldTypes.surfacePolicy.length > 0) {
for (const entry of jsonldTypes.surfacePolicy) {
const requiredSet = new Set(entry.requiredTypes);
const prohibitedSet = new Set(entry.prohibitedTypes);
const overlap = [...requiredSet].filter((t) => prohibitedSet.has(t));
if (overlap.length > 0) {
violations.push({
surface: "jsonld-types",
rule: "jsonld-surface-policy-overlap",
message: `surfacePolicy entry (${entry.surface} depth-${entry.depth}) has overlapping required and prohibited types: ${overlap.join(", ")}`,
});
}
}
validatedSurfaces.push("jsonld-surface-policy");
} else {
violations.push({
surface: "jsonld-types",
rule: "jsonld-surface-policy-missing",
message: `jsonld-types.yaml has no surfacePolicy section — required by RFC-0498`,
});
}
// RFC-0506: Validate ratgeber depth-1 Article field policy in the C-contract.
const articleType = jsonldTypes.types.find((t) => t["@type"] === "Article");
if (articleType) {
const optionalFields = new Set(articleType.optional);
if (!optionalFields.has("description")) {
violations.push({
surface: "jsonld-types",
rule: "jsonld-article-field-missing",
message: `Article type optional fields must include "description" — required by RFC-0506`,
});
}
if (!optionalFields.has("mainEntityOfPage")) {
violations.push({
surface: "jsonld-types",
rule: "jsonld-article-field-missing",
message: `Article type optional fields must include "mainEntityOfPage" — required by RFC-0506`,
});
}
}
const ratgeberDepth1Policy = jsonldTypes.surfacePolicy?.find(
(p) => p.surface === "ratgeber" && p.depth === 1,
);
if (ratgeberDepth1Policy) {
if (!ratgeberDepth1Policy.prohibitedTypes.includes("FAQPage")) {
violations.push({
surface: "jsonld-types",
rule: "jsonld-surface-policy-missing",
message: `ratgeber depth-1 prohibitedTypes must include "FAQPage" — required by RFC-0506`,
});
}
}
// RFC-0499: Validate mediaLeakagePolicy section in the C-contract.
if (jsonldTypes.mediaLeakagePolicy) {
const policy = jsonldTypes.mediaLeakagePolicy;
if (policy.prohibitedStrings.length === 0) {
violations.push({
surface: "jsonld-types",
rule: "media-leakage-policy-empty",
message: `mediaLeakagePolicy has no prohibitedStrings — required by RFC-0499`,
});
}
if (policy.requiredLabels.length === 0) {
violations.push({
surface: "jsonld-types",
rule: "media-leakage-policy-no-labels",
message: `mediaLeakagePolicy has no requiredLabels — required by RFC-0499`,
});
}
if (!policy.requiredLinkPattern) {
violations.push({
surface: "jsonld-types",
rule: "media-leakage-policy-no-link-pattern",
message: `mediaLeakagePolicy has no requiredLinkPattern — required by RFC-0499`,
});
}
if (!policy.aiImageAttribute) {
violations.push({
surface: "jsonld-types",
rule: "media-leakage-policy-no-ai-attribute",
message: `mediaLeakagePolicy has no aiImageAttribute — required by RFC-0499`,
});
}
validatedSurfaces.push("jsonld-media-leakage-policy");
} else {
violations.push({
surface: "jsonld-types",
rule: "media-leakage-policy-missing",
message: `jsonld-types.yaml has no mediaLeakagePolicy section — required by RFC-0499`,
});
}
// 3. Validate sitemap shape
// Check that sitemap.xml (if exists) conforms to the declared shape
const sitemapPath = path.join(siteDir, "dist", "sitemap.xml");
if (await context.io.exists(sitemapPath)) {
try {
const raw = await context.io.readFile(sitemapPath);
const requiredFields = sitemapShape.urlEntry.required;
const hasLoc = raw.includes("");
if (requiredFields.includes("loc") && !hasLoc) {
violations.push({
surface: "sitemap-shape",
rule: "missing-loc",
message: `sitemap.xml does not contain required elements`,
});
}
validatedSurfaces.push("sitemap-shape");
} catch (err) {
violations.push({
surface: "sitemap-shape",
rule: "parse-error",
message: `Failed to parse sitemap.xml: ${(err as Error).message}`,
});
}
} else {
// No sitemap — empty system or not built yet
validatedSurfaces.push("sitemap-shape (empty)");
}
if (violations.length === 0) {
logger.success(
`[surface.contract.validate] ${validatedSurfaces.length} surface(s) validated, 0 violations`,
);
} else {
logger.error(
`[surface.contract.validate] ${validatedSurfaces.length} surface(s) validated, ${violations.length} violation${violations.length === 1 ? "" : "s"}`,
);
for (const v of violations) {
logger.error(` [${v.rule}] ${v.surface}: ${v.message}`);
}
}
return {
data: { systemId, violations, validatedSurfaces },
exitCode: violations.length > 0 ? 1 : 0,
summary: `[surface.contract.validate] ${validatedSurfaces.length} surface(s) validated, ${violations.length} violation${violations.length === 1 ? "" : "s"}`,
};
}
function patternToRegex(pattern: string): RegExp {
// Convert URL pattern like "/:locale?/:slug" to a regex
let regex = pattern.replace(/:locale\?/g, "(?:[a-z]{2}/)?");
regex = regex.replace(/:([a-zA-Z]+)/g, "([^/]+)");
regex = `^${regex}$`;
return new RegExp(regex);
}