From 89648cceac584148833463a64d43e7db575bf2d1 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Wed, 4 Feb 2026 17:49:14 -0800 Subject: [PATCH] feat(phase0.5): validate-extracted with surfaces[].phase0 and v2 exit codes - Schema: add optional surfaces[].phase0 (authPosture, requiresShell, expectsAuthRoutes, expectsDesignSystem) - CLI: validate-extracted reads phase0 only (no x_* in policy), --surface and --exit-codes v2 - v2 exit codes: 0 success, 10 E0 (load/parse), 30 E2 (mismatch) - Findings: phase0.authPosture.mismatch, phase0.authRoutes.missing, phase0.shell.mismatch, phase0.designSystem.missing - Deterministic output order (surfaceId, code). Tests and API/README docs. Co-authored-by: Cursor --- API.md | 30 ++ README.md | 10 + .../dist/commands/validate-extracted.d.ts | 35 ++ .../dist/commands/validate-extracted.d.ts.map | 1 + .../dist/commands/validate-extracted.js | 250 +++++++++++++ packages/interfacectl-cli/dist/index.js | 20 ++ .../src/commands/validate-extracted.ts | 337 ++++++++++++++++++ packages/interfacectl-cli/src/index.ts | 22 ++ .../contract-no-phase0.json | 16 + .../validate-extracted/contract-phase0.json | 22 ++ .../extracted-auth-mismatch.json | 9 + .../extracted-ds-missing.json | 9 + .../validate-extracted/extracted-ok.json | 9 + .../extracted-shell-mismatch.json | 9 + .../test/validate-extracted.test.mjs | 109 ++++++ .../schema/surfaces.web.contract.schema.json | 14 + .../schema/surfaces.web.contract.schema.json | 14 + 17 files changed, 916 insertions(+) create mode 100644 packages/interfacectl-cli/dist/commands/validate-extracted.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/validate-extracted.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/validate-extracted.js create mode 100644 packages/interfacectl-cli/src/commands/validate-extracted.ts create mode 100644 packages/interfacectl-cli/test/fixtures/validate-extracted/contract-no-phase0.json create mode 100644 packages/interfacectl-cli/test/fixtures/validate-extracted/contract-phase0.json create mode 100644 packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-auth-mismatch.json create mode 100644 packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-ds-missing.json create mode 100644 packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-ok.json create mode 100644 packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-shell-mismatch.json create mode 100644 packages/interfacectl-cli/test/validate-extracted.test.mjs diff --git a/API.md b/API.md index c4cc5fd..4a8ad20 100644 --- a/API.md +++ b/API.md @@ -444,6 +444,36 @@ interfacectl generate-contract --app-root --surface [--out ] [ --- +### `validate-extracted` (Phase 0.5) + +Fails if the contract’s Phase 0 expectations (`surfaces[].phase0`) conflict with extracted reality. Use after `generate-contract` to gate policy vs. code. + +**Synopsis:** +```bash +interfacectl validate-extracted --contract --extracted [--surface ] [--format text|json] [--exit-codes v1|v2] +``` + +**Contract shape (policy only; no x_* in policy):** +Optional per-surface block `surfaces[].phase0`: +- `authPosture`: `"public"` | `"auth-aware"` | `"auth-first"` +- `requiresShell`: boolean +- `expectsAuthRoutes`: boolean +- `expectsDesignSystem`: boolean + +**Extracted input:** (1) Extraction report: `{ surfaceId, extracted: { routes, hasShell, designSystemComponents, authAware } }`. (2) Generated contract with `x_extracted`; use `--surface ` when surfaceId cannot be inferred. + +**Validation rules:** +Auth posture: auth-first ⇒ authAware true and all four auth routes; auth-aware ⇒ authAware true. Shell: requiresShell true and hasShell false ⇒ mismatch. Auth routes: when expectsAuthRoutes true, requires `/auth/login`, `/auth/callback`, `/auth/session`, `/auth/logout`. Design system: expectsDesignSystem true and empty designSystemComponents ⇒ mismatch. + +**Findings:** Deterministic order (surfaceId, then code). Codes: `phase0.authPosture.mismatch`, `phase0.authRoutes.missing`, `phase0.shell.mismatch`, `phase0.designSystem.missing`. Category E2 for mismatches, E0 for load/parse errors. + +**Exit codes (v2):** +- `0`: Success (no phase0 block or no mismatches). +- `10`: E0 (load/parse/schema or internal error). +- `30`: E2 (one or more mismatches). + +--- + ## Traceability fields (Phase 2) Diff and enforce JSON output include optional traceability fields for correlation and debugging. These fields are additive and optional; existing consumers should keep working. diff --git a/README.md b/README.md index 48be51f..56387f6 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,16 @@ interfacectl generate-contract --app-root --surface [--out

--extracted [--surface ] [--format text|json] [--exit-codes v2] +``` + +Add optional `phase0` to each surface in your policy contract: `authPosture` (public | auth-aware | auth-first), `requiresShell`, `expectsAuthRoutes`, `expectsDesignSystem`. v2 exit codes: 0 success, 10 E0 (load/parse error), 30 E2 (mismatch). See [API.md](API.md#validate-extracted-phase-05) for rules and finding codes. + For complete command documentation with all options, exit codes, and output formats, see [API.md](API.md). **Generation-time gating:** `interfacectl validate` is the canonical command for contract compliance. Use it to gate changes before merge or deployment. For local use and CI, run validate with your contract path and, for deterministic exit codes, use `--exit-codes v2`. The command `enforce --mode fail` is optional; it runs a structural diff then applies a policy threshold and is useful when you want to block on diff severity separately from compliance. diff --git a/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts b/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts new file mode 100644 index 0000000..b403fe0 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts @@ -0,0 +1,35 @@ +/** Phase 0 extracted fields (extractor shape). */ +export interface ExtractedFields { + routes: string[]; + hasShell: boolean; + designSystemComponents: string[]; + authAware: boolean; +} +/** Optional per-surface Phase 0 expectations (policy contract). */ +export interface Phase0Expectations { + authPosture?: "public" | "auth-aware" | "auth-first"; + requiresShell?: boolean; + expectsAuthRoutes?: boolean; + expectsDesignSystem?: boolean; +} +export interface ValidateExtractedOptions { + contractPath: string; + extractedPath: string; + surfaceId?: string; + format?: "text" | "json"; + exitCodes?: "v1" | "v2"; +} +export interface Finding { + surfaceId: string; + code: string; + category: "E0" | "E2"; + message: string; + expected?: unknown; + found?: unknown; +} +export interface ValidateExtractedResult { + ok: boolean; + findings: Finding[]; +} +export declare function runValidateExtractedCommand(options: ValidateExtractedOptions): Promise; +//# sourceMappingURL=validate-extracted.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts.map b/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts.map new file mode 100644 index 0000000..b2c021c --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/validate-extracted.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"validate-extracted.d.ts","sourceRoot":"","sources":["../../src/commands/validate-extracted.ts"],"names":[],"mappings":"AAMA,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,sBAAsB,EAAE,MAAM,EAAE,CAAC;IACjC,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,mEAAmE;AACnE,MAAM,WAAW,kBAAkB;IACjC,WAAW,CAAC,EAAE,QAAQ,GAAG,YAAY,GAAG,YAAY,CAAC;IACrD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,OAAO;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB;AA4JD,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,MAAM,CAAC,CAwIjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate-extracted.js b/packages/interfacectl-cli/dist/commands/validate-extracted.js new file mode 100644 index 0000000..f55cfb6 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/validate-extracted.js @@ -0,0 +1,250 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import { getExitCodeVersion } from "../utils/exit-codes.js"; +const AUTH_ROUTES = ["/auth/login", "/auth/callback", "/auth/session", "/auth/logout"]; +function normalizeExtracted(raw) { + if (!raw || typeof raw !== "object") + return null; + const o = raw; + return { + routes: Array.isArray(o.routes) ? o.routes.slice().sort() : [], + hasShell: Boolean(o.hasShell), + designSystemComponents: Array.isArray(o.designSystemComponents) + ? o.designSystemComponents.slice().sort() + : [], + authAware: Boolean(o.authAware), + }; +} +/** + * Load extracted reality from extraction report or generated contract. + * Requires surfaceId from report, or from contract surfaces[0].id, or from --surface. + */ +async function loadExtracted(extractedPath, cwd, explicitSurfaceId) { + const resolved = path.resolve(cwd, extractedPath); + const raw = await readFile(resolved, "utf-8"); + const data = JSON.parse(raw); + if (typeof data.surfaceId === "string" && data.extracted && typeof data.extracted === "object") { + const extracted = normalizeExtracted(data.extracted); + if (extracted) + return { surfaceId: data.surfaceId, extracted }; + } + const x = data.x_extracted; + const surfaces = data.surfaces; + if (x && typeof x === "object") { + let surfaceId; + if (Array.isArray(surfaces) && surfaces.length > 0 && typeof surfaces[0].id === "string") { + surfaceId = surfaces[0].id; + } + else if (explicitSurfaceId) { + surfaceId = explicitSurfaceId; + } + if (surfaceId) { + const extracted = normalizeExtracted(x); + if (extracted) + return { surfaceId, extracted }; + } + } + return null; +} +function getPhase0ForSurface(contract, surfaceId) { + const surfaces = contract.surfaces; + if (!Array.isArray(surfaces)) + return null; + const surface = surfaces.find((s) => s.id === surfaceId); + const phase0 = surface?.phase0; + if (!phase0 || typeof phase0 !== "object") + return null; + const p = phase0; + return { + authPosture: p.authPosture, + requiresShell: p.requiresShell, + expectsAuthRoutes: p.expectsAuthRoutes, + expectsDesignSystem: p.expectsDesignSystem, + }; +} +function compare(surfaceId, phase0, extracted) { + const findings = []; + const routesSet = new Set(extracted.routes); + const hasAllAuthRoutes = AUTH_ROUTES.every((r) => routesSet.has(r)); + if (phase0.authPosture === "auth-first") { + if (!extracted.authAware) { + findings.push({ + surfaceId, + code: "phase0.authPosture.mismatch", + category: "E2", + message: "Contract expects auth-first but extracted authAware is false.", + expected: "authAware true", + found: extracted.authAware, + }); + } + else if (!hasAllAuthRoutes) { + findings.push({ + surfaceId, + code: "phase0.authRoutes.missing", + category: "E2", + message: "Contract expects auth-first but extraction is missing one or more auth routes.", + expected: AUTH_ROUTES, + found: extracted.routes.filter((r) => AUTH_ROUTES.includes(r)), + }); + } + } + else if (phase0.authPosture === "auth-aware") { + if (!extracted.authAware) { + findings.push({ + surfaceId, + code: "phase0.authPosture.mismatch", + category: "E2", + message: "Contract expects auth-aware but extracted authAware is false.", + expected: "authAware true", + found: extracted.authAware, + }); + } + } + if (phase0.requiresShell === true && !extracted.hasShell) { + findings.push({ + surfaceId, + code: "phase0.shell.mismatch", + category: "E2", + message: "Contract requires shell but extraction has hasShell false.", + expected: true, + found: extracted.hasShell, + }); + } + if (phase0.expectsAuthRoutes === true && !hasAllAuthRoutes) { + const missing = AUTH_ROUTES.filter((r) => !routesSet.has(r)); + findings.push({ + surfaceId, + code: "phase0.authRoutes.missing", + category: "E2", + message: `Contract expects auth routes; missing: ${missing.join(", ")}.`, + expected: AUTH_ROUTES, + found: extracted.routes.filter((r) => AUTH_ROUTES.includes(r)), + }); + } + if (phase0.expectsDesignSystem === true && extracted.designSystemComponents.length === 0) { + findings.push({ + surfaceId, + code: "phase0.designSystem.missing", + category: "E2", + message: "Contract expects design-system usage but extraction found none.", + expected: "at least one component", + found: extracted.designSystemComponents, + }); + } + return findings; +} +/** Sort findings by surfaceId then code (deterministic). */ +function sortFindings(findings) { + return [...findings].sort((a, b) => { + const s = a.surfaceId.localeCompare(b.surfaceId); + return s !== 0 ? s : a.code.localeCompare(b.code); + }); +} +export async function runValidateExtractedCommand(options) { + const cwd = process.cwd(); + const exitCodeVersion = getExitCodeVersion({ exitCodes: options.exitCodes }); + const format = (options.format ?? "text").toLowerCase() === "json" ? "json" : "text"; + let contract; + try { + const contractResolved = path.resolve(cwd, options.contractPath); + const contractRaw = await readFile(contractResolved, "utf-8"); + contract = JSON.parse(contractRaw); + } + catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (format === "json") { + console.log(JSON.stringify({ + ok: false, + findings: [ + { + surfaceId: "", + code: "phase0.load.contract", + category: "E0", + message: `Failed to load contract: ${message}`, + }, + ], + }, null, 2)); + } + else { + console.error(`Failed to load contract: ${message}`); + } + return exitCodeVersion === "v2" ? 10 : 2; + } + let extractedData; + try { + extractedData = await loadExtracted(options.extractedPath, cwd, options.surfaceId); + } + catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (format === "json") { + console.log(JSON.stringify({ + ok: false, + findings: [ + { + surfaceId: options.surfaceId ?? "", + code: "phase0.load.extracted", + category: "E0", + message: `Failed to load extracted file: ${message}`, + }, + ], + }, null, 2)); + } + else { + console.error("Extracted file must be an extraction report (surfaceId + extracted) or a generated contract with x_extracted. Use --surface when surfaceId cannot be inferred."); + console.error(message); + } + return exitCodeVersion === "v2" ? 10 : 2; + } + if (!extractedData) { + if (format === "json") { + console.log(JSON.stringify({ + ok: false, + findings: [ + { + surfaceId: options.surfaceId ?? "", + code: "phase0.load.extracted", + category: "E0", + message: "Could not parse extracted file or infer surfaceId; provide --surface if using generated contract without surfaces[0].id.", + }, + ], + }, null, 2)); + } + else { + console.error("Could not parse extracted file or infer surfaceId; provide --surface if using generated contract without surfaces[0].id."); + } + return exitCodeVersion === "v2" ? 10 : 2; + } + const phase0 = getPhase0ForSurface(contract, extractedData.surfaceId); + if (!phase0) { + if (format === "json") { + console.log(JSON.stringify({ ok: true, findings: [], message: "No phase0 block for surface; nothing to compare." }, null, 2)); + } + else { + console.log(`No phase0 block for surface ${extractedData.surfaceId}; nothing to compare.`); + } + return 0; + } + const findings = compare(extractedData.surfaceId, phase0, extractedData.extracted); + const sorted = sortFindings(findings); + const ok = sorted.length === 0; + const exitCode = ok ? 0 : exitCodeVersion === "v2" ? 30 : 1; + if (format === "json") { + console.log(JSON.stringify({ ok, findings: sorted }, null, 2)); + return exitCode; + } + if (sorted.length === 0) { + console.log(`No incompatibilities for surface ${extractedData.surfaceId}.`); + return 0; + } + for (const f of sorted) { + console.error(`[${f.surfaceId}] ${f.code} (${f.category}): ${f.message}`); + if (f.expected !== undefined) + console.error(` expected: ${JSON.stringify(f.expected)}`); + if (f.found !== undefined) + console.error(` found: ${JSON.stringify(f.found)}`); + } + if (exitCodeVersion === "v1" && exitCode !== 0) { + process.stderr.write("Deprecation: default exit codes will change. Use --exit-codes v2 to opt in.\n"); + } + return exitCode; +} diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index 2d74179..b87024e 100644 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -5,6 +5,7 @@ import { runDiffCommand } from "./commands/diff.js"; import { runEnforceCommand } from "./commands/enforce.js"; import { runCompileCommand } from "./commands/compile.js"; import { runGenerateContractCommand } from "./commands/generate-contract.js"; +import { runValidateExtractedCommand } from "./commands/validate-extracted.js"; import pkg from "../package.json" with { type: "json" }; const program = new Command(); program @@ -200,6 +201,25 @@ program }); process.exitCode = exitCode; }); +program + .command("validate-extracted") + .description("Fail if contract phase0 expectations conflict with extracted reality (Phase 0.5)") + .requiredOption("--contract ", "Path to the declared policy contract JSON") + .requiredOption("--extracted ", "Path to extraction report or generated contract with x_extracted") + .option("--surface ", "Surface id when not inferrable from extracted file") + .option("--format ", "Output format (text|json)", "text") + .option("--exit-codes ", "Exit code version (default: v1; v2: 0 success, 10 E0, 30 E2)") + .action(async (options) => { + const exitCodeVersion = options.exitCodes === "v1" || options.exitCodes === "v2" ? options.exitCodes : undefined; + const exitCode = await runValidateExtractedCommand({ + contractPath: options.contract, + extractedPath: options.extracted, + surfaceId: options.surface, + format: (options.format ?? "text").toLowerCase() === "json" ? "json" : "text", + exitCodes: exitCodeVersion, + }); + process.exitCode = exitCode; +}); program.parseAsync(process.argv).catch((error) => { console.error(error); process.exitCode = 1; diff --git a/packages/interfacectl-cli/src/commands/validate-extracted.ts b/packages/interfacectl-cli/src/commands/validate-extracted.ts new file mode 100644 index 0000000..7057b09 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/validate-extracted.ts @@ -0,0 +1,337 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import { getExitCodeVersion } from "../utils/exit-codes.js"; + +const AUTH_ROUTES = ["/auth/login", "/auth/callback", "/auth/session", "/auth/logout"]; + +/** Phase 0 extracted fields (extractor shape). */ +export interface ExtractedFields { + routes: string[]; + hasShell: boolean; + designSystemComponents: string[]; + authAware: boolean; +} + +/** Optional per-surface Phase 0 expectations (policy contract). */ +export interface Phase0Expectations { + authPosture?: "public" | "auth-aware" | "auth-first"; + requiresShell?: boolean; + expectsAuthRoutes?: boolean; + expectsDesignSystem?: boolean; +} + +export interface ValidateExtractedOptions { + contractPath: string; + extractedPath: string; + surfaceId?: string; + format?: "text" | "json"; + exitCodes?: "v1" | "v2"; +} + +export interface Finding { + surfaceId: string; + code: string; + category: "E0" | "E2"; + message: string; + expected?: unknown; + found?: unknown; +} + +export interface ValidateExtractedResult { + ok: boolean; + findings: Finding[]; +} + +function normalizeExtracted(raw: unknown): ExtractedFields | null { + if (!raw || typeof raw !== "object") return null; + const o = raw as Record; + return { + routes: Array.isArray(o.routes) ? (o.routes as string[]).slice().sort() : [], + hasShell: Boolean(o.hasShell), + designSystemComponents: Array.isArray(o.designSystemComponents) + ? (o.designSystemComponents as string[]).slice().sort() + : [], + authAware: Boolean(o.authAware), + }; +} + +/** + * Load extracted reality from extraction report or generated contract. + * Requires surfaceId from report, or from contract surfaces[0].id, or from --surface. + */ +async function loadExtracted( + extractedPath: string, + cwd: string, + explicitSurfaceId?: string, +): Promise<{ surfaceId: string; extracted: ExtractedFields } | null> { + const resolved = path.resolve(cwd, extractedPath); + const raw = await readFile(resolved, "utf-8"); + const data = JSON.parse(raw) as Record; + + if (typeof data.surfaceId === "string" && data.extracted && typeof data.extracted === "object") { + const extracted = normalizeExtracted(data.extracted); + if (extracted) return { surfaceId: data.surfaceId, extracted }; + } + + const x = data.x_extracted; + const surfaces = data.surfaces as Array<{ id?: string }> | undefined; + if (x && typeof x === "object") { + let surfaceId: string | undefined; + if (Array.isArray(surfaces) && surfaces.length > 0 && typeof surfaces[0].id === "string") { + surfaceId = surfaces[0].id; + } else if (explicitSurfaceId) { + surfaceId = explicitSurfaceId; + } + if (surfaceId) { + const extracted = normalizeExtracted(x); + if (extracted) return { surfaceId, extracted }; + } + } + + return null; +} + +function getPhase0ForSurface( + contract: Record, + surfaceId: string, +): Phase0Expectations | null { + const surfaces = contract.surfaces as Array> | undefined; + if (!Array.isArray(surfaces)) return null; + const surface = surfaces.find((s) => s.id === surfaceId); + const phase0 = surface?.phase0; + if (!phase0 || typeof phase0 !== "object") return null; + const p = phase0 as Record; + return { + authPosture: p.authPosture as Phase0Expectations["authPosture"], + requiresShell: p.requiresShell as boolean | undefined, + expectsAuthRoutes: p.expectsAuthRoutes as boolean | undefined, + expectsDesignSystem: p.expectsDesignSystem as boolean | undefined, + }; +} + +function compare( + surfaceId: string, + phase0: Phase0Expectations, + extracted: ExtractedFields, +): Finding[] { + const findings: Finding[] = []; + const routesSet = new Set(extracted.routes); + const hasAllAuthRoutes = AUTH_ROUTES.every((r) => routesSet.has(r)); + + if (phase0.authPosture === "auth-first") { + if (!extracted.authAware) { + findings.push({ + surfaceId, + code: "phase0.authPosture.mismatch", + category: "E2", + message: "Contract expects auth-first but extracted authAware is false.", + expected: "authAware true", + found: extracted.authAware, + }); + } else if (!hasAllAuthRoutes) { + findings.push({ + surfaceId, + code: "phase0.authRoutes.missing", + category: "E2", + message: "Contract expects auth-first but extraction is missing one or more auth routes.", + expected: AUTH_ROUTES, + found: extracted.routes.filter((r) => AUTH_ROUTES.includes(r)), + }); + } + } else if (phase0.authPosture === "auth-aware") { + if (!extracted.authAware) { + findings.push({ + surfaceId, + code: "phase0.authPosture.mismatch", + category: "E2", + message: "Contract expects auth-aware but extracted authAware is false.", + expected: "authAware true", + found: extracted.authAware, + }); + } + } + + if (phase0.requiresShell === true && !extracted.hasShell) { + findings.push({ + surfaceId, + code: "phase0.shell.mismatch", + category: "E2", + message: "Contract requires shell but extraction has hasShell false.", + expected: true, + found: extracted.hasShell, + }); + } + + if (phase0.expectsAuthRoutes === true && !hasAllAuthRoutes) { + const missing = AUTH_ROUTES.filter((r) => !routesSet.has(r)); + findings.push({ + surfaceId, + code: "phase0.authRoutes.missing", + category: "E2", + message: `Contract expects auth routes; missing: ${missing.join(", ")}.`, + expected: AUTH_ROUTES, + found: extracted.routes.filter((r) => AUTH_ROUTES.includes(r)), + }); + } + + if (phase0.expectsDesignSystem === true && extracted.designSystemComponents.length === 0) { + findings.push({ + surfaceId, + code: "phase0.designSystem.missing", + category: "E2", + message: "Contract expects design-system usage but extraction found none.", + expected: "at least one component", + found: extracted.designSystemComponents, + }); + } + + return findings; +} + +/** Sort findings by surfaceId then code (deterministic). */ +function sortFindings(findings: Finding[]): Finding[] { + return [...findings].sort((a, b) => { + const s = a.surfaceId.localeCompare(b.surfaceId); + return s !== 0 ? s : a.code.localeCompare(b.code); + }); +} + +export async function runValidateExtractedCommand( + options: ValidateExtractedOptions, +): Promise { + const cwd = process.cwd(); + const exitCodeVersion = getExitCodeVersion({ exitCodes: options.exitCodes }); + const format = (options.format ?? "text").toLowerCase() === "json" ? "json" : "text"; + + let contract: Record; + try { + const contractResolved = path.resolve(cwd, options.contractPath); + const contractRaw = await readFile(contractResolved, "utf-8"); + contract = JSON.parse(contractRaw) as Record; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (format === "json") { + console.log( + JSON.stringify( + { + ok: false, + findings: [ + { + surfaceId: "", + code: "phase0.load.contract", + category: "E0", + message: `Failed to load contract: ${message}`, + }, + ], + }, + null, + 2, + ), + ); + } else { + console.error(`Failed to load contract: ${message}`); + } + return exitCodeVersion === "v2" ? 10 : 2; + } + + let extractedData: { surfaceId: string; extracted: ExtractedFields } | null; + try { + extractedData = await loadExtracted( + options.extractedPath, + cwd, + options.surfaceId, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (format === "json") { + console.log( + JSON.stringify( + { + ok: false, + findings: [ + { + surfaceId: options.surfaceId ?? "", + code: "phase0.load.extracted", + category: "E0", + message: `Failed to load extracted file: ${message}`, + }, + ], + }, + null, + 2, + ), + ); + } else { + console.error( + "Extracted file must be an extraction report (surfaceId + extracted) or a generated contract with x_extracted. Use --surface when surfaceId cannot be inferred.", + ); + console.error(message); + } + return exitCodeVersion === "v2" ? 10 : 2; + } + + if (!extractedData) { + if (format === "json") { + console.log( + JSON.stringify( + { + ok: false, + findings: [ + { + surfaceId: options.surfaceId ?? "", + code: "phase0.load.extracted", + category: "E0", + message: + "Could not parse extracted file or infer surfaceId; provide --surface if using generated contract without surfaces[0].id.", + }, + ], + }, + null, + 2, + ), + ); + } else { + console.error( + "Could not parse extracted file or infer surfaceId; provide --surface if using generated contract without surfaces[0].id.", + ); + } + return exitCodeVersion === "v2" ? 10 : 2; + } + + const phase0 = getPhase0ForSurface(contract, extractedData.surfaceId); + if (!phase0) { + if (format === "json") { + console.log( + JSON.stringify({ ok: true, findings: [], message: "No phase0 block for surface; nothing to compare." }, null, 2), + ); + } else { + console.log(`No phase0 block for surface ${extractedData.surfaceId}; nothing to compare.`); + } + return 0; + } + + const findings = compare(extractedData.surfaceId, phase0, extractedData.extracted); + const sorted = sortFindings(findings); + const ok = sorted.length === 0; + const exitCode = ok ? 0 : exitCodeVersion === "v2" ? 30 : 1; + + if (format === "json") { + console.log(JSON.stringify({ ok, findings: sorted }, null, 2)); + return exitCode; + } + + if (sorted.length === 0) { + console.log(`No incompatibilities for surface ${extractedData.surfaceId}.`); + return 0; + } + + for (const f of sorted) { + console.error(`[${f.surfaceId}] ${f.code} (${f.category}): ${f.message}`); + if (f.expected !== undefined) console.error(` expected: ${JSON.stringify(f.expected)}`); + if (f.found !== undefined) console.error(` found: ${JSON.stringify(f.found)}`); + } + if (exitCodeVersion === "v1" && exitCode !== 0) { + process.stderr.write("Deprecation: default exit codes will change. Use --exit-codes v2 to opt in.\n"); + } + return exitCode; +} diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index 890f13c..b6d8c1e 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -6,6 +6,7 @@ import { runDiffCommand } from "./commands/diff.js"; import { runEnforceCommand } from "./commands/enforce.js"; import { runCompileCommand } from "./commands/compile.js"; import { runGenerateContractCommand } from "./commands/generate-contract.js"; +import { runValidateExtractedCommand } from "./commands/validate-extracted.js"; import pkg from "../package.json" with { type: "json" }; const program = new Command(); @@ -298,6 +299,27 @@ program process.exitCode = exitCode; }); +program + .command("validate-extracted") + .description("Fail if contract phase0 expectations conflict with extracted reality (Phase 0.5)") + .requiredOption("--contract ", "Path to the declared policy contract JSON") + .requiredOption("--extracted ", "Path to extraction report or generated contract with x_extracted") + .option("--surface ", "Surface id when not inferrable from extracted file") + .option("--format ", "Output format (text|json)", "text") + .option("--exit-codes ", "Exit code version (default: v1; v2: 0 success, 10 E0, 30 E2)") + .action(async (options) => { + const exitCodeVersion = + options.exitCodes === "v1" || options.exitCodes === "v2" ? options.exitCodes : undefined; + const exitCode = await runValidateExtractedCommand({ + contractPath: options.contract, + extractedPath: options.extracted, + surfaceId: options.surface, + format: (options.format ?? "text").toLowerCase() === "json" ? "json" : "text", + exitCodes: exitCodeVersion, + }); + process.exitCode = exitCode; + }); + program.parseAsync(process.argv).catch((error) => { console.error(error); process.exitCode = 1; diff --git a/packages/interfacectl-cli/test/fixtures/validate-extracted/contract-no-phase0.json b/packages/interfacectl-cli/test/fixtures/validate-extracted/contract-no-phase0.json new file mode 100644 index 0000000..8cf9852 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/validate-extracted/contract-no-phase0.json @@ -0,0 +1,16 @@ +{ + "contractId": "test", + "version": "0.1.0", + "surfaces": [ + { + "id": "surfaces-web", + "displayName": "Test", + "type": "web", + "requiredSections": ["landing.hero"], + "allowedFonts": ["sans-serif"], + "layout": { "maxContentWidth": 1120 } + } + ], + "sections": [{ "id": "landing.hero", "intent": "primary", "description": "Hero" }], + "constraints": { "motion": { "allowedDurationsMs": [120], "allowedTimingFunctions": ["linear"] } } +} diff --git a/packages/interfacectl-cli/test/fixtures/validate-extracted/contract-phase0.json b/packages/interfacectl-cli/test/fixtures/validate-extracted/contract-phase0.json new file mode 100644 index 0000000..a6873e3 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/validate-extracted/contract-phase0.json @@ -0,0 +1,22 @@ +{ + "contractId": "test", + "version": "0.1.0", + "surfaces": [ + { + "id": "surfaces-web", + "displayName": "Test", + "type": "web", + "requiredSections": ["landing.hero"], + "allowedFonts": ["sans-serif"], + "layout": { "maxContentWidth": 1120 }, + "phase0": { + "authPosture": "auth-aware", + "requiresShell": true, + "expectsAuthRoutes": true, + "expectsDesignSystem": true + } + } + ], + "sections": [{ "id": "landing.hero", "intent": "primary", "description": "Hero" }], + "constraints": { "motion": { "allowedDurationsMs": [120], "allowedTimingFunctions": ["linear"] } } +} diff --git a/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-auth-mismatch.json b/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-auth-mismatch.json new file mode 100644 index 0000000..c12ff90 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-auth-mismatch.json @@ -0,0 +1,9 @@ +{ + "surfaceId": "surfaces-web", + "extracted": { + "authAware": false, + "hasShell": true, + "routes": ["/"], + "designSystemComponents": ["Navigation"] + } +} diff --git a/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-ds-missing.json b/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-ds-missing.json new file mode 100644 index 0000000..f0741cd --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-ds-missing.json @@ -0,0 +1,9 @@ +{ + "surfaceId": "surfaces-web", + "extracted": { + "authAware": true, + "hasShell": true, + "routes": ["/", "/auth/callback", "/auth/login", "/auth/logout", "/auth/session"], + "designSystemComponents": [] + } +} diff --git a/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-ok.json b/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-ok.json new file mode 100644 index 0000000..9d8597b --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-ok.json @@ -0,0 +1,9 @@ +{ + "surfaceId": "surfaces-web", + "extracted": { + "authAware": true, + "hasShell": true, + "routes": ["/", "/auth/callback", "/auth/login", "/auth/logout", "/auth/session"], + "designSystemComponents": ["Navigation"] + } +} diff --git a/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-shell-mismatch.json b/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-shell-mismatch.json new file mode 100644 index 0000000..6a4cce8 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/validate-extracted/extracted-shell-mismatch.json @@ -0,0 +1,9 @@ +{ + "surfaceId": "surfaces-web", + "extracted": { + "authAware": true, + "hasShell": false, + "routes": ["/", "/auth/callback", "/auth/login", "/auth/logout", "/auth/session"], + "designSystemComponents": ["Navigation"] + } +} diff --git a/packages/interfacectl-cli/test/validate-extracted.test.mjs b/packages/interfacectl-cli/test/validate-extracted.test.mjs new file mode 100644 index 0000000..3db14ec --- /dev/null +++ b/packages/interfacectl-cli/test/validate-extracted.test.mjs @@ -0,0 +1,109 @@ +import { test } from "node:test"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const cliPath = path.resolve(__dirname, "..", "dist", "index.js"); +const fixtureRoot = path.resolve(__dirname, "fixtures", "validate-extracted"); + +async function run(args, env = {}) { + const child = spawn("node", [cliPath, ...args], { + env: { ...process.env, ...env }, + cwd: fixtureRoot, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d.toString(); }); + child.stderr.on("data", (d) => { stderr += d.toString(); }); + const [code] = await once(child, "exit"); + return { exitCode: Number(code), stdout, stderr }; +} + +test("validate-extracted: no phase0 block => ok (exit 0)", async () => { + const r = await run([ + "validate-extracted", + "--contract", "contract-no-phase0.json", + "--extracted", "extracted-ok.json", + "--exit-codes", "v2", + ]); + assert.equal(r.exitCode, 0); + assert.ok(r.stdout.includes("nothing to compare") || r.stdout.includes("No incompatibilities")); +}); + +test("validate-extracted: phase0 matches extracted => ok (exit 0)", async () => { + const r = await run([ + "validate-extracted", + "--contract", "contract-phase0.json", + "--extracted", "extracted-ok.json", + "--exit-codes", "v2", + ]); + assert.equal(r.exitCode, 0, r.stderr + r.stdout); +}); + +test("validate-extracted: shell mismatch => E2 exit 30 (v2)", async () => { + const r = await run([ + "validate-extracted", + "--contract", "contract-phase0.json", + "--extracted", "extracted-shell-mismatch.json", + "--exit-codes", "v2", + ]); + assert.equal(r.exitCode, 30); + assert.ok(r.stderr.includes("phase0.shell.mismatch") || r.stdout.includes("phase0.shell.mismatch")); +}); + +test("validate-extracted: auth posture mismatch => E2 exit 30 (v2)", async () => { + const r = await run([ + "validate-extracted", + "--contract", "contract-phase0.json", + "--extracted", "extracted-auth-mismatch.json", + "--exit-codes", "v2", + ]); + assert.equal(r.exitCode, 30); + assert.ok( + r.stderr.includes("phase0.authPosture.mismatch") || r.stdout.includes("phase0.authPosture.mismatch"), + r.stderr + r.stdout, + ); +}); + +test("validate-extracted: design system missing => E2 exit 30 (v2)", async () => { + const r = await run([ + "validate-extracted", + "--contract", "contract-phase0.json", + "--extracted", "extracted-ds-missing.json", + "--exit-codes", "v2", + ]); + assert.equal(r.exitCode, 30); + assert.ok( + r.stderr.includes("phase0.designSystem.missing") || r.stdout.includes("phase0.designSystem.missing"), + r.stderr + r.stdout, + ); +}); + +test("validate-extracted: invalid extracted file => E0 exit 10 (v2)", async () => { + const r = await run([ + "validate-extracted", + "--contract", "contract-no-phase0.json", + "--extracted", "nonexistent.json", + "--exit-codes", "v2", + ]); + assert.equal(r.exitCode, 10); +}); + +test("validate-extracted: JSON output has deterministic findings order", async () => { + const r = await run([ + "validate-extracted", + "--contract", "contract-phase0.json", + "--extracted", "extracted-shell-mismatch.json", + "--format", "json", + "--exit-codes", "v2", + ]); + assert.equal(r.exitCode, 30); + const out = JSON.parse(r.stdout); + assert.equal(out.ok, false); + assert.ok(Array.isArray(out.findings)); + assert.ok(out.findings.length >= 1); + assert.ok(out.findings.every((f) => f.surfaceId && f.code && (f.category === "E0" || f.category === "E2"))); +}); diff --git a/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json b/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json index 0242f14..78b4117 100644 --- a/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json +++ b/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json @@ -138,6 +138,20 @@ "$ref": "#/$defs/pageFrame" } } + }, + "phase0": { + "type": "object", + "description": "Phase 0.5: optional expectations for extracted reality (routes, auth, shell, design system).", + "additionalProperties": false, + "properties": { + "authPosture": { + "type": "string", + "enum": ["public", "auth-aware", "auth-first"] + }, + "requiresShell": { "type": "boolean" }, + "expectsAuthRoutes": { "type": "boolean" }, + "expectsDesignSystem": { "type": "boolean" } + } } } }, diff --git a/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json b/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json index 0242f14..78b4117 100644 --- a/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json +++ b/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json @@ -138,6 +138,20 @@ "$ref": "#/$defs/pageFrame" } } + }, + "phase0": { + "type": "object", + "description": "Phase 0.5: optional expectations for extracted reality (routes, auth, shell, design system).", + "additionalProperties": false, + "properties": { + "authPosture": { + "type": "string", + "enum": ["public", "auth-aware", "auth-first"] + }, + "requiresShell": { "type": "boolean" }, + "expectsAuthRoutes": { "type": "boolean" }, + "expectsDesignSystem": { "type": "boolean" } + } } } },