Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,36 @@ interfacectl generate-contract --app-root <path> --surface <id> [--out <path>] [

---

### `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 <path> --extracted <path> [--surface <id>] [--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 <id>` 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.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ interfacectl generate-contract --app-root <path> --surface <surfaceId> [--out <p

Running the command twice produces identical contract and report (stable key order, no timestamps). See [API.md](API.md) for full options.

### `validate-extracted` (Phase 0.5)

Validates that the **declared policy** (contract `surfaces[].phase0`) matches **extracted reality** (from the extraction report or generated contract). Use after `generate-contract` in CI to fail when auth posture, shell, auth routes, or design-system expectations disagree with what was extracted.

```bash
interfacectl validate-extracted --contract <path> --extracted <path> [--surface <id>] [--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.
Expand Down
35 changes: 35 additions & 0 deletions packages/interfacectl-cli/dist/commands/validate-extracted.d.ts
Original file line number Diff line number Diff line change
@@ -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<number>;
//# sourceMappingURL=validate-extracted.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

250 changes: 250 additions & 0 deletions packages/interfacectl-cli/dist/commands/validate-extracted.js
Original file line number Diff line number Diff line change
@@ -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 <id> 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;
}
20 changes: 20 additions & 0 deletions packages/interfacectl-cli/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>", "Path to the declared policy contract JSON")
.requiredOption("--extracted <path>", "Path to extraction report or generated contract with x_extracted")
.option("--surface <id>", "Surface id when not inferrable from extracted file")
.option("--format <format>", "Output format (text|json)", "text")
.option("--exit-codes <v1|v2>", "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;
Expand Down
Loading