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
73 changes: 73 additions & 0 deletions .github/PULL_REQUEST_PHASE3_COMPILE.md
Original file line number Diff line number Diff line change
@@ -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 <path> --out <dir> [--schema <path>] [--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/<surfaceId>.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 <fixture> --out <tmpdir>` creates manifest.json, contract.normalized.json, surfaces/<surfaceId>.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.
55 changes: 55 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> --out <dir> [--schema <path>] [--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>` | Path to the contract JSON file (required) |
| `--out <dir>` | Output directory for the bundle (required) |
| `--schema <path>` | Optional path to the contract schema JSON file |
| `--format <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/<surfaceId>.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: "<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.
Expand Down
8 changes: 8 additions & 0 deletions packages/interfacectl-cli/dist/commands/compile.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface CompileCommandOptions {
contractPath: string;
outDir: string;
schemaPath?: string;
format?: "json";
}
export declare function runCompileCommand(options: CompileCommandOptions, toolVersion: string): Promise<number>;
//# sourceMappingURL=compile.d.ts.map
1 change: 1 addition & 0 deletions packages/interfacectl-cli/dist/commands/compile.d.ts.map

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

159 changes: 159 additions & 0 deletions packages/interfacectl-cli/dist/commands/compile.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
17 changes: 17 additions & 0 deletions packages/interfacectl-cli/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -164,6 +165,22 @@ program
});
process.exitCode = exitCode;
});
program
.command("compile")
.description("Produce a deterministic directory bundle for runtime consumption")
.requiredOption("--contract <path>", "Path to the contract JSON file")
.requiredOption("--out <dir>", "Output directory for the bundle")
.option("--schema <path>", "Optional path to the contract schema JSON file")
.option("--format <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;
Expand Down
Loading