diff --git a/.github/PULL_REQUEST_PHASE3_COMPILE.md b/.github/PULL_REQUEST_PHASE3_COMPILE.md new file mode 100644 index 0000000..6373e62 --- /dev/null +++ b/.github/PULL_REQUEST_PHASE3_COMPILE.md @@ -0,0 +1,73 @@ +# Phase 3: interfacectl compile directory bundle + +## Strategy check + +- [x] I read "docs/strategy.md" +- [x] This PR strengthens the decision filter sentence (runtime bundle for consumption) +- [x] Enforcement timing is explicit: compile produces build artifact for runtime; validate remains generation-time gate +- [x] Violation handling is unchanged (no changes to validate compliance semantics) +- [x] CLI behavior is tied to contract semantics (bundle is compiled view of contract) + +## What changed + +- Added `interfacectl compile --contract --out [--schema ] [--format json]`. +- Compile loads and validates the contract with the existing validator, then writes a deterministic directory bundle: + - `manifest.json` (bundleVersion, contractId, contractVersion, schemaVersion, tool, inputs, files with sha256 sorted by path) + - `contract.normalized.json` (stable key ordering, same information as source) + - `surfaces/.json` per surface + - `constraints/motion.json` when motion exists +- Deterministic JSON (sorted keys, no timestamps). Atomic writes (temp then rename). +- Fixture-based tests: structure, determinism (two runs → identical manifest.files), golden output, invalid contract fails. +- API.md: compile command description, bundle structure overview, manifest field definitions. + +## Why it matters + +- Teams can produce a versioned, diffable bundle for runtime or edge consumers without implementing enforcement in the CLI. +- Bundle is readable, composable, and cacheable (manifest files list with sha256). +- Validate remains the canonical compliance gate; compile is a build artifact only. + +## Contract and enforcement notes + +1. No contract schema changes. +2. No changes to validate compliance semantics or exit codes. +3. No new commands beyond compile; no `gate` command. + +## Tests + +- **compile.test.mjs** + - Structure: required files exist (manifest.json, contract.normalized.json, surfaces/, constraints/), manifest.files sorted by path, sha256 hex format, no manifest in files list. + - Determinism: two compile runs → identical manifest.files (paths and hashes). + - Golden: generated contract.normalized.json, surfaces/demo-surface.json, constraints/motion.json deep-equal expected fixtures. + - Invalid contract (schema validation failure) → non-zero exit. + - Missing required field (constraints) → non-zero exit. + +## Phase 3 acceptance criteria (docs/plans/phase-3-compile-directory-bundle.md) + +1. **AC1** ✅ `interfacectl compile --contract --out ` creates manifest.json, contract.normalized.json, surfaces/.json for each surface, constraints/motion.json when motion exists. +2. **AC2** ✅ manifest `files` list includes sha256 for every file except manifest itself, sorted by path. +3. **AC3** ✅ Bundles are deterministic; two runs produce identical sha256 sets for the same fixture. +4. **AC4** ✅ Compile fails with non-zero exit code on invalid contract. +5. **AC5** ✅ API.md includes compile command description, bundle structure overview, manifest field definitions. +6. **AC6** ✅ No changes to validate compliance semantics and no contract schema changes. + +## Determinism and schema stability verification + +### schemaVersion + +- **How it is derived:** `schemaVersion` is a **fixed constant** in code: `"surfaces.web.contract@1"`. It is not read from the schema file, filesystem, or environment. +- **Deterministic:** Same value on every run and every machine. It does **not** depend on absolute paths, file mtimes, or environment-specific values. +- **Decision:** Use a stable constant. When a custom schema is supplied via `--schema`, `inputs.schemaPath` records it; `schemaVersion` continues to denote the bundled contract schema format. Fixture and expected manifest use this constant. + +### JSON stringify behavior + +- **Object keys:** Sorted recursively (lexicographically) via `sortKeysRecursive` before `JSON.stringify(..., null, 2)`. Ensures stable key order across runs. +- **Arrays:** Array **element order is preserved**. The stringify helper does not reorder arrays; it only recurses into elements. Set-like array sorting (e.g. `allowedFonts`, `requiredSections`, `allowedDurationsMs`) is done in `normalizeContract()` by documented semantic rule (those fields are set-like in the contract schema), not in the stringify helper. +- **Newlines/indentation:** Always `JSON.stringify(..., null, 2)` plus a single trailing newline. Stable and consistent. +- **sha256:** Computed from the exact string passed to `writeAtomic` (the same content written to disk). No separate read-back; hash matches file bytes. + +## Review checklist (from plan) + +- [x] Compile only validates structure and writes the bundle. +- [x] Outputs are deterministic and tested. +- [x] Manifest is complete and stable (no timestamps). +- [x] Docs are short, factual, and consistent with validate as canonical gate. diff --git a/API.md b/API.md index 9c9dae7..04bd2ee 100644 --- a/API.md +++ b/API.md @@ -355,6 +355,61 @@ Includes traceability fields. --- +### `compile` + +Produces a deterministic directory bundle from a contract for runtime consumption. The bundle is readable, composable, and diffable. This is a build artifact, not an enforcement engine. + +**Synopsis:** +```bash +interfacectl compile --contract --out [--schema ] [--format json] +``` + +**Description:** +- Loads and validates the contract structure using the same validator as `validate`. +- Writes a bundle directory at `--out` with stable key ordering and deterministic JSON. +- Writes files atomically (temp then rename) where possible to avoid partial bundles. +- Exits non-zero on invalid contract or write failure. + +**Options:** + +| Option | Description | +|--------|-------------| +| `--contract ` | Path to the contract JSON file (required) | +| `--out ` | Output directory for the bundle (required) | +| `--schema ` | Optional path to the contract schema JSON file | +| `--format ` | Output format (e.g. json) | + +**Exit Codes:** +- `0`: Bundle written successfully +- `1`: Invalid contract (schema validation failed), missing file, or write failure + +**Bundle structure** + +The output directory contains: + +| Path | Description | +|------|-------------| +| `manifest.json` | Bundle manifest with version, contract id/version, tool info, inputs, and file hashes | +| `contract.normalized.json` | Normalized full contract (stable key order, same information as source) | +| `surfaces/.json` | One file per surface; surface data plus minimal metadata for runtime loading | +| `constraints/motion.json` | Motion constraint category (other categories may be added later) | + +**Manifest fields** + +| Field | Description | +|-------|-------------| +| `bundleVersion` | Format version for this bundle (e.g. `"1.0"`) | +| `contractId` | From the contract | +| `contractVersion` | From the contract (`version` field) | +| `schemaVersion` | Schema bundle identifier used by the CLI (e.g. `surfaces.web.contract@1`) | +| `tool` | `{ name: "interfacectl", version: "" }` | +| `inputs` | `{ contractPath: string, schemaPath: string or null }` | +| `files` | Array of `{ path, sha256 }` for all bundle files except `manifest.json`, sorted lexicographically by `path`. Hashes are SHA-256 hex strings. | + +No timestamps are included in the manifest so that bundles remain deterministic. + +--- + ## 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/packages/interfacectl-cli/dist/commands/compile.d.ts b/packages/interfacectl-cli/dist/commands/compile.d.ts new file mode 100644 index 0000000..1a2d197 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/compile.d.ts @@ -0,0 +1,8 @@ +export interface CompileCommandOptions { + contractPath: string; + outDir: string; + schemaPath?: string; + format?: "json"; +} +export declare function runCompileCommand(options: CompileCommandOptions, toolVersion: string): Promise; +//# sourceMappingURL=compile.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/compile.d.ts.map b/packages/interfacectl-cli/dist/commands/compile.d.ts.map new file mode 100644 index 0000000..62237ba --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/compile.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/commands/compile.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAoED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,qBAAqB,EAC9B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CAgHjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/compile.js b/packages/interfacectl-cli/dist/commands/compile.js new file mode 100644 index 0000000..d4e819c --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/compile.js @@ -0,0 +1,159 @@ +import path from "node:path"; +import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { validateContractStructure, getBundledContractSchema, } from "@surfaces/interfacectl-validator"; +import { normalizeContract } from "../utils/normalize.js"; +const BUNDLE_VERSION = "1.0"; +/** Stable constant for manifest.schemaVersion. Deterministic: no paths, mtimes, or env. When a custom schema is supplied, inputs.schemaPath records it. */ +const SCHEMA_VERSION = "surfaces.web.contract@1"; +/** + * Recursively sort object keys for deterministic JSON output. + * Array element order is preserved; only object keys are sorted. + */ +function sortKeysRecursive(value) { + if (value === null || value === undefined) { + return value; + } + if (Array.isArray(value)) { + return value.map(sortKeysRecursive); + } + if (typeof value === "object") { + const sorted = {}; + for (const k of Object.keys(value).sort()) { + sorted[k] = sortKeysRecursive(value[k]); + } + return sorted; + } + return value; +} +/** + * Serialize value to JSON with stable key ordering and readable indent. + */ +function stringifyDeterministic(value) { + return `${JSON.stringify(sortKeysRecursive(value), null, 2)}\n`; +} +function sha256Hex(content) { + return createHash("sha256").update(content, "utf8").digest("hex"); +} +/** + * Write content to file atomically (write to .tmp then rename). + */ +async function writeAtomic(filePath, content) { + const dir = path.dirname(filePath); + await mkdir(dir, { recursive: true }); + const tmpPath = `${filePath}.tmp`; + await writeFile(tmpPath, content, "utf8"); + await rename(tmpPath, filePath); +} +export async function runCompileCommand(options, toolVersion) { + const outDir = path.resolve(options.outDir); + const contractInput = path.resolve(options.contractPath); + const schemaPath = options.schemaPath + ? path.resolve(options.schemaPath) + : undefined; + let contractRaw; + try { + contractRaw = await readFile(contractInput, "utf8"); + } + catch (err) { + const message = err.code === "ENOENT" + ? `Contract file not found: ${contractInput}` + : `Failed to read contract: ${err.message}`; + console.error(message); + return 1; + } + let contractData; + try { + contractData = JSON.parse(contractRaw); + } + catch (err) { + console.error(`Invalid contract JSON: ${err.message}`); + return 1; + } + let schema; + if (schemaPath) { + try { + const raw = await readFile(schemaPath, "utf8"); + schema = JSON.parse(raw); + } + catch (err) { + const message = err.code === "ENOENT" + ? `Schema file not found: ${schemaPath}` + : `Failed to read schema: ${err.message}`; + console.error(message); + return 1; + } + } + else { + schema = getBundledContractSchema(); + } + const structureResult = validateContractStructure(contractData, schema); + if (!structureResult.ok || !structureResult.contract) { + console.error("Contract schema validation failed:"); + for (const error of structureResult.errors) { + console.error(` • ${error}`); + } + return 1; + } + const contract = structureResult.contract; + const { contract: normalizedContract } = normalizeContract(contract); + const contractContent = stringifyDeterministic(normalizedContract); + const bundleFiles = [ + { path: "contract.normalized.json", content: contractContent }, + ]; + for (const surface of normalizedContract.surfaces) { + const surfacePayload = surfaceToBundlePayload(surface); + const content = stringifyDeterministic(surfacePayload); + bundleFiles.push({ + path: `surfaces/${surface.id}.json`, + content, + }); + } + if (normalizedContract.constraints.motion) { + const motionContent = stringifyDeterministic(normalizedContract.constraints.motion); + bundleFiles.push({ + path: "constraints/motion.json", + content: motionContent, + }); + } + const filesSorted = [...bundleFiles].sort((a, b) => a.path.localeCompare(b.path)); + const fileEntries = filesSorted.map(({ path: p, content }) => ({ + path: p, + sha256: sha256Hex(content), + })); + const manifest = { + bundleVersion: BUNDLE_VERSION, + contractId: normalizedContract.contractId, + contractVersion: normalizedContract.version, + schemaVersion: SCHEMA_VERSION, + tool: { name: "interfacectl", version: toolVersion }, + inputs: { + contractPath: options.contractPath, + schemaPath: schemaPath ?? null, + }, + files: fileEntries, + }; + const manifestContent = stringifyDeterministic(manifest); + try { + for (const { path: p, content } of filesSorted) { + await writeAtomic(path.join(outDir, p), content); + } + await writeAtomic(path.join(outDir, "manifest.json"), manifestContent); + } + catch (err) { + console.error(`Failed to write bundle: ${err.message}`); + return 1; + } + return 0; +} +function surfaceToBundlePayload(surface) { + return { + id: surface.id, + displayName: surface.displayName, + type: surface.type, + requiredSections: surface.requiredSections, + allowedFonts: surface.allowedFonts, + ...(surface.allowedColors && { allowedColors: surface.allowedColors }), + layout: surface.layout, + }; +} diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index 59f1905..230e8be 100644 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -3,6 +3,7 @@ import { Command } from "commander"; import { runValidateCommand } from "./commands/validate.js"; import { runDiffCommand } from "./commands/diff.js"; import { runEnforceCommand } from "./commands/enforce.js"; +import { runCompileCommand } from "./commands/compile.js"; import pkg from "../package.json" with { type: "json" }; const program = new Command(); program @@ -164,6 +165,22 @@ program }); process.exitCode = exitCode; }); +program + .command("compile") + .description("Produce a deterministic directory bundle for runtime consumption") + .requiredOption("--contract ", "Path to the contract JSON file") + .requiredOption("--out ", "Output directory for the bundle") + .option("--schema ", "Optional path to the contract schema JSON file") + .option("--format ", "Output format (json)") + .action(async (options) => { + const exitCode = await runCompileCommand({ + contractPath: options.contract, + outDir: options.out, + schemaPath: options.schema, + format: options.format, + }, pkg.version ?? "0.0.0"); + process.exitCode = exitCode; +}); program.parseAsync(process.argv).catch((error) => { console.error(error); process.exitCode = 1; diff --git a/packages/interfacectl-cli/src/commands/compile.ts b/packages/interfacectl-cli/src/commands/compile.ts new file mode 100644 index 0000000..e81d52c --- /dev/null +++ b/packages/interfacectl-cli/src/commands/compile.ts @@ -0,0 +1,216 @@ +import path from "node:path"; +import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import type { InterfaceContract, ContractSurface } from "@surfaces/interfacectl-validator"; +import { + validateContractStructure, + getBundledContractSchema, +} from "@surfaces/interfacectl-validator"; +import { normalizeContract } from "../utils/normalize.js"; + +const BUNDLE_VERSION = "1.0"; + +/** Stable constant for manifest.schemaVersion. Deterministic: no paths, mtimes, or env. When a custom schema is supplied, inputs.schemaPath records it. */ +const SCHEMA_VERSION = "surfaces.web.contract@1"; + +export interface CompileCommandOptions { + contractPath: string; + outDir: string; + schemaPath?: string; + format?: "json"; +} + +interface ManifestInputs { + contractPath: string; + schemaPath: string | null; +} + +interface ManifestFileEntry { + path: string; + sha256: string; +} + +interface Manifest { + bundleVersion: string; + contractId: string; + contractVersion: string; + schemaVersion: string; + tool: { name: string; version: string }; + inputs: ManifestInputs; + files: ManifestFileEntry[]; +} + +/** + * Recursively sort object keys for deterministic JSON output. + * Array element order is preserved; only object keys are sorted. + */ +function sortKeysRecursive(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + if (Array.isArray(value)) { + return value.map(sortKeysRecursive); + } + if (typeof value === "object") { + const sorted: Record = {}; + for (const k of Object.keys(value as Record).sort()) { + sorted[k] = sortKeysRecursive((value as Record)[k]); + } + return sorted; + } + return value; +} + +/** + * Serialize value to JSON with stable key ordering and readable indent. + */ +function stringifyDeterministic(value: unknown): string { + return `${JSON.stringify(sortKeysRecursive(value), null, 2)}\n`; +} + +function sha256Hex(content: string): string { + return createHash("sha256").update(content, "utf8").digest("hex"); +} + +/** + * Write content to file atomically (write to .tmp then rename). + */ +async function writeAtomic( + filePath: string, + content: string, +): Promise { + const dir = path.dirname(filePath); + await mkdir(dir, { recursive: true }); + const tmpPath = `${filePath}.tmp`; + await writeFile(tmpPath, content, "utf8"); + await rename(tmpPath, filePath); +} + +export async function runCompileCommand( + options: CompileCommandOptions, + toolVersion: string, +): Promise { + const outDir = path.resolve(options.outDir); + const contractInput = path.resolve(options.contractPath); + const schemaPath = options.schemaPath + ? path.resolve(options.schemaPath) + : undefined; + + let contractRaw: string; + try { + contractRaw = await readFile(contractInput, "utf8"); + } catch (err) { + const message = (err as NodeJS.ErrnoException).code === "ENOENT" + ? `Contract file not found: ${contractInput}` + : `Failed to read contract: ${(err as Error).message}`; + console.error(message); + return 1; + } + + let contractData: unknown; + try { + contractData = JSON.parse(contractRaw); + } catch (err) { + console.error(`Invalid contract JSON: ${(err as Error).message}`); + return 1; + } + + let schema: object; + if (schemaPath) { + try { + const raw = await readFile(schemaPath, "utf8"); + schema = JSON.parse(raw) as object; + } catch (err) { + const message = (err as NodeJS.ErrnoException).code === "ENOENT" + ? `Schema file not found: ${schemaPath}` + : `Failed to read schema: ${(err as Error).message}`; + console.error(message); + return 1; + } + } else { + schema = getBundledContractSchema(); + } + + const structureResult = validateContractStructure(contractData, schema); + if (!structureResult.ok || !structureResult.contract) { + console.error("Contract schema validation failed:"); + for (const error of structureResult.errors) { + console.error(` • ${error}`); + } + return 1; + } + + const contract = structureResult.contract; + const { contract: normalizedContract } = normalizeContract(contract); + + const contractContent = stringifyDeterministic(normalizedContract); + const bundleFiles: { path: string; content: string }[] = [ + { path: "contract.normalized.json", content: contractContent }, + ]; + + for (const surface of normalizedContract.surfaces) { + const surfacePayload = surfaceToBundlePayload(surface); + const content = stringifyDeterministic(surfacePayload); + bundleFiles.push({ + path: `surfaces/${surface.id}.json`, + content, + }); + } + + if (normalizedContract.constraints.motion) { + const motionContent = stringifyDeterministic( + normalizedContract.constraints.motion, + ); + bundleFiles.push({ + path: "constraints/motion.json", + content: motionContent, + }); + } + + const filesSorted = [...bundleFiles].sort((a, b) => + a.path.localeCompare(b.path), + ); + const fileEntries: ManifestFileEntry[] = filesSorted.map(({ path: p, content }) => ({ + path: p, + sha256: sha256Hex(content), + })); + + const manifest: Manifest = { + bundleVersion: BUNDLE_VERSION, + contractId: normalizedContract.contractId, + contractVersion: normalizedContract.version, + schemaVersion: SCHEMA_VERSION, + tool: { name: "interfacectl", version: toolVersion }, + inputs: { + contractPath: options.contractPath, + schemaPath: schemaPath ?? null, + }, + files: fileEntries, + }; + + const manifestContent = stringifyDeterministic(manifest); + + try { + for (const { path: p, content } of filesSorted) { + await writeAtomic(path.join(outDir, p), content); + } + await writeAtomic(path.join(outDir, "manifest.json"), manifestContent); + } catch (err) { + console.error(`Failed to write bundle: ${(err as Error).message}`); + return 1; + } + + return 0; +} + +function surfaceToBundlePayload(surface: ContractSurface): Record { + return { + id: surface.id, + displayName: surface.displayName, + type: surface.type, + requiredSections: surface.requiredSections, + allowedFonts: surface.allowedFonts, + ...(surface.allowedColors && { allowedColors: surface.allowedColors }), + layout: surface.layout, + }; +} diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index 0f0c0aa..2c83e28 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -4,6 +4,7 @@ import { Command } from "commander"; import { runValidateCommand } from "./commands/validate.js"; import { runDiffCommand } from "./commands/diff.js"; import { runEnforceCommand } from "./commands/enforce.js"; +import { runCompileCommand } from "./commands/compile.js"; import pkg from "../package.json" with { type: "json" }; const program = new Command(); @@ -251,6 +252,26 @@ program process.exitCode = exitCode; }); +program + .command("compile") + .description("Produce a deterministic directory bundle for runtime consumption") + .requiredOption("--contract ", "Path to the contract JSON file") + .requiredOption("--out ", "Output directory for the bundle") + .option("--schema ", "Optional path to the contract schema JSON file") + .option("--format ", "Output format (json)") + .action(async (options) => { + const exitCode = await runCompileCommand( + { + contractPath: options.contract, + outDir: options.out, + schemaPath: options.schema, + format: options.format, + }, + pkg.version ?? "0.0.0", + ); + process.exitCode = exitCode; + }); + program.parseAsync(process.argv).catch((error) => { console.error(error); process.exitCode = 1; diff --git a/packages/interfacectl-cli/test/compile.test.mjs b/packages/interfacectl-cli/test/compile.test.mjs new file mode 100644 index 0000000..a12b52e --- /dev/null +++ b/packages/interfacectl-cli/test/compile.test.mjs @@ -0,0 +1,193 @@ +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"; +import os from "node:os"; +import { + mkdtemp, + readFile, + readdir, + rm, + stat, +} from "node:fs/promises"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const cliPath = path.resolve(__dirname, "..", "dist", "index.js"); +const fixtureDir = path.resolve(__dirname, "fixtures", "compile"); +const contractPath = path.join(fixtureDir, "contract", "ui.contract.json"); +const expectedDir = path.join(fixtureDir, "expected"); + +async function runCompile(contract, outDir, schemaPath = undefined) { + const args = [ + "compile", + "--contract", + contract, + "--out", + outDir, + ]; + if (schemaPath) { + args.push("--schema", schemaPath); + } + const child = spawn("node", [cliPath, ...args], { + env: process.env, + cwd: path.dirname(contract), + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d.toString(); }); + child.stderr.on("data", (d) => { stderr += d.toString(); }); + + const [exitCode] = await once(child, "exit"); + return { + exitCode: Number(exitCode), + stdout, + stderr, + }; +} + +async function readJson(p) { + const raw = await readFile(p, "utf8"); + return JSON.parse(raw); +} + +test("compile: structure - required files exist and no extra files", async () => { + const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-structure-")); + try { + const result = await runCompile(contractPath, outDir); + assert.equal(result.exitCode, 0, `compile should exit 0: ${result.stderr}`); + + const manifest = await readJson(path.join(outDir, "manifest.json")); + assert.equal(manifest.bundleVersion, "1.0"); + assert.equal(manifest.contractId, "demo-ui"); + assert.equal(manifest.contractVersion, "1.0.0"); + assert.ok(Array.isArray(manifest.files)); + assert.ok(manifest.files.length >= 3); + + const paths = manifest.files.map((f) => f.path); + assert.ok(paths.includes("contract.normalized.json"), "bundle must include contract.normalized.json"); + assert.ok(paths.includes("surfaces/demo-surface.json"), "bundle must include surfaces/demo-surface.json"); + assert.ok(paths.includes("constraints/motion.json"), "bundle must include constraints/motion.json"); + + for (const entry of manifest.files) { + assert.ok(!entry.path.includes("manifest.json"), "files must not include manifest.json"); + assert.match(entry.sha256, /^[a-f0-9]{64}$/, `sha256 for ${entry.path} must be 64 hex chars`); + } + + const sortedPaths = [...paths].sort(); + assert.deepEqual(paths, sortedPaths, "manifest.files must be sorted by path"); + + const contractNorm = path.join(outDir, "contract.normalized.json"); + const contractStat = await stat(contractNorm); + assert.ok(contractStat.isFile(), "contract.normalized.json must be a file"); + + const surfacesDir = path.join(outDir, "surfaces"); + const surfacesStat = await stat(surfacesDir); + assert.ok(surfacesStat.isDirectory(), "surfaces/ must exist"); + const surfaceFiles = await readdir(surfacesDir); + assert.ok(surfaceFiles.includes("demo-surface.json"), "surfaces/ must contain demo-surface.json"); + + const constraintsDir = path.join(outDir, "constraints"); + const constraintsStat = await stat(constraintsDir); + assert.ok(constraintsStat.isDirectory(), "constraints/ must exist"); + const motionPath = path.join(constraintsDir, "motion.json"); + const motionStat = await stat(motionPath); + assert.ok(motionStat.isFile(), "constraints/motion.json must exist"); + } finally { + await rm(outDir, { recursive: true, force: true }); + } +}); + +test("compile: determinism - two runs produce identical manifest.files", async () => { + const out1 = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-a-")); + const out2 = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-b-")); + try { + const r1 = await runCompile(contractPath, out1); + const r2 = await runCompile(contractPath, out2); + assert.equal(r1.exitCode, 0, `first run should exit 0: ${r1.stderr}`); + assert.equal(r2.exitCode, 0, `second run should exit 0: ${r2.stderr}`); + + const manifest1 = await readJson(path.join(out1, "manifest.json")); + const manifest2 = await readJson(path.join(out2, "manifest.json")); + + assert.deepEqual( + manifest1.files, + manifest2.files, + "Two runs must produce identical manifest.files (paths and sha256)", + ); + } finally { + await rm(out1, { recursive: true, force: true }); + await rm(out2, { recursive: true, force: true }); + } +}); + +test("compile: golden - generated contract.normalized.json and surface/constraint files match expected", async () => { + const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-golden-")); + try { + const result = await runCompile(contractPath, outDir); + assert.equal(result.exitCode, 0, `compile should exit 0: ${result.stderr}`); + + const expectedContract = await readJson(path.join(expectedDir, "contract.normalized.json")); + const generatedContract = await readJson(path.join(outDir, "contract.normalized.json")); + assert.deepEqual(generatedContract, expectedContract, "contract.normalized.json must match expected"); + + const expectedSurface = await readJson(path.join(expectedDir, "surfaces", "demo-surface.json")); + const generatedSurface = await readJson(path.join(outDir, "surfaces", "demo-surface.json")); + assert.deepEqual(generatedSurface, expectedSurface, "surfaces/demo-surface.json must match expected"); + + const expectedMotion = await readJson(path.join(expectedDir, "constraints", "motion.json")); + const generatedMotion = await readJson(path.join(outDir, "constraints", "motion.json")); + assert.deepEqual(generatedMotion, expectedMotion, "constraints/motion.json must match expected"); + } finally { + await rm(outDir, { recursive: true, force: true }); + } +}); + +test("compile: invalid contract fails with non-zero exit", async () => { + const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-fail-")); + const invalidContract = path.join(outDir, "invalid.json"); + try { + const { writeFile } = await import("node:fs/promises"); + await writeFile( + invalidContract, + JSON.stringify({ + contractId: "bad", + version: "1.0.0", + surfaces: [], + sections: [], + constraints: {}, + }, null, 2), + "utf8", + ); + const result = await runCompile(invalidContract, outDir); + assert.notEqual(result.exitCode, 0, "invalid contract must cause non-zero exit"); + } finally { + await rm(outDir, { recursive: true, force: true }); + } +}); + +test("compile: missing required field (constraints) fails", async () => { + const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-fail2-")); + const badContract = path.join(outDir, "bad.json"); + try { + const { writeFile } = await import("node:fs/promises"); + await writeFile( + badContract, + JSON.stringify({ + contractId: "bad", + version: "1.0.0", + surfaces: [{ id: "x", displayName: "X", type: "web", requiredSections: [], allowedFonts: [], layout: { maxContentWidth: 100 } }], + sections: [], + }, null, 2), + "utf8", + ); + const result = await runCompile(badContract, outDir); + assert.notEqual(result.exitCode, 0, "contract missing required constraints must fail"); + } finally { + await rm(outDir, { recursive: true, force: true }); + } +}); diff --git a/packages/interfacectl-cli/test/fixtures/compile/README.md b/packages/interfacectl-cli/test/fixtures/compile/README.md new file mode 100644 index 0000000..296350e --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/README.md @@ -0,0 +1,8 @@ +# Compile fixture + +Input contract and golden expected output for `interfacectl compile` tests. + +- **contract/ui.contract.json**: Small representative contract (one surface, motion constraints). +- **expected/**: Golden output for one compile run. Tests compare generated bundle to this for structure and determinism. + +Tests run compile twice into separate temp dirs and assert manifest.files and hashes match (determinism). Optionally compare generated files to expected/ for shape and content. diff --git a/packages/interfacectl-cli/test/fixtures/compile/contract/ui.contract.json b/packages/interfacectl-cli/test/fixtures/compile/contract/ui.contract.json new file mode 100644 index 0000000..54a9c93 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/contract/ui.contract.json @@ -0,0 +1,29 @@ +{ + "contractId": "demo-ui", + "version": "1.0.0", + "surfaces": [ + { + "id": "demo-surface", + "displayName": "Demo Surface", + "type": "web", + "requiredSections": ["main.hero"], + "allowedFonts": ["var(--font-demo)", "Demo Sans", "sans-serif"], + "layout": { + "maxContentWidth": 960 + } + } + ], + "sections": [ + { + "id": "main.hero", + "intent": "primary-intro", + "description": "Demo hero section" + } + ], + "constraints": { + "motion": { + "allowedDurationsMs": [120], + "allowedTimingFunctions": ["linear"] + } + } +} diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/constraints/motion.json b/packages/interfacectl-cli/test/fixtures/compile/expected/constraints/motion.json new file mode 100644 index 0000000..d4d3deb --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/constraints/motion.json @@ -0,0 +1,4 @@ +{ + "allowedDurationsMs": [120], + "allowedTimingFunctions": ["linear"] +} diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/contract.normalized.json b/packages/interfacectl-cli/test/fixtures/compile/expected/contract.normalized.json new file mode 100644 index 0000000..3d3e10a --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/contract.normalized.json @@ -0,0 +1,33 @@ +{ + "constraints": { + "motion": { + "allowedDurationsMs": [120], + "allowedTimingFunctions": ["linear"] + } + }, + "contractId": "demo-ui", + "sections": [ + { + "description": "Demo hero section", + "id": "main.hero", + "intent": "primary-intro" + } + ], + "surfaces": [ + { + "allowedFonts": [ + "Demo Sans", + "sans-serif", + "var(--font-demo)" + ], + "displayName": "Demo Surface", + "id": "demo-surface", + "layout": { + "maxContentWidth": 960 + }, + "requiredSections": ["main.hero"], + "type": "web" + } + ], + "version": "1.0.0" +} diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/manifest.json b/packages/interfacectl-cli/test/fixtures/compile/expected/manifest.json new file mode 100644 index 0000000..03bebcd --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/manifest.json @@ -0,0 +1,28 @@ +{ + "bundleVersion": "1.0", + "contractId": "demo-ui", + "contractVersion": "1.0.0", + "schemaVersion": "surfaces.web.contract@1", + "tool": { + "name": "interfacectl", + "version": "0.1.0" + }, + "inputs": { + "contractPath": "contracts/ui.contract.json", + "schemaPath": null + }, + "files": [ + { + "path": "constraints/motion.json", + "sha256": "" + }, + { + "path": "contract.normalized.json", + "sha256": "" + }, + { + "path": "surfaces/demo-surface.json", + "sha256": "" + } + ] +} diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface.json b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface.json new file mode 100644 index 0000000..819afcc --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface.json @@ -0,0 +1,14 @@ +{ + "allowedFonts": [ + "Demo Sans", + "sans-serif", + "var(--font-demo)" + ], + "displayName": "Demo Surface", + "id": "demo-surface", + "layout": { + "maxContentWidth": 960 + }, + "requiredSections": ["main.hero"], + "type": "web" +}