From c56226e29bcd12b635c67ce019db38e1bcf6366b Mon Sep 17 00:00:00 2001 From: Mike Long Date: Thu, 12 Mar 2026 21:56:30 -0700 Subject: [PATCH 1/5] feat: add generation bundle adapter commands --- API.md | 60 +- README.md | 15 +- docs/ai-generator-adapter-api.md | 44 +- docs/ai-generator-adapter-quickstart.md | 105 +- docs/ai-tool-playbooks.md | 56 +- docs/generator-consumption.md | 92 +- .../interfacectl-cli/dist/adapter/bundle.d.ts | 46 + .../dist/adapter/bundle.d.ts.map | 1 + .../interfacectl-cli/dist/adapter/bundle.js | 153 +++ .../interfacectl-cli/dist/adapter/core.d.ts | 76 ++ .../dist/adapter/core.d.ts.map | 1 + .../interfacectl-cli/dist/adapter/core.js | 686 ++++++++++++ .../dist/commands/compile.d.ts.map | 2 +- .../interfacectl-cli/dist/commands/compile.js | 538 +++++++++- .../dist/commands/prepare-generation.d.ts | 7 + .../dist/commands/prepare-generation.d.ts.map | 1 + .../dist/commands/prepare-generation.js | 225 ++++ .../commands/serve-generation-adapter.d.ts | 18 + .../serve-generation-adapter.d.ts.map | 1 + .../dist/commands/serve-generation-adapter.js | 169 +++ .../dist/commands/validate-generation.d.ts | 13 + .../commands/validate-generation.d.ts.map | 1 + .../dist/commands/validate-generation.js | 85 ++ packages/interfacectl-cli/dist/index.js | 60 ++ .../generation-adapter-request.schema.json | 120 +++ .../generation-adapter-response.schema.json | 131 +++ .../interfacectl-cli/src/adapter/bundle.ts | 216 ++++ packages/interfacectl-cli/src/adapter/core.ts | 975 ++++++++++++++++++ .../interfacectl-cli/src/commands/compile.ts | 689 ++++++++++++- .../src/commands/prepare-generation.ts | 275 +++++ .../src/commands/serve-generation-adapter.ts | 225 ++++ .../src/commands/validate-generation.ts | 143 +++ packages/interfacectl-cli/src/index.ts | 63 ++ .../interfacectl-cli/test/compile.test.mjs | 271 ++++- .../compile/expected/constraints/motion.json | 4 - .../normalized.json} | 10 +- .../fixtures/compile/expected/manifest.json | 20 +- .../expected/surfaces/demo-surface.json | 14 - .../surfaces/demo-surface/components.json | 9 + .../surfaces/demo-surface/constraints.json | 27 + .../surfaces/demo-surface/generation.json | 113 ++ .../surfaces/demo-surface/repair-map.json | 76 ++ .../surfaces/demo-surface/sections.json | 16 + .../test/generation-adapter.test.mjs | 433 ++++++++ .../test/prepare-generation.test.mjs | 350 +++++++ 45 files changed, 6321 insertions(+), 314 deletions(-) create mode 100644 packages/interfacectl-cli/dist/adapter/bundle.d.ts create mode 100644 packages/interfacectl-cli/dist/adapter/bundle.d.ts.map create mode 100644 packages/interfacectl-cli/dist/adapter/bundle.js create mode 100644 packages/interfacectl-cli/dist/adapter/core.d.ts create mode 100644 packages/interfacectl-cli/dist/adapter/core.d.ts.map create mode 100644 packages/interfacectl-cli/dist/adapter/core.js create mode 100644 packages/interfacectl-cli/dist/commands/prepare-generation.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/prepare-generation.js create mode 100644 packages/interfacectl-cli/dist/commands/serve-generation-adapter.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/serve-generation-adapter.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/serve-generation-adapter.js create mode 100644 packages/interfacectl-cli/dist/commands/validate-generation.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/validate-generation.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/validate-generation.js create mode 100644 packages/interfacectl-cli/schemas/generation-adapter-request.schema.json create mode 100644 packages/interfacectl-cli/schemas/generation-adapter-response.schema.json create mode 100644 packages/interfacectl-cli/src/adapter/bundle.ts create mode 100644 packages/interfacectl-cli/src/adapter/core.ts create mode 100644 packages/interfacectl-cli/src/commands/prepare-generation.ts create mode 100644 packages/interfacectl-cli/src/commands/serve-generation-adapter.ts create mode 100644 packages/interfacectl-cli/src/commands/validate-generation.ts delete mode 100644 packages/interfacectl-cli/test/fixtures/compile/expected/constraints/motion.json rename packages/interfacectl-cli/test/fixtures/compile/expected/{contract.normalized.json => contract/normalized.json} (90%) delete mode 100644 packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface.json create mode 100644 packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/components.json create mode 100644 packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/constraints.json create mode 100644 packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/generation.json create mode 100644 packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/repair-map.json create mode 100644 packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/sections.json create mode 100644 packages/interfacectl-cli/test/generation-adapter.test.mjs create mode 100644 packages/interfacectl-cli/test/prepare-generation.test.mjs diff --git a/API.md b/API.md index 776b75f..d09a9ad 100644 --- a/API.md +++ b/API.md @@ -563,15 +563,19 @@ 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) | +| `contract/normalized.json` | Normalized full contract kept for traceability and downstream validation | +| `surfaces//generation.json` | Surface entrypoint for generators and adapter consumers | +| `surfaces//sections.json` | Surface-local section slices and anatomy references | +| `surfaces//components.json` | Shared component catalog referenced by sections | +| `surfaces//constraints.json` | Cross-cutting contract-authoritative constraints for the surface | +| `surfaces//repair-map.json` | Deterministic repair actions keyed by canonical finding codes | +| `surfaces//authoring.json` | Optional authoring hints when the surface declares them | **Manifest fields** | Field | Description | |-------|-------------| -| `bundleVersion` | Format version for this bundle (e.g. `"1.0"`) | +| `bundleVersion` | Format version for this bundle (e.g. `"2.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`) | @@ -583,6 +587,54 @@ No timestamps are included in the manifest so that bundles remain deterministic. --- +### `prepare-generation` + +Resolves one compiled surface bundle into a single, agent-ready JSON payload for local workspace agents. + +**Synopsis:** +```bash +interfacectl prepare-generation --bundle-root --surface [--out ] +``` + +**Description:** +- Loads a compiled generation bundle and validates that the requested surface exists. +- Resolves `generation.json`, `sections.json`, `components.json`, `constraints.json`, `repair-map.json`, optional `authoring.json`, and `contract/normalized.json`. +- Emits one deterministic JSON document with summary text, checklist items, resolved generation guidance, sections, components, constraints, repairs, provenance, and source file paths. +- Uses evidence refs only; it does not inline extracted observation payloads. + +**Options:** + +| Option | Description | +|--------|-------------| +| `--bundle-root ` | Path to the compiled generation bundle directory (required) | +| `--surface ` | Surface identifier to resolve from the bundle (required) | +| `--out ` | Optional output file path. When provided, the command writes the JSON payload to disk and suppresses the full stdout payload | + +**Exit Codes:** +- `0`: Prepared payload written successfully +- `10`: Invalid input, missing bundle files, unsupported bundle version, or unreadable compiled contract +- `1`: Unexpected internal error + +**Output shape** + +The generated JSON document includes: + +| Field | Description | +|-------|-------------| +| `surface` | `{ surfaceId, displayName, type }` | +| `bundle` | `{ root, version, manifestPath, sourcePaths }` | +| `contract` | `{ id, version, normalizedPath }` | +| `summary` | Human-readable text plus checklist/focus items and top repair priorities | +| `generation` | Resolved `boundary`, `structure`, `layout`, `visual`, and `guidance` objects | +| `sections` | Resolved section list for the target surface | +| `components` | Resolved component catalog for the target surface | +| `constraints` | Cross-cutting contract-authoritative constraints | +| `repairMap` | Deterministic repair actions keyed by canonical finding codes | +| `authoring` | Optional authoring hints when present in the source bundle | +| `evidenceRefs` | Evidence refs only; no inline extracted payloads | + +--- + ### `generate-contract` (Phase 0) Extracts a deterministic contract artifact from a Next.js app by analyzing app code. **Extraction only** — no enforcement, no network calls. Output is schema-valid; extracted-only fields live under `x_extracted`. diff --git a/README.md b/README.md index fb90ae2..0a8979f 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ interfacectl enforce [options] ### `compile` -Compiles a validated interface contract into a deterministic, runtime-readable bundle. The bundle includes a manifest, a normalized contract, per-surface files, and per-constraint files for downstream tools to consume. +Compiles a validated interface contract into a deterministic, generation-oriented bundle. The bundle includes a manifest, `contract/normalized.json`, and per-surface slices for downstream generators, adapters, and workbench consumers. This command does **not** perform enforcement or runtime gating. It produces a stable artifact intended for inspection, tooling, or future runtime consumption. @@ -155,6 +155,16 @@ This command does **not** perform enforcement or runtime gating. It produces a s interfacectl compile --contract --out ``` +### `prepare-generation` + +Resolves one compiled surface bundle into a single, agent-ready JSON payload for local workspace agents. The payload includes bundle and contract provenance, resolved generation guidance, sections, components, constraints, repair actions, and optional authoring hints. + +```bash +interfacectl prepare-generation --bundle-root --surface [--out ] +``` + +Use this before local agent generation. Use `validate-generation` after code is written. + ### `generate-contract` (Phase 0 expert command) Extracts a **deterministic contract artifact** from a Next.js app by analyzing app code and config. This is **contract extraction only** — no first-run classification, no design-system draft, and no onboarding summary. Prefer `interfacectl init` for the user-facing entry point. @@ -201,6 +211,9 @@ interfacectl separates interface governance into clear phases: - **Compile** Produces a deterministic, runtime-readable bundle (manifest, normalized contract, surfaces, constraints) that serves as the handoff point to other tools. +- **Prepare generation** + Resolves one surface from the bundle into a single local-agent payload. + - **Runtime consumption (framing only)** Consumption semantics are documented in Phase 4. No runtime enforcement or loaders exist in this repo. diff --git a/docs/ai-generator-adapter-api.md b/docs/ai-generator-adapter-api.md index ac677ae..5e7d50f 100644 --- a/docs/ai-generator-adapter-api.md +++ b/docs/ai-generator-adapter-api.md @@ -1,8 +1,15 @@ # AI Generator Adapter API -This page defines the canonical request/response semantics for generator adapters built around `interfacectl` validation. +This page defines the canonical request/response semantics for generator adapters built around `interfacectl` validation and the compiled generation bundle. -Consumer repos may materialize this contract as JSON Schema, typed interfaces, CLI wrappers, or HTTP endpoints, but they should not change the meaning of the fields or the `pass|warn|block` verdict model. +For local workspace agents, the expected pre-generation handoff is `interfacectl prepare-generation`. This document covers the post-generation validation contract. + +JSON Schemas for this contract live in: + +- `packages/interfacectl-cli/schemas/generation-adapter-request.schema.json` +- `packages/interfacectl-cli/schemas/generation-adapter-response.schema.json` + +Consumer repos may materialize this contract as CLI wrappers, HTTP endpoints, editor tasks, or MCP tools, but they should not change the meaning of the fields or the `pass|warn|block` verdict model. ## Request contract @@ -12,6 +19,7 @@ Consumer repos may materialize this contract as JSON Schema, typed interfaces, C "tool": "codex|cursor|lovable|figma-make", "surfaceId": "reference-target-web", "mode": "workspace|descriptor", + "bundleRoot": "/absolute/path/to/compiled/bundle", "workspaceRoot": "/absolute/path/optional", "descriptor": [ { @@ -21,7 +29,6 @@ Consumer repos may materialize this contract as JSON Schema, typed interfaces, C "icons": [{ "value": "@heroicons/react/24/outline", "source": "generated.tsx" }] } ], - "contractPath": "/absolute/path/optional", "provenance": { "sessionId": "string", "userId": "string", @@ -32,12 +39,10 @@ Consumer repos may materialize this contract as JSON Schema, typed interfaces, C Field notes: -- `requestId` is caller-supplied when available and otherwise may be generated by the consumer. -- `tool` identifies the caller for provenance and debugging; it does not change policy meaning. -- `surfaceId` selects the governed surface within the target contract. +- `bundleRoot` is required and must point to the output of `interfacectl compile`. - `mode=workspace` requires `workspaceRoot`. - `mode=descriptor` requires `descriptor`. -- `contractPath` is an optional consumer override for contract resolution. +- `contractPath` is no longer supported. - `provenance` is optional but recommended for auditability. Descriptor entries commonly include: @@ -54,6 +59,12 @@ Descriptor entries commonly include: "requestId": "uuid", "status": "pass|warn|block", "surfaceId": "reference-target-web", + "bundle": { + "root": "/absolute/path/to/compiled/bundle", + "version": "2.0", + "manifestPath": "/absolute/path/to/compiled/bundle/manifest.json", + "surfacePath": "/absolute/path/to/compiled/bundle/surfaces/reference-target-web/generation.json" + }, "contract": { "id": "surfaces.web", "version": "0.1.0" @@ -92,12 +103,11 @@ Descriptor entries commonly include: Field notes: -- `status` is the canonical verdict. +- `bundle` captures the compiled bundle provenance that shaped the evaluation. +- `contract` preserves canonical contract identity and version. - `coverage.generationGuard` is `true` when generation-time guard checks ran. - `coverage.fullValidate` is `true` only when a full validation pass ran. -- Category-specific coverage flags state whether that policy area was actually evaluated for this request. - `findings` carry the structured remediation payload that tools should feed back into generation. -- `timings` and `provenance` are additive operational metadata. ## Status semantics @@ -111,17 +121,9 @@ Consumers must preserve these semantics even if they map the result onto repo-lo This contract is transport-agnostic. -- CLI wrappers may emit JSON and map `block` to a non-zero exit code. -- HTTP wrappers may map `block` to `422` or another non-success status. -- Editor or MCP wrappers may return the payload directly. - -Transport choices are local. Verdict and finding meaning are canonical. - -## Versioning policy - -- Additive fields are backward compatible. -- Breaking request/response changes require coordinated updates across canonical docs and consumer wrappers. -- Consumers may add transport metadata, but they should not rename or reinterpret the canonical verdict fields. +- `interfacectl validate-generation` emits JSON and maps `block` to exit code `30`. +- `interfacectl serve-generation-adapter` maps `block` to HTTP `422`. +- Consumer wrappers may bind `bundleRoot` out-of-band, but the canonical data model remains bundle-based. ## Related docs diff --git a/docs/ai-generator-adapter-quickstart.md b/docs/ai-generator-adapter-quickstart.md index 3803bff..765c1b4 100644 --- a/docs/ai-generator-adapter-quickstart.md +++ b/docs/ai-generator-adapter-quickstart.md @@ -1,68 +1,93 @@ # AI Generator Adapter Quick Start -Use this flow when a generator authors or modifies UI and you want immediate contract feedback at `Generation time`. +Use this flow when a local agent or hosted generator needs contract-aware guidance before generation and structured findings after generation. -## Purpose +## Integration flow -The generator adapter loop gives tools a stable verdict contract: +1. Compile the contract into a generation bundle. +2. For local agents, resolve the bundle into one agent-ready payload with `prepare-generation`. +3. Generate or edit UI. +4. Run `validate-generation` against the same bundle. +5. Repeat until the result is `pass` or an accepted `warn`. -- `pass` -- `warn` -- `block` +## Step 1: compile the bundle -The adapter is not a separate policy system. It translates generator output into the same contract-aware feedback loop that `interfacectl validate` uses for authoritative compliance checks. +```bash +interfacectl compile --contract --out +``` -## Integration flow +All later steps use `bundleRoot` from this compiled directory. The CLI never compiles implicitly inside adapter commands. -1. Load the target contract and surface id. -2. Choose execution mode: - - `descriptor` for hosted builders or tools without a full checkout - - `workspace` for local agents or CI jobs with a checkout -3. Convert generated output into a descriptor or point the adapter at the workspace. -4. Run generation guard checks so shell-boundary violations and other generation-time findings surface immediately. -5. In `workspace` mode, run full `interfacectl validate` and treat that result as the authoritative promotion gate. -6. Feed structured findings back into the generator and regenerate until the result is `pass` or an accepted `warn`. +## Step 2: prepare local-agent input -## Mode guidance +Use `prepare-generation` for workspace agents such as Codex, Cursor, or Claude Code. -### `descriptor` +```bash +interfacectl prepare-generation \ + --bundle-root ./artifacts/generation-bundles/surfaces-web \ + --surface surfaces-web \ + --out ./artifacts/generation-inputs/surfaces-web.json +``` -Use `descriptor` mode for tools such as Lovable, Figma Make, or other hosted builders that do not operate inside a repo checkout. +The output is one tool-neutral JSON payload with: -- Always evaluate shell-boundary signals. -- Evaluate any additional parity checks that the consumer repo enables for descriptor-mode rollout. -- Never treat descriptor-only success as the final shipping gate. +- bundle and contract provenance +- resolved boundary, structure, layout, visual, and guidance data +- sections, components, constraints, repair map, and optional authoring hints +- summary text plus checklist items +- evidence refs only, never inline extracted payloads + +## Step 3: validate generation ### `workspace` -Use `workspace` mode for tools such as Codex, Cursor, Claude Code, or any local generator that can inspect the real repo state. +Use `workspace` mode after a local agent edits the repo. + +```bash +interfacectl validate-generation \ + --tool codex \ + --surface surfaces-web \ + --mode workspace \ + --workspace-root . \ + --bundle-root ./artifacts/generation-bundles/surfaces-web +``` + +### `descriptor` + +Use `descriptor` mode for hosted builders such as Lovable or Figma Make. + +```bash +interfacectl validate-generation \ + --tool lovable \ + --surface reference-target-web \ + --mode descriptor \ + --bundle-root ./artifacts/generation-bundles/reference-target-web \ + --descriptor-path ./generated/reference-target-web.descriptor.json +``` -- Run generation guard checks first for fast feedback. -- Run full `interfacectl validate` before promotion or merge. -- Prefer deterministic exit-code modes in CI so generated changes can be blocked cleanly. +Exit semantics: -## Adapter outputs +- `0` for `pass` and `warn` +- `30` for `block` +- `10` for malformed adapter input or unreadable bundle -Every adapter wrapper should return: +## HTTP mode -- `requestId` -- `status` -- `findings` -- `coverage` -- `contract` metadata -- `timings` -- `provenance` +Hosted builders can still use the HTTP adapter: -See [AI Generator Adapter API](./ai-generator-adapter-api.md) for the canonical request/response contract. +```bash +interfacectl serve-generation-adapter \ + --bundle-root ./artifacts/generation-bundles/reference-target-web +``` -## Consumer wiring +Endpoint: -Consumer repos may expose the adapter through wrapper commands, HTTP endpoints, editor tasks, or MCP tools. Those entrypoints are repo-local concerns. They should preserve the canonical verdict semantics documented here and in the adapter API. +- `POST /surfaces.validateGeneration` +- `422` for `status=block` +- `400` for invalid request ## Related docs - [AI Generator Adapter API](./ai-generator-adapter-api.md) - [AI Tool Playbooks](./ai-tool-playbooks.md) - [Generator-Aware Contract Consumption](./generator-consumption.md) -- [Shell Boundary Semantics](./shell-boundary.md) -- [Generation Boundaries Guide](./generation-boundaries.md) diff --git a/docs/ai-tool-playbooks.md b/docs/ai-tool-playbooks.md index 6577d79..fb56969 100644 --- a/docs/ai-tool-playbooks.md +++ b/docs/ai-tool-playbooks.md @@ -1,18 +1,18 @@ # AI Tool Playbooks -This page shows how generators should use the same contract and verdict loop across local agents, IDE coders, hosted builders, and template engines. +This page shows how generators should use the same compiled bundle and verdict loop across local agents, IDE coders, hosted builders, and template engines. ## Shared model All tools should follow the same pattern: -1. Load the target contract and surface id before generation. -2. Constrain generation with the contract instead of treating validation as a post-hoc lint step. -3. Validate generated output through an adapter or a workspace validation flow. -4. Feed structured findings back into the generator context and regenerate when needed. -5. Keep `interfacectl validate` as the final CI/CD gate. +1. Compile the target contract into a bundle. +2. For local agents, materialize that bundle with `prepare-generation`. +3. Generate or edit UI. +4. Validate the result against the same bundle. +5. Keep CI or workspace validation as the final shipping gate. -The transport can change. The contract semantics do not. +The transport can change. The bundle semantics and `pass|warn|block` verdict do not. ## Agentic coders @@ -20,13 +20,14 @@ Examples: Codex, Cursor, Claude Code, repo-local codegen workflows. Recommended flow: -1. Read the contract and identify the target surface. -2. Generate directly in the repo or a checked-out workspace. -3. Run `workspace` mode or `interfacectl validate`. -4. Treat `block` as a hard stop. -5. Feed findings back into the next generation attempt. +1. Run `interfacectl compile`. +2. Run `interfacectl prepare-generation` for the target surface. +3. Feed the prepared JSON into the agent context or repo-local wrapper. +4. Generate directly in the repo. +5. Run `interfacectl validate-generation --mode workspace`. +6. Treat `block` as a hard stop and feed findings back into the next attempt. -These tools benefit most from the full workspace path because they can validate against the actual repo state before code lands. +These tools should consume the prepared generation payload, not the raw contract or sibling bundle files directly. ## Hosted builders @@ -34,13 +35,13 @@ Examples: Lovable, Figma Make, or other tools that produce UI outside the repo. Recommended flow: -1. Map generated output into a descriptor. -2. Submit the descriptor to an adapter wrapper. -3. Surface the returned findings in-product. -4. Gate export or publish on `block` whenever the host allows it. -5. Keep a CI fallback that runs workspace validation before merge or deploy. +1. Compile the bundle for the target surface. +2. Map generated output into a descriptor. +3. Submit the descriptor to the HTTP adapter. +4. Surface returned findings in-product. +5. Keep a workspace or CI fallback for final promotion. -Hosted tools usually start with `descriptor` mode and rely on a later workspace gate for authoritative promotion. +Hosted tools stay on the descriptor path for now; `prepare-generation` is the local-agent handoff. ## Template engines and scaffolds @@ -48,24 +49,25 @@ Examples: internal generators, boilerplate scaffolds, CMS-to-code exporters. Recommended flow: -1. Use the contract as an input spec for allowed structure, layout, color, icon, motion, and shell boundary rules. -2. Emit provenance in generated output. -3. Produce descriptors or run workspace validation as part of generation. -4. Fail the generation job when a blocking finding is returned. +1. Compile the bundle. +2. Use `prepare-generation` as the canonical input payload. +3. Emit provenance in generated output. +4. Run descriptor or workspace validation as part of generation. +5. Fail the job on `block`. ## What can vary by tool -- how the contract is injected into prompts or generation config -- whether the tool uses `descriptor` or `workspace` mode first +- how the prepared JSON is injected into prompts or generation config +- whether the tool validates first in `descriptor` or `workspace` mode - how findings are surfaced back to the user - whether publish/export can be blocked in-product or only in CI ## What must stay stable -- contract semantics +- bundle semantics - shell-boundary meaning - `pass|warn|block` verdict meaning -- the use of CI/CD validation as the final shipping gate +- the use of CI or workspace validation as the final shipping gate ## Related docs diff --git a/docs/generator-consumption.md b/docs/generator-consumption.md index f9af6c5..aab88df 100644 --- a/docs/generator-consumption.md +++ b/docs/generator-consumption.md @@ -1,68 +1,44 @@ # Generator-Aware Contract Consumption -This document defines how generators should consume interface contracts to improve generation accuracy before code lands and to correct output when it drifts. +This document defines how generators should consume compiled generation bundles to improve first-pass accuracy and then correct drift after generation. -## Use the contract twice +## Use the bundle twice -Generators should use the contract in two loops: +Generators should use the bundle in two loops: -1. **Before generation** to constrain structure, layout, tokens, and boundaries. -2. **After generation** to evaluate the produced output and feed structured findings back into the next attempt. +1. **Before generation** with `prepare-generation` to obtain one resolved, agent-ready payload. +2. **After generation** with `validate-generation` to evaluate the produced output and feed findings back into the next attempt. -Using only the second loop turns the contract into a post-hoc blocker. Using both loops makes it an authoring aid. +Using only the second loop turns the contract into a blocker. Using both loops makes it an authoring aid. -## Contract fields that matter at generation time +## Local-agent flow -### Boundary and ownership +For workspace agents: -- `shell.owns` -- `shell.contentSlot` -- `surface.mustNotEmit` +1. Run `interfacectl compile --contract --out `. +2. Run `interfacectl prepare-generation --bundle-root --surface `. +3. Feed the resulting JSON into the agent. +4. Generate only inside the surface-owned boundary. +5. Run `interfacectl validate-generation --mode workspace` against the same bundle. +6. Feed structured findings back into the next attempt. -These fields define what the generator must leave to the shell and where the surface-owned content is allowed to live. +The prepared payload is the canonical handoff for local agents. Do not make each agent re-load and merge sibling bundle files independently. -### Structure and composition +## What the prepared payload must carry -- `sections[*].id` -- `sections[*].intent` -- `surfaces[*].requiredSections` -- `surfaces[*].layout.landingPattern` -- `surfaces[*].flows` +Local agents need these resolved inputs: -These fields tell the generator which sections must exist, what order or grouping rules apply, and which flows or steps are required. +- boundary and ownership rules +- structure, required sections, and flow summary +- layout constraints and viewport hints +- visual policy and selected profiles +- sections and referenced components +- cross-cutting constraints +- deterministic repair actions +- optional authoring hints +- bundle and contract provenance -### Layout constraints - -- `surfaces[*].layout.maxContentWidth` -- `surfaces[*].layout.requiredContainers` -- `surfaces[*].layout.pageFrame` -- `surfaces[*].layout.chromePolicy` -- `marketingProfiles.layout` - -These fields constrain page width, container shape, frame rules, and shared marketing layout expectations. - -### Visual system constraints - -- `color.policy` -- `color.allowedValues` -- `surfaces[*].icons.policy` -- `surfaces[*].icons.allowedSources` -- `constraints.motion.allowedDurationsMs` -- `constraints.motion.allowedTimingFunctions` -- `marketingProfiles.typography` -- `surfaces[*].marketingTypographyProfile` -- `surfaces[*].marketingTypographyPolicy` - -These fields constrain the palette, icon sources, motion behavior, and shared typography/layout profiles. - -## Generator workflow - -1. Load the contract and narrow to the target surface. -2. Convert contract fields into prompt instructions, generation config, or locked UI regions. -3. Generate only inside the surface-owned boundary. -4. Emit provenance such as `surfaceId`, `contractId`, and `contract version`. -5. Convert the result into a descriptor or validate directly against the workspace. -6. Feed structured findings back into the next generation attempt. +The payload must include only evidence refs, never inline extracted observation payloads. ## Findings are generation input @@ -71,7 +47,7 @@ Generators should not treat findings as human-only diagnostics. - `shell-owned-primitive-emitted` means the next attempt must stay inside the surface boundary. - `color.disallowed` means the next attempt must restrict itself to the allowlist. - `icon.source-disallowed` means the next attempt must pick an allowed icon library. -- layout, landing-pattern, typography, and flow findings should be translated into concrete repair instructions for the next attempt. +- layout, landing-pattern, typography, and flow findings should be translated into concrete repair instructions from the bundle repair map. ## Provenance expectations @@ -80,12 +56,18 @@ Generated output should carry enough provenance for later inspection: - `surfaceId` - `contractId` - `contract version` +- bundle version when a consumer stores it + +Embedding patterns vary by consumer, but the goal is consistent traceability from generated output back to the bundle that shaped it. -Embedding patterns vary by consumer, but the goal is consistent traceability from generated output back to the contract that shaped it. +## Limits of the canonical payload -## Limits of the current contract +The prepared payload is intentionally tool-neutral. -The current contract is strongest at correctness constraints and boundary enforcement. Consumer repos may add richer authoring metadata for higher-fidelity first-pass generation, but generators should treat the canonical contract fields above as the minimum interoperable source of truth. +- It is not a model-specific prompt pack. +- It does not replace `validate-generation`. +- It does not inline raw extraction evidence. +- It does not try to cover hosted descriptor flows, which remain on the adapter path. ## Related docs diff --git a/packages/interfacectl-cli/dist/adapter/bundle.d.ts b/packages/interfacectl-cli/dist/adapter/bundle.d.ts new file mode 100644 index 0000000..9e8ea3b --- /dev/null +++ b/packages/interfacectl-cli/dist/adapter/bundle.d.ts @@ -0,0 +1,46 @@ +export declare const SUPPORTED_BUNDLE_VERSION = "2.0"; +export interface JsonRecord { + [key: string]: unknown; +} +export interface BundleManifest extends JsonRecord { + bundleVersion?: string; + contractId?: string; + contractVersion?: string; +} +export interface LoadedJsonFile { + path: string; + value: T; +} +export interface LoadedCompiledSurfaceBundle { + root: string; + version: string; + contractId: string; + contractVersion: string; + manifest: LoadedJsonFile; + contract: LoadedJsonFile; + surface: { + id: string; + dir: string; + generation: LoadedJsonFile; + sections: LoadedJsonFile; + components: LoadedJsonFile; + constraints: LoadedJsonFile; + repairMap: LoadedJsonFile; + authoring?: LoadedJsonFile; + }; +} +export declare class AdapterInputError extends Error { + code: string; + meta?: Record; + constructor(message: string, details?: { + code?: string; + meta?: Record; + }); +} +export declare function isAdapterInputError(error: unknown): error is AdapterInputError; +export declare function isRecord(value: unknown): value is JsonRecord; +export declare function readJsonFile(filePath: string, label: string): T; +export declare function ensureReadableFile(filePath: string, label: string): void; +export declare function ensureReadableDirectory(dirPath: string, label: string): void; +export declare function loadCompiledSurfaceBundle(bundleRootInput: string, surfaceId: string, cwd: string): LoadedCompiledSurfaceBundle; +//# sourceMappingURL=bundle.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/adapter/bundle.d.ts.map b/packages/interfacectl-cli/dist/adapter/bundle.d.ts.map new file mode 100644 index 0000000..f710a6c --- /dev/null +++ b/packages/interfacectl-cli/dist/adapter/bundle.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"bundle.d.ts","sourceRoot":"","sources":["../../src/adapter/bundle.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,wBAAwB,QAAQ,CAAC;AAE9C,MAAM,WAAW,UAAU;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,cAAe,SAAQ,UAAU;IAChD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,CAAC;CACV;AAED,MAAM,WAAW,2BAA2B;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,cAAc,CAAC,cAAc,CAAC,CAAC;IACzC,QAAQ,EAAE,cAAc,CAAC;IACzB,OAAO,EAAE;QACP,EAAE,EAAE,MAAM,CAAC;QACX,GAAG,EAAE,MAAM,CAAC;QACZ,UAAU,EAAE,cAAc,CAAC;QAC3B,QAAQ,EAAE,cAAc,CAAC;QACzB,UAAU,EAAE,cAAc,CAAC;QAC3B,WAAW,EAAE,cAAc,CAAC;QAC5B,SAAS,EAAE,cAAc,CAAC;QAC1B,SAAS,CAAC,EAAE,cAAc,CAAC;KAC5B,CAAC;CACH;AAED,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;gBAEnB,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAO;CAM7F;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,iBAAiB,CAE9E;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,UAAU,CAE5D;AAED,wBAAgB,YAAY,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,CAAC,CAalG;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAOxE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAO5E;AAQD,wBAAgB,yBAAyB,CACvC,eAAe,EAAE,MAAM,EACvB,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,2BAA2B,CAiH7B"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/adapter/bundle.js b/packages/interfacectl-cli/dist/adapter/bundle.js new file mode 100644 index 0000000..1cd3909 --- /dev/null +++ b/packages/interfacectl-cli/dist/adapter/bundle.js @@ -0,0 +1,153 @@ +import fs from "node:fs"; +import path from "node:path"; +export const SUPPORTED_BUNDLE_VERSION = "2.0"; +export class AdapterInputError extends Error { + code; + meta; + constructor(message, details = {}) { + super(message); + this.name = "AdapterInputError"; + this.code = details.code ?? "adapter.input"; + this.meta = details.meta; + } +} +export function isAdapterInputError(error) { + return error instanceof AdapterInputError; +} +export function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} +export function readJsonFile(filePath, label) { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) { + throw new Error(`${label} JSON must be an object.`); + } + return parsed; + } + catch (error) { + throw new AdapterInputError(`Failed to read ${label} JSON at ${filePath}: ${error.message}`); + } +} +export function ensureReadableFile(filePath, label) { + if (!fs.existsSync(filePath)) { + throw new AdapterInputError(`${label} file not found at ${filePath}.`); + } + if (!fs.statSync(filePath).isFile()) { + throw new AdapterInputError(`${label} path is not a file: ${filePath}.`); + } +} +export function ensureReadableDirectory(dirPath, label) { + if (!fs.existsSync(dirPath)) { + throw new AdapterInputError(`${label} directory not found at ${dirPath}.`); + } + if (!fs.statSync(dirPath).isDirectory()) { + throw new AdapterInputError(`${label} path is not a directory: ${dirPath}.`); + } +} +function getNestedString(record, key) { + if (!record) + return undefined; + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} +export function loadCompiledSurfaceBundle(bundleRootInput, surfaceId, cwd) { + const bundleRoot = path.resolve(cwd, bundleRootInput); + ensureReadableDirectory(bundleRoot, "Bundle root"); + const manifestPath = path.join(bundleRoot, "manifest.json"); + ensureReadableFile(manifestPath, "Bundle manifest"); + const manifest = readJsonFile(manifestPath, "bundle manifest"); + if (manifest.bundleVersion !== SUPPORTED_BUNDLE_VERSION) { + throw new AdapterInputError(`Unsupported bundle version "${manifest.bundleVersion ?? "unknown"}". Expected ${SUPPORTED_BUNDLE_VERSION}.`, { code: "adapter.bundle.version-unsupported" }); + } + const surfaceDir = path.join(bundleRoot, "surfaces", surfaceId); + ensureReadableDirectory(surfaceDir, "Surface bundle"); + const generationPath = path.join(surfaceDir, "generation.json"); + const sectionsPath = path.join(surfaceDir, "sections.json"); + const componentsPath = path.join(surfaceDir, "components.json"); + const constraintsPath = path.join(surfaceDir, "constraints.json"); + const repairMapPath = path.join(surfaceDir, "repair-map.json"); + for (const [filePath, label] of [ + [generationPath, "generation entrypoint"], + [sectionsPath, "sections bundle"], + [componentsPath, "components bundle"], + [constraintsPath, "constraints bundle"], + [repairMapPath, "repair map"], + ]) { + ensureReadableFile(filePath, label); + } + const generation = { + path: generationPath, + value: readJsonFile(generationPath, "generation entrypoint"), + }; + const sections = { + path: sectionsPath, + value: readJsonFile(sectionsPath, "sections bundle"), + }; + const components = { + path: componentsPath, + value: readJsonFile(componentsPath, "components bundle"), + }; + const constraints = { + path: constraintsPath, + value: readJsonFile(constraintsPath, "constraints bundle"), + }; + const repairMap = { + path: repairMapPath, + value: readJsonFile(repairMapPath, "repair map"), + }; + const refs = isRecord(generation.value.refs) ? generation.value.refs : {}; + const contractRef = typeof refs.contract === "string" && refs.contract.trim().length > 0 + ? refs.contract + : "../../contract/normalized.json"; + const contractPath = path.resolve(path.dirname(generationPath), contractRef); + ensureReadableFile(contractPath, "Compiled contract"); + const contract = { + path: contractPath, + value: readJsonFile(contractPath, "Compiled contract"), + }; + let authoring; + if (typeof refs.authoring === "string" && refs.authoring.trim().length > 0) { + const authoringPath = path.resolve(path.dirname(generationPath), refs.authoring); + ensureReadableFile(authoringPath, "Authoring bundle"); + authoring = { + path: authoringPath, + value: readJsonFile(authoringPath, "Authoring bundle"), + }; + } + const generationProvenance = isRecord(generation.value.provenance) + ? generation.value.provenance + : undefined; + const contractId = typeof manifest.contractId === "string" && manifest.contractId.length > 0 + ? manifest.contractId + : getNestedString(generationProvenance, "contractId") ?? + getNestedString(contract.value, "contractId") ?? + "unknown"; + const contractVersion = typeof manifest.contractVersion === "string" && manifest.contractVersion.length > 0 + ? manifest.contractVersion + : getNestedString(generationProvenance, "contractVersion") ?? + getNestedString(contract.value, "version") ?? + "unknown"; + return { + root: bundleRoot, + version: SUPPORTED_BUNDLE_VERSION, + contractId, + contractVersion, + manifest: { + path: manifestPath, + value: manifest, + }, + contract, + surface: { + id: surfaceId, + dir: surfaceDir, + generation, + sections, + components, + constraints, + repairMap, + ...(authoring ? { authoring } : {}), + }, + }; +} diff --git a/packages/interfacectl-cli/dist/adapter/core.d.ts b/packages/interfacectl-cli/dist/adapter/core.d.ts new file mode 100644 index 0000000..09ac654 --- /dev/null +++ b/packages/interfacectl-cli/dist/adapter/core.d.ts @@ -0,0 +1,76 @@ +import { type JsonRecord } from "./bundle.js"; +export { AdapterInputError, isAdapterInputError } from "./bundle.js"; +type FindingSeverity = "error" | "warning"; +type FindingPolicy = "strict" | "warn" | "off"; +type AdapterMode = "workspace" | "descriptor"; +export interface AdapterFinding { + code: string; + severity: FindingSeverity; + policy: FindingPolicy; + message: string; + location: { + file: string; + line: number; + }; + evidence: Record; +} +export interface GenerationAdapterRequest { + requestId?: string; + tool?: string; + surfaceId?: string; + mode?: string; + bundleRoot?: string; + workspaceRoot?: string; + descriptor?: JsonRecord[]; + provenance?: { + sessionId?: string; + userId?: string; + timestamp?: string; + }; + contractPath?: string; +} +export interface GenerationAdapterResponse { + requestId: string; + status: "pass" | "warn" | "block"; + surfaceId: string; + bundle: { + root: string; + version: string; + manifestPath: string; + surfacePath: string; + }; + contract: { + id: string; + version: string; + }; + coverage: { + generationGuard: boolean; + fullValidate: boolean; + shellBoundaryEvaluated: boolean; + colorPolicyEvaluated: boolean; + iconPolicyEvaluated: boolean; + }; + findings: AdapterFinding[]; + timings: { + totalMs: number; + }; + provenance: { + sessionId?: string; + userId?: string; + timestamp: string; + evaluatedAt: string; + }; +} +export interface RunGenerationAdapterOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + nodeBin?: string; + generationGuardPath?: string; + descriptorParityConfigPath?: string; + descriptorParitySurfaces?: string[]; + defaultBundleRoot?: string; +} +export declare function buildCoverage(mode: AdapterMode, detail?: Partial): GenerationAdapterResponse["coverage"]; +export declare function computeStatusFromFindings(findings: AdapterFinding[]): "pass" | "warn" | "block"; +export declare function runGenerationAdapter(requestInput: GenerationAdapterRequest, options?: RunGenerationAdapterOptions): Promise; +//# sourceMappingURL=core.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/adapter/core.d.ts.map b/packages/interfacectl-cli/dist/adapter/core.d.ts.map new file mode 100644 index 0000000..c95c6ec --- /dev/null +++ b/packages/interfacectl-cli/dist/adapter/core.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../../src/adapter/core.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAIrB,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAyBrE,KAAK,eAAe,GAAG,OAAO,GAAG,SAAS,CAAC;AAC3C,KAAK,aAAa,GAAG,QAAQ,GAAG,MAAM,GAAG,KAAK,CAAC;AAC/C,KAAK,WAAW,GAAG,WAAW,GAAG,YAAY,CAAC;AAE9C,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,eAAe,CAAC;IAC1B,MAAM,EAAE,aAAa,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE;QACR,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,wBAAwB;IACvC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,UAAU,EAAE,CAAC;IAC1B,UAAU,CAAC,EAAE;QACX,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAkDD,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE;QACN,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,YAAY,EAAE,MAAM,CAAC;QACrB,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,QAAQ,EAAE;QACR,EAAE,EAAE,MAAM,CAAC;QACX,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;IACF,QAAQ,EAAE;QACR,eAAe,EAAE,OAAO,CAAC;QACzB,YAAY,EAAE,OAAO,CAAC;QACtB,sBAAsB,EAAE,OAAO,CAAC;QAChC,oBAAoB,EAAE,OAAO,CAAC;QAC9B,mBAAmB,EAAE,OAAO,CAAC;KAC9B,CAAC;IACF,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;IACF,UAAU,EAAE;QACV,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;CACH;AAED,MAAM,WAAW,2BAA2B;IAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,wBAAwB,CAAC,EAAE,MAAM,EAAE,CAAC;IACpC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AA+sBD,wBAAgB,aAAa,CAC3B,IAAI,EAAE,WAAW,EACjB,MAAM,GAAE,OAAO,CAAC,yBAAyB,CAAC,UAAU,CAAC,CAAM,GAC1D,yBAAyB,CAAC,UAAU,CAAC,CAQvC;AAED,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,cAAc,EAAE,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAO/F;AAED,wBAAsB,oBAAoB,CACxC,YAAY,EAAE,wBAAwB,EACtC,OAAO,GAAE,2BAAgC,GACxC,OAAO,CAAC,yBAAyB,CAAC,CAmEpC"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/adapter/core.js b/packages/interfacectl-cli/dist/adapter/core.js new file mode 100644 index 0000000..0c50b29 --- /dev/null +++ b/packages/interfacectl-cli/dist/adapter/core.js @@ -0,0 +1,686 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { AdapterInputError, ensureReadableFile, isRecord, loadCompiledSurfaceBundle, readJsonFile, } from "./bundle.js"; +import { runDescribeCommand } from "../commands/describe.js"; +import { runValidateCommand } from "../commands/validate.js"; +export { AdapterInputError, isAdapterInputError } from "./bundle.js"; +const VALID_TOOLS = new Set(["codex", "cursor", "lovable", "figma-make"]); +const VALID_MODES = new Set(["workspace", "descriptor"]); +const DEFAULT_DESCRIPTOR_PARITY_CONFIG = "contracts/generation-descriptor-parity.json"; +const PARITY_FINDING_CODES = new Set([ + "color.disallowed", + "icon.source-disallowed", + "descriptor.icons.missing", +]); +const VALIDATE_CODE_MAP = { + "color.raw-value.used": "color.rawValues", + "color.token.namespace.violation": "color.token.namespace", + "shell.primitive.disallowed": "shell-owned-primitive-emitted", +}; +const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_GUARD_PATH = path.resolve(MODULE_DIR, "../../../../tools/check-generation-boundaries.mjs"); +function resolvePathOrDefault(value, fallback, cwd) { + const candidate = value ?? fallback; + if (!candidate) + return undefined; + return path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate); +} +function resolveGuardPath(options, cwd) { + const env = options.env ?? process.env; + const explicit = resolvePathOrDefault(options.generationGuardPath ?? + env.SURFACES_INTERFACECTL_GENERATION_GUARD ?? + env.SURFACES_ADAPTER_GENERATION_GUARD, undefined, cwd); + if (explicit) { + ensureReadableFile(explicit, "Generation guard"); + return explicit; + } + ensureReadableFile(DEFAULT_GUARD_PATH, "Generation guard"); + return DEFAULT_GUARD_PATH; +} +function normalizeAdapterRequest(input, options = {}) { + if (!isRecord(input)) { + throw new AdapterInputError("Adapter request must be an object."); + } + if (typeof input.contractPath === "string" && input.contractPath.trim().length > 0) { + throw new AdapterInputError("contractPath is no longer supported. Use bundleRoot instead.", { code: "adapter.input.legacy-contract-path" }); + } + const cwd = options.cwd ?? process.cwd(); + const requestId = typeof input.requestId === "string" && input.requestId.trim().length > 0 + ? input.requestId.trim() + : crypto.randomUUID(); + const tool = typeof input.tool === "string" ? input.tool.trim().toLowerCase() : ""; + if (!VALID_TOOLS.has(tool)) { + throw new AdapterInputError(`Invalid tool "${input.tool ?? ""}". Expected one of: ${[...VALID_TOOLS].join(", ")}.`); + } + const surfaceId = typeof input.surfaceId === "string" ? input.surfaceId.trim() : ""; + if (!surfaceId) { + throw new AdapterInputError("surfaceId is required."); + } + const mode = typeof input.mode === "string" ? input.mode.trim() : ""; + if (!VALID_MODES.has(mode)) { + throw new AdapterInputError(`Invalid mode "${input.mode ?? ""}". Expected workspace|descriptor.`); + } + const bundleRootInput = typeof input.bundleRoot === "string" && input.bundleRoot.trim().length > 0 + ? input.bundleRoot.trim() + : options.defaultBundleRoot; + if (!bundleRootInput) { + throw new AdapterInputError("bundleRoot is required."); + } + const bundleRoot = path.resolve(cwd, bundleRootInput); + let workspaceRoot; + if (mode === "workspace") { + const rawWorkspaceRoot = typeof input.workspaceRoot === "string" && input.workspaceRoot.trim().length > 0 + ? input.workspaceRoot.trim() + : undefined; + if (!rawWorkspaceRoot) { + throw new AdapterInputError("workspaceRoot is required when mode=workspace."); + } + workspaceRoot = path.resolve(cwd, rawWorkspaceRoot); + } + let descriptor; + if (mode === "descriptor") { + if (!Array.isArray(input.descriptor)) { + throw new AdapterInputError("descriptor must be an array when mode=descriptor."); + } + descriptor = input.descriptor.map((entry) => { + if (!isRecord(entry)) { + throw new AdapterInputError("descriptor entries must be JSON objects."); + } + return entry; + }); + } + const provenanceInput = isRecord(input.provenance) ? input.provenance : {}; + const provenance = { + sessionId: typeof provenanceInput.sessionId === "string" + ? provenanceInput.sessionId + : undefined, + userId: typeof provenanceInput.userId === "string" + ? provenanceInput.userId + : undefined, + timestamp: typeof provenanceInput.timestamp === "string" + ? provenanceInput.timestamp + : new Date().toISOString(), + }; + return { + requestId, + tool, + surfaceId, + mode: mode, + bundleRoot, + workspaceRoot, + descriptor, + provenance, + }; +} +function resolveBundleSurface(request, cwd) { + const bundle = loadCompiledSurfaceBundle(request.bundleRoot, request.surfaceId, cwd); + return { + root: bundle.root, + version: bundle.version, + contractId: bundle.contractId, + contractVersion: bundle.contractVersion, + manifestPath: bundle.manifest.path, + contractPath: bundle.contract.path, + generationPath: bundle.surface.generation.path, + sectionsPath: bundle.surface.sections.path, + componentsPath: bundle.surface.components.path, + constraintsPath: bundle.surface.constraints.path, + repairMapPath: bundle.surface.repairMap.path, + ...(bundle.surface.authoring ? { authoringPath: bundle.surface.authoring.path } : {}), + }; +} +function resolveRuntime(request, options = {}) { + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + const descriptorParityConfigPath = resolvePathOrDefault(options.descriptorParityConfigPath ?? + env.SURFACES_ADAPTER_DESCRIPTOR_PARITY_CONFIG ?? + DEFAULT_DESCRIPTOR_PARITY_CONFIG, undefined, cwd); + return { + cwd, + bundle: resolveBundleSurface(request, cwd), + guardPath: resolveGuardPath(options, cwd), + nodeBin: options.nodeBin ?? process.execPath, + env, + descriptorParityConfigPath, + }; +} +function runNodeScript(runtime, scriptPath, args) { + const result = spawnSync(runtime.nodeBin, [scriptPath, ...args], { + cwd: runtime.cwd, + env: runtime.env, + encoding: "utf8", + }); + if (result.error) { + throw new Error(`Failed to run ${scriptPath}: ${result.error.message}`); + } + return { + status: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} +function makeTempJsonFile(prefix, fileName, payload) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + const filePath = path.join(tempDir, fileName); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + return { tempDir, filePath }; +} +function makeTempEmptyFile(prefix, fileName) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + return { tempDir, filePath: path.join(tempDir, fileName) }; +} +function cleanupTempPath(tempDir) { + if (!tempDir) + return; + fs.rmSync(tempDir, { recursive: true, force: true }); +} +function parseCsvList(value) { + if (!value) + return []; + return String(value) + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} +function normalizeGuardFinding(entry) { + if (!isRecord(entry)) { + return null; + } + const code = typeof entry.code === "string" ? entry.code.trim() : ""; + if (!code) { + return null; + } + const severity = entry.severity === "error" ? "error" : "warning"; + const policy = entry.policy === "strict" || entry.policy === "off" ? entry.policy : "warn"; + const message = typeof entry.message === "string" && entry.message.trim().length > 0 + ? entry.message + : code; + const location = isRecord(entry.location) ? entry.location : {}; + return { + code, + severity, + policy, + message, + location: { + file: typeof location.file === "string" ? location.file : "", + line: Number.isInteger(location.line) ? location.line : 0, + }, + evidence: isRecord(entry.evidence) ? entry.evidence : {}, + }; +} +function parseGuardFindingsFromJson(payload) { + if (!isRecord(payload) || !Array.isArray(payload.findings)) { + return []; + } + return payload.findings + .map((entry) => normalizeGuardFinding(entry)) + .filter((entry) => entry !== null); +} +function parseGuardFindingsFromText(output) { + const findings = []; + const lines = output.split(/\r?\n/); + let section = ""; + const primitivePattern = /^-\s+surface=(\S+)\s+role=(\S+)\s+count=(\d+)\s+sources=(.*?)\s+disallowedSources=(.*)$/; + const colorBlockingPattern = /^-\s+surface=(\S+)\s+rule=(\S+)\s+value=(.+?)\s+policy=(\S+)\s+source=(\S*)\s+message=(.*)$/; + const colorWarningPattern = /^-\s+surface=(\S+)\s+rule=(\S+)\s+value=(.+?)\s+source=(\S*)\s+message=(.*)$/; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) + continue; + if (line.startsWith("shell-owned-primitive-emitted detected")) { + section = "primitive"; + continue; + } + if (line.startsWith("color-policy-blocking detected")) { + section = "color-blocking"; + continue; + } + if (line.startsWith("color-policy-warning detected")) { + section = "color-warning"; + continue; + } + if (!line.startsWith("- ")) { + continue; + } + if (section === "primitive") { + const match = line.match(primitivePattern); + if (!match) + continue; + const [, surfaceId, role, count, sourcesRaw, disallowedRaw] = match; + const sources = parseCsvList(sourcesRaw); + const disallowedSources = parseCsvList(disallowedRaw); + findings.push({ + code: "shell-owned-primitive-emitted", + severity: "error", + policy: "strict", + message: `Shell-owned primitive "${role}" emitted for surface "${surfaceId}".`, + location: { + file: disallowedSources[0] ?? sources[0] ?? "", + line: 0, + }, + evidence: { + source: "generationGuard", + surfaceId, + role, + count: Number(count), + sources, + disallowedSources, + }, + }); + continue; + } + if (section === "color-blocking") { + const match = line.match(colorBlockingPattern); + if (!match) + continue; + const [, surfaceId, rule, value, policy, source, message] = match; + findings.push({ + code: rule, + severity: "error", + policy: policy === "warn" || policy === "off" ? "warn" : "strict", + message, + location: { + file: source ?? "", + line: 0, + }, + evidence: { + source: "generationGuard", + surfaceId, + value, + }, + }); + continue; + } + if (section === "color-warning") { + const match = line.match(colorWarningPattern); + if (!match) + continue; + const [, surfaceId, rule, value, source, message] = match; + findings.push({ + code: rule, + severity: "warning", + policy: "warn", + message, + location: { + file: source ?? "", + line: 0, + }, + evidence: { + source: "generationGuard", + surfaceId, + value, + }, + }); + } + } + return findings; +} +function parseGuardEvaluation(payload) { + const evaluation = isRecord(payload) && isRecord(payload.evaluation) ? payload.evaluation : {}; + return { + shellBoundaryEvaluated: evaluation.shellBoundaryEvaluated !== false, + colorPolicyEvaluated: evaluation.colorPolicyEvaluated === true, + iconPolicyEvaluated: evaluation.iconPolicyEvaluated === true, + }; +} +function loadDescriptorParitySurfaces(runtime, options) { + if (Array.isArray(options.descriptorParitySurfaces)) { + return new Set(options.descriptorParitySurfaces + .map((entry) => String(entry).trim()) + .filter(Boolean)); + } + const envOverride = runtime.env.SURFACES_ADAPTER_DESCRIPTOR_PARITY_SURFACES; + if (typeof envOverride === "string" && envOverride.trim().length > 0) { + return new Set(parseCsvList(envOverride)); + } + const configPath = runtime.descriptorParityConfigPath; + if (!configPath || !fs.existsSync(configPath)) { + return new Set(); + } + const payload = readJsonFile(configPath, "descriptor parity config"); + const enforced = Array.isArray(payload.enforcedSurfaces) + ? payload.enforcedSurfaces + .map((entry) => String(entry).trim()) + .filter(Boolean) + : []; + return new Set(enforced); +} +function mapValidateCode(code) { + return VALIDATE_CODE_MAP[code] ?? code; +} +function inferValidatePolicy(findingCode, severity) { + if (findingCode === "color.token.namespace") { + return "warn"; + } + if (severity === "error") { + return "strict"; + } + return "warn"; +} +function toFindingLocation(location) { + if (typeof location !== "string") { + return { file: "", line: 0 }; + } + return { file: location, line: 0 }; +} +function parseJsonPayload(text) { + if (!text || !text.trim()) + return undefined; + try { + return JSON.parse(text); + } + catch { + return undefined; + } +} +function normalizeValidateFindings(payload) { + if (!isRecord(payload) || !Array.isArray(payload.findings)) { + return []; + } + return payload.findings.map((findingRaw) => { + const finding = isRecord(findingRaw) ? findingRaw : {}; + const severity = finding.severity === "error" ? "error" : "warning"; + const code = mapValidateCode(typeof finding.code === "string" ? finding.code : "validate.finding"); + const policy = inferValidatePolicy(code, severity); + return { + code, + severity, + policy, + message: typeof finding.message === "string" && finding.message.trim().length > 0 + ? finding.message + : code, + location: toFindingLocation(finding.location), + evidence: { + source: "validate", + surfaceId: typeof finding.surface === "string" ? finding.surface : undefined, + category: finding.category, + expected: finding.expected, + found: finding.found, + }, + }; + }); +} +function uniqueFindings(findings) { + const seen = new Set(); + const normalized = []; + for (const finding of findings) { + const key = JSON.stringify({ + code: finding.code, + severity: finding.severity, + policy: finding.policy, + message: finding.message, + file: finding.location.file, + line: finding.location.line, + surfaceId: finding.evidence.surfaceId, + }); + if (seen.has(key)) { + continue; + } + seen.add(key); + normalized.push({ + code: finding.code, + severity: finding.severity, + policy: finding.policy, + message: finding.message, + location: { + file: finding.location.file, + line: finding.location.line, + }, + evidence: finding.evidence, + }); + } + return normalized; +} +async function runGenerationGuard(request, runtime) { + let descriptorTempDir; + let descriptorPath; + const findings = []; + let evaluation = { + shellBoundaryEvaluated: true, + colorPolicyEvaluated: false, + iconPolicyEvaluated: false, + }; + try { + if (request.mode === "descriptor") { + const temp = makeTempJsonFile("interfacectl-generation-descriptor", "descriptor.json", request.descriptor ?? []); + descriptorTempDir = temp.tempDir; + descriptorPath = temp.filePath; + } + else { + const temp = makeTempEmptyFile("interfacectl-generation-describe", "descriptor.json"); + descriptorTempDir = temp.tempDir; + descriptorPath = temp.filePath; + const describeExitCode = await runDescribeCommand({ + contractPath: runtime.bundle.contractPath, + root: request.workspaceRoot, + surface: [request.surfaceId], + out: descriptorPath, + }); + if (describeExitCode !== 0) { + findings.push({ + code: "generation.guard.describe.failed", + severity: "error", + policy: "strict", + message: "Failed to collect descriptors for generation guard.", + location: { file: "", line: 0 }, + evidence: { + source: "generationGuard", + surfaceId: request.surfaceId, + exitCode: describeExitCode, + }, + }); + return { findings, evaluation }; + } + } + const guard = runNodeScript(runtime, runtime.guardPath, [ + "--contract", + runtime.bundle.contractPath, + "--descriptor", + descriptorPath, + "--format", + "json", + ]); + const payload = parseJsonPayload(guard.stdout) ?? parseJsonPayload(guard.stderr); + const parsedFindings = parseGuardFindingsFromJson(payload); + const parsedFallback = parsedFindings.length > 0 + ? [] + : parseGuardFindingsFromText(`${guard.stderr}\n${guard.stdout}`); + if (parsedFindings.length > 0) { + findings.push(...parsedFindings); + evaluation = parseGuardEvaluation(payload); + } + else { + findings.push(...parsedFallback); + } + if (guard.status === 1) { + const errorMessage = (isRecord(payload) && isRecord(payload.error) && typeof payload.error.message === "string" + ? payload.error.message + : isRecord(payload) && typeof payload.error === "string" + ? payload.error + : `${guard.stderr || guard.stdout}`.trim()) || "unknown error"; + throw new AdapterInputError(`Generation guard failed due to invalid input: ${errorMessage}`); + } + if (guard.status === 2 && parsedFindings.length === 0 && parsedFallback.length === 0) { + findings.push({ + code: "generation.guard.blocked", + severity: "error", + policy: "strict", + message: "Generation guard reported violations but emitted no structured findings.", + location: { file: "", line: 0 }, + evidence: { + source: "generationGuard", + surfaceId: request.surfaceId, + exitCode: guard.status, + }, + }); + } + if (guard.status !== 0 && guard.status !== 2) { + findings.push({ + code: "generation.guard.execution.failed", + severity: "error", + policy: "strict", + message: `Generation guard failed unexpectedly: ${guard.stderr || guard.stdout || "unknown error"}`.trim(), + location: { file: "", line: 0 }, + evidence: { + source: "generationGuard", + surfaceId: request.surfaceId, + exitCode: guard.status, + }, + }); + } + return { findings, evaluation }; + } + finally { + cleanupTempPath(descriptorTempDir); + } +} +async function runWorkspaceValidate(request, runtime) { + const temp = makeTempEmptyFile("interfacectl-generation-validate", "validate.json"); + try { + const exitCode = await runValidateCommand({ + workspaceRoot: request.workspaceRoot, + contractPath: runtime.bundle.contractPath, + surfaceFilters: [request.surfaceId], + outputFormat: "json", + outputPath: temp.filePath, + exitCodes: "v2", + }); + if (!fs.existsSync(temp.filePath)) { + return [ + { + code: "validate.execution.failed", + severity: "error", + policy: "strict", + message: "validate command did not emit JSON output.", + location: { file: "", line: 0 }, + evidence: { + source: "validate", + surfaceId: request.surfaceId, + exitCode, + }, + }, + ]; + } + const payload = parseJsonPayload(fs.readFileSync(temp.filePath, "utf8")); + if (!payload) { + return [ + { + code: "validate.execution.failed", + severity: "error", + policy: "strict", + message: "validate command did not return JSON output.", + location: { file: "", line: 0 }, + evidence: { + source: "validate", + surfaceId: request.surfaceId, + exitCode, + }, + }, + ]; + } + const allFindings = normalizeValidateFindings(payload); + const findings = allFindings.filter((finding) => { + const surfaceId = finding.evidence.surfaceId; + return !surfaceId || surfaceId === request.surfaceId; + }); + if (exitCode !== 0 && findings.length === 0 && allFindings.length > 0) { + return []; + } + if (exitCode !== 0 && findings.length === 0) { + findings.push({ + code: "validate.blocked", + severity: "error", + policy: "strict", + message: "validate command failed without structured findings.", + location: { file: "", line: 0 }, + evidence: { + source: "validate", + surfaceId: request.surfaceId, + exitCode, + }, + }); + } + return findings; + } + finally { + cleanupTempPath(temp.tempDir); + } +} +export function buildCoverage(mode, detail = {}) { + return { + generationGuard: true, + fullValidate: mode === "workspace", + shellBoundaryEvaluated: detail.shellBoundaryEvaluated ?? false, + colorPolicyEvaluated: detail.colorPolicyEvaluated ?? false, + iconPolicyEvaluated: detail.iconPolicyEvaluated ?? false, + }; +} +export function computeStatusFromFindings(findings) { + const hasBlocking = findings.some((finding) => finding.severity === "error" || finding.policy === "strict"); + if (hasBlocking) + return "block"; + if (findings.length > 0) + return "warn"; + return "pass"; +} +export async function runGenerationAdapter(requestInput, options = {}) { + const startedAt = Date.now(); + const request = normalizeAdapterRequest(requestInput, options); + const runtime = resolveRuntime(request, options); + const descriptorParitySurfaces = loadDescriptorParitySurfaces(runtime, options); + ensureReadableFile(runtime.guardPath, "Generation guard"); + if (request.mode === "workspace") { + if (!request.workspaceRoot || !fs.existsSync(request.workspaceRoot)) { + throw new AdapterInputError(`workspaceRoot does not exist: ${request.workspaceRoot ?? ""}.`); + } + } + const descriptorParityEnabled = request.mode === "descriptor" && descriptorParitySurfaces.has(request.surfaceId); + const guardResult = await runGenerationGuard(request, runtime); + const findings = []; + const guardFindings = descriptorParityEnabled + ? guardResult.findings + : guardResult.findings.filter((finding) => !PARITY_FINDING_CODES.has(finding.code)); + findings.push(...guardFindings); + if (request.mode === "workspace") { + findings.push(...(await runWorkspaceValidate(request, runtime))); + } + const coverage = buildCoverage(request.mode, { + shellBoundaryEvaluated: guardResult.evaluation.shellBoundaryEvaluated, + colorPolicyEvaluated: descriptorParityEnabled || request.mode === "workspace" + ? guardResult.evaluation.colorPolicyEvaluated || request.mode === "workspace" + : false, + iconPolicyEvaluated: descriptorParityEnabled || request.mode === "workspace" + ? guardResult.evaluation.iconPolicyEvaluated || request.mode === "workspace" + : false, + }); + const normalizedFindings = uniqueFindings(findings); + const status = computeStatusFromFindings(normalizedFindings); + return { + requestId: request.requestId, + status, + surfaceId: request.surfaceId, + bundle: { + root: runtime.bundle.root, + version: runtime.bundle.version, + manifestPath: runtime.bundle.manifestPath, + surfacePath: runtime.bundle.generationPath, + }, + contract: { + id: runtime.bundle.contractId, + version: runtime.bundle.contractVersion, + }, + coverage, + findings: normalizedFindings, + timings: { + totalMs: Date.now() - startedAt, + }, + provenance: { + ...request.provenance, + evaluatedAt: new Date().toISOString(), + }, + }; +} diff --git a/packages/interfacectl-cli/dist/commands/compile.d.ts.map b/packages/interfacectl-cli/dist/commands/compile.d.ts.map index 62237ba..936a25c 100644 --- a/packages/interfacectl-cli/dist/commands/compile.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/compile.d.ts.map @@ -1 +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 +{"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/commands/compile.ts"],"names":[],"mappings":"AAqBA,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;AA0qBD,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,qBAAqB,EAC9B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CAkGjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/compile.js b/packages/interfacectl-cli/dist/commands/compile.js index 4161fe7..c9bd867 100644 --- a/packages/interfacectl-cli/dist/commands/compile.js +++ b/packages/interfacectl-cli/dist/commands/compile.js @@ -3,13 +3,8 @@ 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 BUNDLE_VERSION = "2.0"; 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; @@ -26,18 +21,12 @@ function sortKeysRecursive(value) { } 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 }); @@ -45,6 +34,497 @@ async function writeAtomic(filePath, content) { await writeFile(tmpPath, content, "utf8"); await rename(tmpPath, filePath); } +function makeBundleProvenance(contract, surfaceId) { + return { + contractId: contract.contractId, + contractVersion: contract.version, + bundleVersion: BUNDLE_VERSION, + ...(surfaceId ? { surfaceId } : {}), + }; +} +function uniqueStrings(values) { + const seen = new Set(); + const result = []; + for (const value of values) { + if (!value || seen.has(value)) + continue; + seen.add(value); + result.push(value); + } + return result; +} +function collectComponentIdsFromSlots(slots) { + if (!slots) + return []; + return slots.flatMap((slot) => slot.acceptsComponents ?? []); +} +function collectSectionComponentIds(section) { + return uniqueStrings([ + section.anatomy?.defaultComponent, + ...(section.anatomy?.allowedComponents ?? []), + ...collectComponentIdsFromSlots(section.anatomy?.slots), + ]); +} +function resolveSurfaceSections(contract, _surface) { + return contract.sections; +} +function resolveSurfaceComponents(contract, sections) { + const referencedIds = new Set(); + for (const section of sections) { + for (const componentId of collectSectionComponentIds(section)) { + referencedIds.add(componentId); + } + } + return (contract.components ?? []).filter((component) => referencedIds.has(component.id)); +} +function buildSectionOrderHints(surface) { + const sectionOrder = surface.layout.landingPattern?.sectionOrder ?? []; + const requiredTopLevel = new Set(surface.layout.landingPattern?.requireTopLevelSections ?? []); + const hints = new Map(); + for (const sectionId of sectionOrder) { + hints.set(sectionId, { + sectionOrderIndex: sectionOrder.indexOf(sectionId), + topLevelRequired: requiredTopLevel.has(sectionId), + }); + } + for (const sectionId of requiredTopLevel) { + const existing = hints.get(sectionId); + hints.set(sectionId, { + sectionOrderIndex: existing?.sectionOrderIndex, + topLevelRequired: true, + }); + } + return hints; +} +function buildSectionsPayload(contract, surface, sections) { + const orderHints = buildSectionOrderHints(surface); + return { + provenance: makeBundleProvenance(contract, surface.id), + sections: sections.map((section) => { + const hint = orderHints.get(section.id); + return { + id: section.id, + intent: section.intent, + description: section.description, + requiredBySurface: surface.requiredSections.includes(section.id), + ...(hint + ? { + orderHints: { + ...(typeof hint.sectionOrderIndex === "number" + ? { sectionOrderIndex: hint.sectionOrderIndex } + : {}), + topLevelRequired: hint.topLevelRequired, + }, + } + : {}), + ...(section.editPolicy ? { editPolicy: section.editPolicy } : {}), + ...(section.responsive ? { responsive: section.responsive } : {}), + ...(section.anatomy + ? { + anatomy: { + pattern: section.anatomy.pattern, + ...(section.anatomy.defaultComponent + ? { defaultComponentId: section.anatomy.defaultComponent } + : {}), + ...(section.anatomy.allowedComponents + ? { allowedComponentIds: section.anatomy.allowedComponents } + : {}), + ...(section.anatomy.slots + ? { + slots: section.anatomy.slots.map((slot) => ({ + id: slot.id, + kind: slot.kind, + required: slot.required, + ...(slot.repeatable !== undefined ? { repeatable: slot.repeatable } : {}), + ...(slot.minItems !== undefined ? { minItems: slot.minItems } : {}), + ...(slot.maxItems !== undefined ? { maxItems: slot.maxItems } : {}), + ...(slot.textRole ? { textRole: slot.textRole } : {}), + ...(slot.acceptsComponents + ? { acceptsComponentIds: slot.acceptsComponents } + : {}), + ...(slot.contentRules ? { contentRules: slot.contentRules } : {}), + })), + } + : {}), + }, + } + : {}), + }; + }), + }; +} +function buildComponentsPayload(contract, surface, components) { + const catalog = components.map((component) => ({ + id: component.id, + intent: component.intent, + ...(component.description ? { description: component.description } : {}), + ...(component.variants ? { variants: component.variants } : {}), + slots: component.slots, + ...(component.states ? { states: component.states } : {}), + ...(component.interactions ? { interactions: component.interactions } : {}), + ...(component.implementation ? { implementation: component.implementation } : {}), + ...(component.references ? { references: component.references } : {}), + })); + return { + provenance: makeBundleProvenance(contract, surface.id), + components: catalog, + }; +} +function resolveProfileById(profiles, profileId) { + if (!profiles || !profileId) + return null; + return profiles.find((profile) => profile.id === profileId) ?? null; +} +function buildConstraintsPayload(contract, surface) { + const selectedLayoutProfile = resolveProfileById(contract.marketingProfiles?.layout, surface.layout.landingPattern?.marketingLayoutProfile); + const selectedTypographyProfile = resolveProfileById(contract.marketingProfiles?.typography, surface.marketingTypographyProfile); + return { + provenance: makeBundleProvenance(contract, surface.id), + constraints: { + motion: contract.constraints.motion, + color: contract.color, + ...(contract.tokens ? { tokens: contract.tokens } : {}), + layoutPolicy: { + maxContentWidth: surface.layout.maxContentWidth, + requiredContainers: surface.layout.requiredContainers ?? [], + ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), + ...(surface.layout.chromePolicy ? { chromePolicy: surface.layout.chromePolicy } : {}), + ...(surface.layout.landingPattern ? { landingPattern: surface.layout.landingPattern } : {}), + ...(surface.viewports ? { viewports: surface.viewports } : {}), + }, + ...(surface.icons ? { icons: surface.icons } : {}), + ...(surface.flows ? { flows: surface.flows } : {}), + marketingProfiles: { + ...(selectedLayoutProfile ? { layout: selectedLayoutProfile } : {}), + ...(selectedTypographyProfile ? { typography: selectedTypographyProfile } : {}), + }, + }, + }; +} +function buildGuidance(contract, surface, sections) { + const shellOwns = contract.shell?.owns ?? []; + const mustNotEmit = surface.mustNotEmit ?? []; + const prohibitedRoles = uniqueStrings([...shellOwns, ...mustNotEmit]); + const hasLandingPattern = Boolean(surface.layout.landingPattern); + const hasResponsiveRules = sections.some((section) => section.responsive?.rules?.length); + const hasVisualPolicy = contract.color.policy !== "off" || + Boolean(surface.icons && surface.icons.policy !== "off") || + Boolean(contract.tokens && Object.keys(contract.tokens).length > 0); + return { + priorities: [ + { id: "boundary", rank: 1 }, + { id: "structure", rank: 2 }, + { id: "layout", rank: 3 }, + { id: "visual", rank: 4 }, + { id: "interaction", rank: 5 }, + ], + requiredOutputs: [ + { type: "surface-content", required: true }, + ...surface.requiredSections.map((sectionId) => ({ + type: "section", + sectionId, + required: true, + })), + ], + doNotEmitRoles: prohibitedRoles, + boundaryRules: [ + { + id: "shell-ownership", + shellOwns, + mustNotEmit, + ...(contract.shell?.contentSlot ? { contentSlot: contract.shell.contentSlot } : {}), + }, + ], + generationFocusOrder: uniqueStrings([ + "boundary", + "sections", + hasLandingPattern ? "landing-pattern" : undefined, + hasResponsiveRules ? "responsive" : undefined, + "layout", + hasVisualPolicy ? "visual" : undefined, + surface.flows ? "flows" : undefined, + ]), + }; +} +function buildObservationRefs(contract) { + const refs = []; + if (contract.x_extracted) { + refs.push({ + kind: "contract-field", + path: "/x_extracted", + }); + } + return refs; +} +function buildGenerationPayload(contract, surface, sections) { + const shellOwns = contract.shell?.owns ?? []; + const mustNotEmit = surface.mustNotEmit ?? []; + const requiredSections = surface.requiredSections; + const landingPattern = surface.layout.landingPattern; + const observationRefs = buildObservationRefs(contract); + return { + identity: { + surfaceId: surface.id, + displayName: surface.displayName, + type: surface.type, + }, + provenance: makeBundleProvenance(contract, surface.id), + boundary: { + shellOwns, + contentSlot: contract.shell?.contentSlot ?? null, + mustNotEmit, + allowSources: surface.shellOwnedPrimitiveAllowSources ?? [], + }, + structure: { + requiredSectionIds: requiredSections, + sectionOrder: landingPattern?.sectionOrder ?? [], + topLevelSectionIds: landingPattern?.requireTopLevelSections ?? [], + flowSummary: surface.flows + ? { + policy: surface.flows.policy, + flowIds: surface.flows.requirements.map((flow) => flow.flowId), + requirementCount: surface.flows.requirements.length, + } + : null, + sectionCount: sections.length, + }, + layout: { + maxContentWidth: surface.layout.maxContentWidth, + requiredContainers: surface.layout.requiredContainers ?? [], + ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), + ...(surface.layout.chromePolicy ? { chromePolicy: surface.layout.chromePolicy } : {}), + ...(landingPattern ? { landingPattern } : {}), + viewportIds: (surface.viewports ?? []).map((viewport) => viewport.id), + }, + visual: { + allowedFonts: surface.allowedFonts, + color: { + policy: contract.color.policy, + allowedValuesCount: contract.color.allowedValues.length, + }, + icons: surface.icons + ? { + policy: surface.icons.policy, + allowedSourcesCount: surface.icons.allowedSources.length, + } + : null, + motion: { + allowedDurationsMs: contract.constraints.motion.allowedDurationsMs, + allowedTimingFunctions: contract.constraints.motion.allowedTimingFunctions, + }, + typography: { + marketingProfileId: surface.marketingTypographyProfile ?? null, + marketingPolicy: surface.marketingTypographyPolicy ?? "off", + tokenPolicyCategories: Object.keys(contract.tokens ?? {}), + }, + }, + guidance: buildGuidance(contract, surface, sections), + refs: { + contract: "../../contract/normalized.json", + sections: "./sections.json", + components: "./components.json", + constraints: "./constraints.json", + ...(surface.authoring ? { authoring: "./authoring.json" } : {}), + repairMap: "./repair-map.json", + ...(observationRefs.length > 0 ? { evidence: observationRefs } : {}), + }, + }; +} +function buildAuthoringPayload(contract, surface) { + if (!surface.authoring) + return null; + return { + provenance: makeBundleProvenance(contract, surface.id), + authoring: { + ...surface.authoring, + sourcePriority: (surface.authoring.sourcePriority ?? []).map((source) => source), + }, + }; +} +function addRepair(repairs, code, priority, category, action) { + repairs.push({ code, priority, category, action }); +} +function buildRepairMapPayload(contract, surface, sections) { + const repairs = []; + const shellOwns = contract.shell?.owns ?? []; + const mustNotEmit = surface.mustNotEmit ?? []; + const prohibitedRoles = uniqueStrings([...shellOwns, ...mustNotEmit]); + if (prohibitedRoles.length > 0) { + addRepair(repairs, "shell.primitive.disallowed", "high", "boundary", { + type: "remove-prohibited-primitives", + prohibitedRoles, + ...(contract.shell?.contentSlot ? { contentSlot: contract.shell.contentSlot } : {}), + allowSources: surface.shellOwnedPrimitiveAllowSources ?? [], + }); + } + if (surface.requiredSections.length > 0) { + addRepair(repairs, "section.missing", "high", "structure", { + type: "ensure-required-sections", + sectionIds: surface.requiredSections, + }); + } + if (sections.length > 0) { + addRepair(repairs, "section.unexpected", "medium", "structure", { + type: "restrict-section-set", + allowedSectionIds: sections.map((section) => section.id), + }); + } + addRepair(repairs, "font.disallowed", "medium", "visual", { + type: "restrict-fonts", + allowedFonts: surface.allowedFonts, + }); + if (contract.color.policy !== "off" || contract.color.allowedValues.length > 0) { + addRepair(repairs, "color.disallowed", "medium", "visual", { + type: "restrict-colors", + policy: contract.color.policy, + allowedValues: contract.color.allowedValues, + }); + } + if (surface.icons && (surface.icons.policy !== "off" || surface.icons.allowedSources.length > 0)) { + addRepair(repairs, "icon.source-disallowed", "medium", "visual", { + type: "restrict-icon-sources", + policy: surface.icons.policy, + allowedSources: surface.icons.allowedSources, + }); + } + if (contract.tokens?.typography && contract.tokens.typography.policy !== "off") { + addRepair(repairs, "token.disallowed", "medium", "visual", { + type: "restrict-tokens", + tokenCategory: "typography", + policy: contract.tokens.typography.policy, + allowedTokens: contract.tokens.typography.allowedTokens, + }); + } + if (contract.tokens?.layout && contract.tokens.layout.policy !== "off") { + addRepair(repairs, "token.disallowed", "medium", "layout", { + type: "restrict-tokens", + tokenCategory: "layout", + policy: contract.tokens.layout.policy, + allowedTokens: contract.tokens.layout.allowedTokens, + }); + } + if (contract.tokens?.motion && contract.tokens.motion.policy !== "off") { + addRepair(repairs, "token.disallowed", "medium", "visual", { + type: "restrict-tokens", + tokenCategory: "motion", + policy: contract.tokens.motion.policy, + allowedTokens: contract.tokens.motion.allowedTokens, + }); + } + addRepair(repairs, "motion.duration", "medium", "visual", { + type: "restrict-motion-durations", + allowedDurationsMs: contract.constraints.motion.allowedDurationsMs, + }); + addRepair(repairs, "motion.timing", "medium", "visual", { + type: "restrict-motion-timing", + allowedTimingFunctions: contract.constraints.motion.allowedTimingFunctions, + }); + if (surface.layout.maxContentWidth !== undefined) { + addRepair(repairs, "layout.width-exceeded", "medium", "layout", { + type: "reduce-content-width", + maxContentWidth: surface.layout.maxContentWidth, + }); + } + if ((surface.layout.requiredContainers ?? []).length > 0) { + addRepair(repairs, "layout.container-missing", "medium", "layout", { + type: "ensure-required-containers", + requiredContainers: surface.layout.requiredContainers, + }); + } + if (surface.layout.pageFrame) { + addRepair(repairs, "layout.pageframe.maxwidth-mismatch", "medium", "layout", { + type: "align-page-frame", + pageFrame: surface.layout.pageFrame, + }); + addRepair(repairs, "layout.pageframe.padding-mismatch", "medium", "layout", { + type: "align-page-frame", + pageFrame: surface.layout.pageFrame, + }); + } + if (surface.layout.landingPattern && surface.layout.landingPattern.policy !== "off") { + addRepair(repairs, "landing.pattern.signal-missing", "high", "layout", { + type: "restore-landing-pattern-signals", + landingPattern: surface.layout.landingPattern, + }); + addRepair(repairs, "landing.pattern.top-level-missing", "high", "layout", { + type: "restore-top-level-sections", + sectionIds: surface.layout.landingPattern.requireTopLevelSections ?? [], + }); + addRepair(repairs, "landing.pattern.section-order", "medium", "layout", { + type: "restore-section-order", + sectionOrder: surface.layout.landingPattern.sectionOrder ?? [], + }); + addRepair(repairs, "landing.pattern.background-mode", "medium", "layout", { + type: "restore-page-background-mode", + pageBackgroundMode: surface.layout.landingPattern.pageBackgroundMode ?? null, + }); + } + if (surface.marketingTypographyProfile && surface.marketingTypographyPolicy !== "off") { + addRepair(repairs, "marketing.typography.profile-missing", "medium", "visual", { + type: "restore-marketing-typography-profile", + profileId: surface.marketingTypographyProfile, + policy: surface.marketingTypographyPolicy ?? "warn", + }); + } + if (surface.flows && surface.flows.policy !== "off") { + addRepair(repairs, "flow.required.missing", "high", "interaction", { + type: "restore-required-flows", + requirements: surface.flows.requirements, + }); + addRepair(repairs, "flow.steps.required", "medium", "interaction", { + type: "restore-required-flow-steps", + requirements: surface.flows.requirements, + }); + addRepair(repairs, "flow.transition.required", "medium", "interaction", { + type: "restore-required-transitions", + requirements: surface.flows.requirements, + }); + } + return { + provenance: makeBundleProvenance(contract, surface.id), + repairs, + }; +} +function buildSurfaceBundleFiles(contract, surface) { + const surfaceDir = `surfaces/${surface.id}`; + const sections = resolveSurfaceSections(contract, surface); + const components = resolveSurfaceComponents(contract, sections); + const constraintsPayload = buildConstraintsPayload(contract, surface); + const generationPayload = buildGenerationPayload(contract, surface, sections); + const sectionsPayload = buildSectionsPayload(contract, surface, sections); + const componentsPayload = buildComponentsPayload(contract, surface, components); + const repairMapPayload = buildRepairMapPayload(contract, surface, sections); + const authoringPayload = buildAuthoringPayload(contract, surface); + const files = [ + { + path: `${surfaceDir}/generation.json`, + content: stringifyDeterministic(generationPayload), + }, + { + path: `${surfaceDir}/sections.json`, + content: stringifyDeterministic(sectionsPayload), + }, + { + path: `${surfaceDir}/components.json`, + content: stringifyDeterministic(componentsPayload), + }, + { + path: `${surfaceDir}/constraints.json`, + content: stringifyDeterministic(constraintsPayload), + }, + { + path: `${surfaceDir}/repair-map.json`, + content: stringifyDeterministic(repairMapPayload), + }, + ]; + if (authoringPayload) { + files.push({ + path: `${surfaceDir}/authoring.json`, + content: stringifyDeterministic(authoringPayload), + }); + } + return files; +} export async function runCompileCommand(options, toolVersion) { const outDir = path.resolve(options.outDir); const contractInput = path.resolve(options.contractPath); @@ -97,25 +577,13 @@ export async function runCompileCommand(options, toolVersion) { } const contract = structureResult.contract; const { contract: normalizedContract } = normalizeContract(contract); - const contractContent = stringifyDeterministic(normalizedContract); const bundleFiles = [ - { path: "contract.normalized.json", content: contractContent }, + { + path: "contract/normalized.json", + content: stringifyDeterministic(normalizedContract), + }, + ...normalizedContract.surfaces.flatMap((surface) => buildSurfaceBundleFiles(normalizedContract, surface)), ]; - 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, @@ -146,15 +614,3 @@ export async function runCompileCommand(options, toolVersion) { } return 0; } -function surfaceToBundlePayload(surface) { - return { - id: surface.id, - displayName: surface.displayName, - type: surface.type, - requiredSections: surface.requiredSections, - allowedFonts: surface.allowedFonts, - layout: surface.layout, - ...(surface.icons ? { icons: surface.icons } : {}), - ...(surface.flows ? { flows: surface.flows } : {}), - }; -} diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts new file mode 100644 index 0000000..3d44f48 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts @@ -0,0 +1,7 @@ +export interface PrepareGenerationCommandOptions { + bundleRoot?: string; + surfaceId?: string; + outPath?: string; +} +export declare function runPrepareGenerationCommand(options: PrepareGenerationCommandOptions): Promise; +//# sourceMappingURL=prepare-generation.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map new file mode 100644 index 0000000..3571821 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"prepare-generation.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-generation.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,+BAA+B;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAkOD,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,MAAM,CAAC,CAgCjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.js b/packages/interfacectl-cli/dist/commands/prepare-generation.js new file mode 100644 index 0000000..c625fdf --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.js @@ -0,0 +1,225 @@ +import fs from "node:fs"; +import path from "node:path"; +import { AdapterInputError, isRecord, loadCompiledSurfaceBundle, } from "../adapter/bundle.js"; +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 key of Object.keys(value).sort()) { + sorted[key] = sortKeysRecursive(value[key]); + } + return sorted; + } + return value; +} +function stringifyDeterministic(value) { + return `${JSON.stringify(sortKeysRecursive(value), null, 2)}\n`; +} +function asRecord(value) { + return isRecord(value) ? value : {}; +} +function asString(value) { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} +function asStringArray(value) { + if (!Array.isArray(value)) + return []; + return value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); +} +function uniqueStrings(values) { + return [...new Set(values.filter(Boolean))]; +} +function getRepairPriorityRank(priority) { + switch (priority) { + case "high": + return 0; + case "medium": + return 1; + case "low": + return 2; + default: + return 3; + } +} +function getRepairSummary(repairMap) { + const repairs = Array.isArray(repairMap.repairs) ? repairMap.repairs : []; + return repairs + .filter((entry) => isRecord(entry)) + .map((entry) => { + const action = asRecord(entry.action); + return { + code: asString(entry.code) ?? "unknown", + priority: asString(entry.priority) ?? "medium", + category: asString(entry.category) ?? "unknown", + actionType: asString(action.type) ?? "unknown", + }; + }) + .sort((a, b) => getRepairPriorityRank(a.priority) - getRepairPriorityRank(b.priority) || + a.category.localeCompare(b.category) || + a.code.localeCompare(b.code)); +} +function buildSummary(bundle) { + const generation = asRecord(bundle.surface.generation.value); + const boundary = asRecord(generation.boundary); + const structure = asRecord(generation.structure); + const guidance = asRecord(generation.guidance); + const repairs = getRepairSummary(bundle.surface.repairMap.value); + const focusOrder = asStringArray(guidance.generationFocusOrder); + const requiredSectionIds = asStringArray(structure.requiredSectionIds); + const prohibitedRoles = uniqueStrings([ + ...asStringArray(guidance.doNotEmitRoles), + ...asStringArray(boundary.shellOwns), + ...asStringArray(boundary.mustNotEmit), + ]); + const checklist = []; + if (prohibitedRoles.length > 0) { + checklist.push({ + id: "boundary", + label: "Stay inside the surface boundary", + detail: `Do not emit shell-owned roles: ${prohibitedRoles.join(", ")}.`, + }); + } + if (requiredSectionIds.length > 0) { + checklist.push({ + id: "sections", + label: "Include every required section", + detail: `Required sections: ${requiredSectionIds.join(", ")}.`, + }); + } + if (focusOrder.length > 0) { + checklist.push({ + id: "focus-order", + label: "Follow the generation focus order", + detail: `Focus order: ${focusOrder.join(" -> ")}.`, + }); + } + if (repairs.length > 0) { + checklist.push({ + id: "repair-priorities", + label: "Use repair priorities when correcting drift", + detail: `Highest-priority repair codes: ${repairs + .slice(0, 3) + .map((repair) => repair.code) + .join(", ")}.`, + }); + } + const textParts = []; + if (focusOrder.length > 0) { + textParts.push(`Focus on ${focusOrder.join(", ")}`); + } + if (requiredSectionIds.length > 0) { + textParts.push(`include required sections ${requiredSectionIds.join(", ")}`); + } + if (prohibitedRoles.length > 0) { + textParts.push(`avoid shell-owned roles ${prohibitedRoles.join(", ")}`); + } + if (repairs.length > 0) { + textParts.push(`prioritize repairs ${repairs.slice(0, 3).map((repair) => repair.code).join(", ")}`); + } + return { + text: textParts.length > 0 ? `${textParts.join("; ")}.` : "Use the compiled surface bundle as the authoritative generation input.", + focusOrder, + requiredSectionIds, + prohibitedRoles, + checklist, + topRepairs: repairs.slice(0, 5), + }; +} +function buildPreparedGenerationPayload(bundle) { + const generation = asRecord(bundle.surface.generation.value); + const generationRefs = asRecord(generation.refs); + const identity = asRecord(generation.identity); + const sectionsDoc = asRecord(bundle.surface.sections.value); + const componentsDoc = asRecord(bundle.surface.components.value); + const constraintsDoc = asRecord(bundle.surface.constraints.value); + const repairMapDoc = asRecord(bundle.surface.repairMap.value); + const authoringDoc = bundle.surface.authoring + ? asRecord(bundle.surface.authoring.value) + : undefined; + return { + surface: { + surfaceId: asString(identity.surfaceId) ?? bundle.surface.id, + displayName: asString(identity.displayName) ?? bundle.surface.id, + type: asString(identity.type) ?? "unknown", + }, + bundle: { + root: bundle.root, + version: bundle.version, + manifestPath: bundle.manifest.path, + sourcePaths: { + contract: bundle.contract.path, + generation: bundle.surface.generation.path, + sections: bundle.surface.sections.path, + components: bundle.surface.components.path, + constraints: bundle.surface.constraints.path, + repairMap: bundle.surface.repairMap.path, + ...(bundle.surface.authoring ? { authoring: bundle.surface.authoring.path } : {}), + }, + }, + contract: { + id: bundle.contractId, + version: bundle.contractVersion, + normalizedPath: bundle.contract.path, + }, + summary: buildSummary(bundle), + generation: { + boundary: asRecord(generation.boundary), + structure: asRecord(generation.structure), + layout: asRecord(generation.layout), + visual: asRecord(generation.visual), + guidance: asRecord(generation.guidance), + }, + sections: Array.isArray(sectionsDoc.sections) ? sectionsDoc.sections : [], + components: Array.isArray(componentsDoc.components) ? componentsDoc.components : [], + constraints: asRecord(constraintsDoc.constraints), + repairMap: Array.isArray(repairMapDoc.repairs) ? repairMapDoc.repairs : [], + ...(authoringDoc && isRecord(authoringDoc.authoring) + ? { authoring: authoringDoc.authoring } + : {}), + evidenceRefs: Array.isArray(generationRefs.evidence) ? generationRefs.evidence : [], + }; +} +function writeError(error, code) { + process.stderr.write(`${JSON.stringify({ + status: "error", + code, + error: error.message, + }, null, 2)}\n`); +} +export async function runPrepareGenerationCommand(options) { + try { + if (!options.bundleRoot) { + throw new AdapterInputError("--bundle-root is required."); + } + if (!options.surfaceId) { + throw new AdapterInputError("--surface is required."); + } + const bundle = loadCompiledSurfaceBundle(options.bundleRoot, options.surfaceId, process.cwd()); + const payload = buildPreparedGenerationPayload(bundle); + const serialized = stringifyDeterministic(payload); + if (options.outPath) { + const outPath = path.resolve(options.outPath); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, serialized, "utf8"); + return 0; + } + process.stdout.write(serialized); + return 0; + } + catch (error) { + if (error instanceof AdapterInputError) { + writeError(error, error.code); + return 10; + } + const internalError = error instanceof Error ? error : new Error(String(error)); + writeError(internalError, "prepare-generation.internal"); + return 1; + } +} diff --git a/packages/interfacectl-cli/dist/commands/serve-generation-adapter.d.ts b/packages/interfacectl-cli/dist/commands/serve-generation-adapter.d.ts new file mode 100644 index 0000000..c07b438 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/serve-generation-adapter.d.ts @@ -0,0 +1,18 @@ +import http from "node:http"; +export interface ServeGenerationAdapterCommandOptions { + host?: string; + port?: number; + token?: string; + tokenHeader?: string; + bundleRoot?: string; + descriptorParityConfigPath?: string; +} +export declare function createGenerationAdapterServer(config?: { + host?: string; + token?: string; + tokenHeader?: string; + bundleRoot?: string; + descriptorParityConfigPath?: string; +}): http.Server; +export declare function runServeGenerationAdapterCommand(options: ServeGenerationAdapterCommandOptions): Promise; +//# sourceMappingURL=serve-generation-adapter.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/serve-generation-adapter.d.ts.map b/packages/interfacectl-cli/dist/commands/serve-generation-adapter.d.ts.map new file mode 100644 index 0000000..099509b --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/serve-generation-adapter.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"serve-generation-adapter.d.ts","sourceRoot":"","sources":["../../src/commands/serve-generation-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAc7B,MAAM,WAAW,oCAAoC;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC;AAiED,wBAAgB,6BAA6B,CAAC,MAAM,GAAE;IACpD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0BAA0B,CAAC,EAAE,MAAM,CAAC;CAChC,wEAmFL;AAED,wBAAsB,gCAAgC,CACpD,OAAO,EAAE,oCAAoC,GAC5C,OAAO,CAAC,IAAI,CAAC,CA6Cf"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/serve-generation-adapter.js b/packages/interfacectl-cli/dist/commands/serve-generation-adapter.js new file mode 100644 index 0000000..310f8f5 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/serve-generation-adapter.js @@ -0,0 +1,169 @@ +import http from "node:http"; +import { URL } from "node:url"; +import { AdapterInputError, runGenerationAdapter, } from "../adapter/core.js"; +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 7777; +const DEFAULT_TOKEN_HEADER = "x-surfaces-adapter-token"; +const MAX_REQUEST_BYTES = 1_000_000; +function isObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} +function redactSecrets(value) { + if (Array.isArray(value)) { + return value.map((entry) => redactSecrets(entry)); + } + if (!isObject(value)) { + return value; + } + const output = {}; + for (const [key, entry] of Object.entries(value)) { + if (/token|secret|password|authorization|api[_-]?key/i.test(key)) { + output[key] = "[REDACTED]"; + continue; + } + output[key] = redactSecrets(entry); + } + return output; +} +function sendJson(res, statusCode, payload) { + const body = `${JSON.stringify(payload, null, 2)}\n`; + res.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(body), + }); + res.end(body); +} +async function readBody(req) { + const chunks = []; + let bytes = 0; + for await (const chunk of req) { + bytes += chunk.length; + if (bytes > MAX_REQUEST_BYTES) { + throw new AdapterInputError(`Request body exceeds ${MAX_REQUEST_BYTES} bytes.`); + } + chunks.push(chunk); + } + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + throw new AdapterInputError("Request body must be valid JSON."); + } + try { + const parsed = JSON.parse(raw); + if (!isObject(parsed)) { + throw new Error("Request body must be a JSON object."); + } + return parsed; + } + catch (error) { + throw new AdapterInputError(`Invalid JSON body: ${error.message}`); + } +} +export function createGenerationAdapterServer(config = {}) { + const host = config.host ?? DEFAULT_HOST; + const token = config.token; + const tokenHeader = (config.tokenHeader ?? DEFAULT_TOKEN_HEADER).toLowerCase(); + const defaultBundleRoot = config.bundleRoot; + const runtimeOptions = { + cwd: process.cwd(), + defaultBundleRoot, + descriptorParityConfigPath: config.descriptorParityConfigPath, + }; + if (host !== "127.0.0.1" && !token) { + throw new AdapterInputError("Non-local bind requires a token. Set --token or SURFACES_ADAPTER_TOKEN."); + } + return http.createServer(async (req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host || host}`); + if (req.method !== "POST" || requestUrl.pathname !== "/surfaces.validateGeneration") { + sendJson(res, 404, { + error: "Not found", + path: requestUrl.pathname, + }); + return; + } + if (token) { + const providedToken = req.headers[tokenHeader]; + const headerValue = Array.isArray(providedToken) + ? providedToken[0] + : providedToken; + if (headerValue !== token) { + sendJson(res, 401, { + error: "Unauthorized", + code: "adapter.auth", + }); + return; + } + } + const payload = await readBody(req); + const requestPayload = { + ...payload, + ...(defaultBundleRoot ? { bundleRoot: defaultBundleRoot } : {}), + }; + const response = await runGenerationAdapter(requestPayload, runtimeOptions); + const statusCode = response.status === "block" ? 422 : 200; + sendJson(res, statusCode, response); + console.log(JSON.stringify({ + event: "surfaces.validateGeneration", + status: response.status, + requestId: response.requestId, + tool: payload.tool, + surfaceId: payload.surfaceId, + findings: response.findings.length, + timings: response.timings, + payload: redactSecrets({ + mode: payload.mode, + provenance: payload.provenance, + }), + })); + } + catch (error) { + if (error instanceof AdapterInputError) { + sendJson(res, 400, { + error: error.message, + code: error.code, + }); + return; + } + sendJson(res, 500, { + error: error instanceof Error ? error.message : String(error), + code: "adapter.internal", + }); + } + }); +} +export async function runServeGenerationAdapterCommand(options) { + if (!options.bundleRoot) { + throw new AdapterInputError("--bundle-root is required."); + } + const host = options.host ?? process.env.SURFACES_ADAPTER_HOST ?? DEFAULT_HOST; + const portRaw = options.port ?? Number(process.env.SURFACES_ADAPTER_PORT ?? DEFAULT_PORT); + const port = Number(portRaw); + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new AdapterInputError(`Invalid port: ${portRaw}`); + } + const token = options.token ?? process.env.SURFACES_ADAPTER_TOKEN; + const tokenHeader = options.tokenHeader ?? + (process.env.SURFACES_ADAPTER_TOKEN_HEADER || DEFAULT_TOKEN_HEADER).toLowerCase(); + const server = createGenerationAdapterServer({ + host, + token, + tokenHeader, + bundleRoot: options.bundleRoot, + descriptorParityConfigPath: options.descriptorParityConfigPath, + }); + await new Promise((resolve) => { + server.listen(port, host, () => { + const address = server.address(); + const displayPort = typeof address === "object" && address ? address.port : port; + console.log(`Generation adapter listening on http://${host}:${displayPort}/surfaces.validateGeneration`); + resolve(); + }); + }); + const shutdown = () => { + server.close(() => { + process.exit(0); + }); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} diff --git a/packages/interfacectl-cli/dist/commands/validate-generation.d.ts b/packages/interfacectl-cli/dist/commands/validate-generation.d.ts new file mode 100644 index 0000000..0826a7c --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/validate-generation.d.ts @@ -0,0 +1,13 @@ +export interface ValidateGenerationCommandOptions { + tool?: string; + surfaceId?: string; + mode?: string; + bundleRoot?: string; + workspaceRoot?: string; + descriptorPath?: string; + outPath?: string; + requestId?: string; + descriptorParityConfigPath?: string; +} +export declare function runValidateGenerationCommand(options: ValidateGenerationCommandOptions): Promise; +//# sourceMappingURL=validate-generation.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate-generation.d.ts.map b/packages/interfacectl-cli/dist/commands/validate-generation.d.ts.map new file mode 100644 index 0000000..4db768b --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/validate-generation.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"validate-generation.d.ts","sourceRoot":"","sources":["../../src/commands/validate-generation.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,gCAAgC;IAC/C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC;AA0BD,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,gCAAgC,GACxC,OAAO,CAAC,MAAM,CAAC,CA+FjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate-generation.js b/packages/interfacectl-cli/dist/commands/validate-generation.js new file mode 100644 index 0000000..a633f9f --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/validate-generation.js @@ -0,0 +1,85 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { AdapterInputError, runGenerationAdapter, } from "../adapter/core.js"; +function readDescriptor(descriptorPath) { + const resolvedPath = path.resolve(descriptorPath); + if (!fs.existsSync(resolvedPath)) { + throw new AdapterInputError(`Descriptor file not found: ${resolvedPath}.`); + } + try { + const parsed = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + if (!Array.isArray(parsed)) { + throw new AdapterInputError(`Descriptor file must contain a JSON array: ${resolvedPath}.`); + } + return parsed; + } + catch (error) { + if (error instanceof AdapterInputError) { + throw error; + } + throw new AdapterInputError(`Failed to read descriptor JSON at ${resolvedPath}: ${error.message}`); + } +} +export async function runValidateGenerationCommand(options) { + const request = { + requestId: options.requestId ?? crypto.randomUUID(), + tool: options.tool, + surfaceId: options.surfaceId, + mode: options.mode, + bundleRoot: options.bundleRoot, + provenance: { + timestamp: new Date().toISOString(), + sessionId: process.env.SURFACES_ADAPTER_SESSION_ID, + userId: process.env.SURFACES_ADAPTER_USER_ID, + }, + }; + if (options.mode === "workspace") { + request.workspaceRoot = + typeof options.workspaceRoot === "string" + ? path.resolve(options.workspaceRoot) + : undefined; + } + else if (options.mode === "descriptor") { + if (!options.descriptorPath) { + throw new AdapterInputError("--descriptor-path is required when --mode descriptor."); + } + request.descriptor = readDescriptor(options.descriptorPath); + } + if (options.mode === "workspace" && !options.workspaceRoot) { + throw new AdapterInputError("--workspace-root is required when --mode workspace."); + } + const runtimeOptions = { + cwd: process.cwd(), + descriptorParityConfigPath: options.descriptorParityConfigPath, + }; + try { + const response = await runGenerationAdapter(request, runtimeOptions); + const serialized = `${JSON.stringify(response, null, 2)}\n`; + if (options.outPath) { + const outPath = path.resolve(options.outPath); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, serialized, "utf8"); + } + process.stdout.write(serialized); + return response.status === "block" ? 30 : 0; + } + catch (error) { + if (error instanceof AdapterInputError) { + process.stderr.write(`${JSON.stringify({ + requestId: request.requestId, + status: "block", + error: error.message, + code: error.code, + }, null, 2)}\n`); + return 10; + } + process.stderr.write(`${JSON.stringify({ + requestId: request.requestId, + status: "block", + error: error instanceof Error ? error.message : String(error), + code: "adapter.internal", + }, null, 2)}\n`); + return 1; + } +} diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index 61ee1f9..cc26317 100644 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -9,6 +9,9 @@ import { runGenerateContractCommand } from "./commands/generate-contract.js"; import { runMigrateColorPolicyCommand } from "./commands/migrate-color-policy.js"; import { runValidateExtractedCommand } from "./commands/validate-extracted.js"; import { runDescribeCommand } from "./commands/describe.js"; +import { runPrepareGenerationCommand } from "./commands/prepare-generation.js"; +import { runValidateGenerationCommand } from "./commands/validate-generation.js"; +import { runServeGenerationAdapterCommand } from "./commands/serve-generation-adapter.js"; import { runInitCommand } from "./commands/init.js"; import { runAnalyzeCommand } from "./commands/analyze.js"; import { runAuthCaptureCommand, runAuthClearCommand, runAuthListCommandWithOptions, runAuthTestCommand, } from "./commands/auth.js"; @@ -189,6 +192,63 @@ program }, pkg.version ?? "0.0.0"); process.exitCode = exitCode; }); +program + .command("prepare-generation") + .description("Resolve a compiled generation bundle into one agent-ready JSON payload") + .requiredOption("--bundle-root ", "Path to the compiled generation bundle directory") + .requiredOption("--surface ", "Surface identifier") + .option("--out ", "Write the prepared JSON payload to the provided file") + .action(async (options) => { + process.exitCode = await runPrepareGenerationCommand({ + bundleRoot: options.bundleRoot, + surfaceId: options.surface, + outPath: options.out, + }); +}); +program + .command("validate-generation") + .description("Validate generated UI against a compiled generation bundle") + .requiredOption("--tool ", "Generator tool identifier") + .requiredOption("--surface ", "Surface identifier") + .requiredOption("--mode ", "Validation mode") + .requiredOption("--bundle-root ", "Path to the compiled generation bundle directory") + .option("--workspace-root ", "Required when mode=workspace") + .option("--descriptor-path ", "Required when mode=descriptor") + .option("--descriptor-parity-config ", "Optional descriptor parity config path") + .option("--out ", "Write output JSON to the provided file") + .option("--request-id ", "Optional request identifier") + .action(async (options) => { + process.exitCode = await runValidateGenerationCommand({ + tool: options.tool, + surfaceId: options.surface, + mode: options.mode, + bundleRoot: options.bundleRoot, + workspaceRoot: options.workspaceRoot, + descriptorPath: options.descriptorPath, + descriptorParityConfigPath: options.descriptorParityConfig, + outPath: options.out, + requestId: options.requestId, + }); +}); +program + .command("serve-generation-adapter") + .description("Serve the generation adapter over HTTP for a compiled generation bundle") + .requiredOption("--bundle-root ", "Path to the compiled generation bundle directory") + .option("--host ", "Bind host (default: 127.0.0.1)") + .option("--port ", "Bind port (default: 7777)") + .option("--token ", "Optional auth token") + .option("--token-header ", "Token header name (default: x-surfaces-adapter-token)") + .option("--descriptor-parity-config ", "Optional descriptor parity config path") + .action(async (options) => { + await runServeGenerationAdapterCommand({ + host: options.host, + port: options.port ? Number(options.port) : undefined, + token: options.token, + tokenHeader: options.tokenHeader, + bundleRoot: options.bundleRoot, + descriptorParityConfigPath: options.descriptorParityConfig, + }); +}); program .command("generate-contract") .description("Extract a contract from a Next.js app (Phase 0: extraction only)") diff --git a/packages/interfacectl-cli/schemas/generation-adapter-request.schema.json b/packages/interfacectl-cli/schemas/generation-adapter-request.schema.json new file mode 100644 index 0000000..b2085f4 --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-adapter-request.schema.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/generation-adapter-request.schema.json", + "title": "GenerationAdapterRequest", + "type": "object", + "additionalProperties": false, + "required": ["requestId", "tool", "surfaceId", "mode", "bundleRoot"], + "properties": { + "requestId": { + "type": "string", + "minLength": 1 + }, + "tool": { + "type": "string", + "enum": ["codex", "cursor", "lovable", "figma-make"] + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "mode": { + "type": "string", + "enum": ["workspace", "descriptor"] + }, + "bundleRoot": { + "type": "string", + "minLength": 1 + }, + "workspaceRoot": { + "type": "string", + "minLength": 1 + }, + "descriptor": { + "type": "array", + "items": { + "$ref": "#/$defs/descriptorEntry" + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "properties": { + "sessionId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { "const": "workspace" } + } + }, + "then": { + "required": ["workspaceRoot"] + } + }, + { + "if": { + "properties": { + "mode": { "const": "descriptor" } + } + }, + "then": { + "required": ["descriptor"] + } + } + ], + "$defs": { + "descriptorEntry": { + "type": "object", + "additionalProperties": true, + "required": ["surfaceId", "primitives", "colors"], + "properties": { + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "primitives": { + "type": "array", + "items": { + "type": "object" + } + }, + "colors": { + "type": "array", + "items": { + "type": "object" + } + }, + "icons": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["value"], + "properties": { + "value": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/packages/interfacectl-cli/schemas/generation-adapter-response.schema.json b/packages/interfacectl-cli/schemas/generation-adapter-response.schema.json new file mode 100644 index 0000000..5a0bfc7 --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-adapter-response.schema.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/generation-adapter-response.schema.json", + "title": "GenerationAdapterResponse", + "type": "object", + "additionalProperties": false, + "required": ["requestId", "status", "surfaceId", "bundle", "contract", "coverage", "findings", "timings", "provenance"], + "properties": { + "requestId": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": ["pass", "warn", "block"] + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "bundle": { + "type": "object", + "additionalProperties": false, + "required": ["root", "version", "manifestPath", "surfacePath"], + "properties": { + "root": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "manifestPath": { + "type": "string", + "minLength": 1 + }, + "surfacePath": { + "type": "string", + "minLength": 1 + } + } + }, + "contract": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": ["generationGuard", "fullValidate", "shellBoundaryEvaluated", "colorPolicyEvaluated", "iconPolicyEvaluated"], + "properties": { + "generationGuard": { "type": "boolean" }, + "fullValidate": { "type": "boolean" }, + "shellBoundaryEvaluated": { "type": "boolean" }, + "colorPolicyEvaluated": { "type": "boolean" }, + "iconPolicyEvaluated": { "type": "boolean" } + } + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "severity", "policy", "message", "location", "evidence"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "severity": { "type": "string", "enum": ["error", "warning"] }, + "policy": { "type": "string", "enum": ["strict", "warn", "off"] }, + "message": { "type": "string", "minLength": 1 }, + "location": { + "type": "object", + "additionalProperties": false, + "required": ["file", "line"], + "properties": { + "file": { "type": "string" }, + "line": { "type": "integer", "minimum": 0 } + } + }, + "evidence": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "timings": { + "type": "object", + "additionalProperties": false, + "required": ["totalMs"], + "properties": { + "totalMs": { + "type": "number", + "minimum": 0 + } + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["timestamp", "evaluatedAt"], + "properties": { + "sessionId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "evaluatedAt": { + "type": "string", + "format": "date-time" + } + } + } + } +} diff --git a/packages/interfacectl-cli/src/adapter/bundle.ts b/packages/interfacectl-cli/src/adapter/bundle.ts new file mode 100644 index 0000000..8e2dd3d --- /dev/null +++ b/packages/interfacectl-cli/src/adapter/bundle.ts @@ -0,0 +1,216 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const SUPPORTED_BUNDLE_VERSION = "2.0"; + +export interface JsonRecord { + [key: string]: unknown; +} + +export interface BundleManifest extends JsonRecord { + bundleVersion?: string; + contractId?: string; + contractVersion?: string; +} + +export interface LoadedJsonFile { + path: string; + value: T; +} + +export interface LoadedCompiledSurfaceBundle { + root: string; + version: string; + contractId: string; + contractVersion: string; + manifest: LoadedJsonFile; + contract: LoadedJsonFile; + surface: { + id: string; + dir: string; + generation: LoadedJsonFile; + sections: LoadedJsonFile; + components: LoadedJsonFile; + constraints: LoadedJsonFile; + repairMap: LoadedJsonFile; + authoring?: LoadedJsonFile; + }; +} + +export class AdapterInputError extends Error { + code: string; + meta?: Record; + + constructor(message: string, details: { code?: string; meta?: Record } = {}) { + super(message); + this.name = "AdapterInputError"; + this.code = details.code ?? "adapter.input"; + this.meta = details.meta; + } +} + +export function isAdapterInputError(error: unknown): error is AdapterInputError { + return error instanceof AdapterInputError; +} + +export function isRecord(value: unknown): value is JsonRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function readJsonFile(filePath: string, label: string): T { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + throw new Error(`${label} JSON must be an object.`); + } + return parsed as T; + } catch (error) { + throw new AdapterInputError( + `Failed to read ${label} JSON at ${filePath}: ${(error as Error).message}`, + ); + } +} + +export function ensureReadableFile(filePath: string, label: string): void { + if (!fs.existsSync(filePath)) { + throw new AdapterInputError(`${label} file not found at ${filePath}.`); + } + if (!fs.statSync(filePath).isFile()) { + throw new AdapterInputError(`${label} path is not a file: ${filePath}.`); + } +} + +export function ensureReadableDirectory(dirPath: string, label: string): void { + if (!fs.existsSync(dirPath)) { + throw new AdapterInputError(`${label} directory not found at ${dirPath}.`); + } + if (!fs.statSync(dirPath).isDirectory()) { + throw new AdapterInputError(`${label} path is not a directory: ${dirPath}.`); + } +} + +function getNestedString(record: JsonRecord | undefined, key: string): string | undefined { + if (!record) return undefined; + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function loadCompiledSurfaceBundle( + bundleRootInput: string, + surfaceId: string, + cwd: string, +): LoadedCompiledSurfaceBundle { + const bundleRoot = path.resolve(cwd, bundleRootInput); + ensureReadableDirectory(bundleRoot, "Bundle root"); + + const manifestPath = path.join(bundleRoot, "manifest.json"); + ensureReadableFile(manifestPath, "Bundle manifest"); + const manifest = readJsonFile(manifestPath, "bundle manifest"); + if (manifest.bundleVersion !== SUPPORTED_BUNDLE_VERSION) { + throw new AdapterInputError( + `Unsupported bundle version "${manifest.bundleVersion ?? "unknown"}". Expected ${SUPPORTED_BUNDLE_VERSION}.`, + { code: "adapter.bundle.version-unsupported" }, + ); + } + + const surfaceDir = path.join(bundleRoot, "surfaces", surfaceId); + ensureReadableDirectory(surfaceDir, "Surface bundle"); + + const generationPath = path.join(surfaceDir, "generation.json"); + const sectionsPath = path.join(surfaceDir, "sections.json"); + const componentsPath = path.join(surfaceDir, "components.json"); + const constraintsPath = path.join(surfaceDir, "constraints.json"); + const repairMapPath = path.join(surfaceDir, "repair-map.json"); + + for (const [filePath, label] of [ + [generationPath, "generation entrypoint"], + [sectionsPath, "sections bundle"], + [componentsPath, "components bundle"], + [constraintsPath, "constraints bundle"], + [repairMapPath, "repair map"], + ] as const) { + ensureReadableFile(filePath, label); + } + + const generation = { + path: generationPath, + value: readJsonFile(generationPath, "generation entrypoint"), + }; + const sections = { + path: sectionsPath, + value: readJsonFile(sectionsPath, "sections bundle"), + }; + const components = { + path: componentsPath, + value: readJsonFile(componentsPath, "components bundle"), + }; + const constraints = { + path: constraintsPath, + value: readJsonFile(constraintsPath, "constraints bundle"), + }; + const repairMap = { + path: repairMapPath, + value: readJsonFile(repairMapPath, "repair map"), + }; + + const refs = isRecord(generation.value.refs) ? generation.value.refs : {}; + const contractRef = + typeof refs.contract === "string" && refs.contract.trim().length > 0 + ? refs.contract + : "../../contract/normalized.json"; + const contractPath = path.resolve(path.dirname(generationPath), contractRef); + ensureReadableFile(contractPath, "Compiled contract"); + const contract = { + path: contractPath, + value: readJsonFile(contractPath, "Compiled contract"), + }; + + let authoring: LoadedJsonFile | undefined; + if (typeof refs.authoring === "string" && refs.authoring.trim().length > 0) { + const authoringPath = path.resolve(path.dirname(generationPath), refs.authoring); + ensureReadableFile(authoringPath, "Authoring bundle"); + authoring = { + path: authoringPath, + value: readJsonFile(authoringPath, "Authoring bundle"), + }; + } + + const generationProvenance = isRecord(generation.value.provenance) + ? generation.value.provenance + : undefined; + const contractId = + typeof manifest.contractId === "string" && manifest.contractId.length > 0 + ? manifest.contractId + : getNestedString(generationProvenance, "contractId") ?? + getNestedString(contract.value, "contractId") ?? + "unknown"; + const contractVersion = + typeof manifest.contractVersion === "string" && manifest.contractVersion.length > 0 + ? manifest.contractVersion + : getNestedString(generationProvenance, "contractVersion") ?? + getNestedString(contract.value, "version") ?? + "unknown"; + + return { + root: bundleRoot, + version: SUPPORTED_BUNDLE_VERSION, + contractId, + contractVersion, + manifest: { + path: manifestPath, + value: manifest, + }, + contract, + surface: { + id: surfaceId, + dir: surfaceDir, + generation, + sections, + components, + constraints, + repairMap, + ...(authoring ? { authoring } : {}), + }, + }; +} diff --git a/packages/interfacectl-cli/src/adapter/core.ts b/packages/interfacectl-cli/src/adapter/core.ts new file mode 100644 index 0000000..6981f4d --- /dev/null +++ b/packages/interfacectl-cli/src/adapter/core.ts @@ -0,0 +1,975 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + AdapterInputError, + ensureReadableFile, + isRecord, + loadCompiledSurfaceBundle, + readJsonFile, + type JsonRecord, +} from "./bundle.js"; +import { runDescribeCommand } from "../commands/describe.js"; +import { runValidateCommand } from "../commands/validate.js"; + +export { AdapterInputError, isAdapterInputError } from "./bundle.js"; + +const VALID_TOOLS = new Set(["codex", "cursor", "lovable", "figma-make"]); +const VALID_MODES = new Set(["workspace", "descriptor"]); +const DEFAULT_DESCRIPTOR_PARITY_CONFIG = + "contracts/generation-descriptor-parity.json"; + +const PARITY_FINDING_CODES = new Set([ + "color.disallowed", + "icon.source-disallowed", + "descriptor.icons.missing", +]); + +const VALIDATE_CODE_MAP: Record = { + "color.raw-value.used": "color.rawValues", + "color.token.namespace.violation": "color.token.namespace", + "shell.primitive.disallowed": "shell-owned-primitive-emitted", +}; + +const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_GUARD_PATH = path.resolve( + MODULE_DIR, + "../../../../tools/check-generation-boundaries.mjs", +); + +type FindingSeverity = "error" | "warning"; +type FindingPolicy = "strict" | "warn" | "off"; +type AdapterMode = "workspace" | "descriptor"; + +export interface AdapterFinding { + code: string; + severity: FindingSeverity; + policy: FindingPolicy; + message: string; + location: { + file: string; + line: number; + }; + evidence: Record; +} + +export interface GenerationAdapterRequest { + requestId?: string; + tool?: string; + surfaceId?: string; + mode?: string; + bundleRoot?: string; + workspaceRoot?: string; + descriptor?: JsonRecord[]; + provenance?: { + sessionId?: string; + userId?: string; + timestamp?: string; + }; + contractPath?: string; +} + +interface NormalizedGenerationAdapterRequest { + requestId: string; + tool: string; + surfaceId: string; + mode: AdapterMode; + bundleRoot: string; + workspaceRoot?: string; + descriptor?: JsonRecord[]; + provenance: { + sessionId?: string; + userId?: string; + timestamp: string; + }; +} + +interface BundleSurfaceRefs { + contractPath: string; + generationPath: string; + sectionsPath: string; + componentsPath: string; + constraintsPath: string; + repairMapPath: string; + authoringPath?: string; + manifestPath: string; +} + +interface BundleInfo extends BundleSurfaceRefs { + root: string; + version: string; + contractId: string; + contractVersion: string; +} + +interface RuntimeContext { + cwd: string; + bundle: BundleInfo; + guardPath: string; + nodeBin: string; + env: NodeJS.ProcessEnv; + descriptorParityConfigPath?: string; +} + +interface GuardEvaluation { + shellBoundaryEvaluated: boolean; + colorPolicyEvaluated: boolean; + iconPolicyEvaluated: boolean; +} + +export interface GenerationAdapterResponse { + requestId: string; + status: "pass" | "warn" | "block"; + surfaceId: string; + bundle: { + root: string; + version: string; + manifestPath: string; + surfacePath: string; + }; + contract: { + id: string; + version: string; + }; + coverage: { + generationGuard: boolean; + fullValidate: boolean; + shellBoundaryEvaluated: boolean; + colorPolicyEvaluated: boolean; + iconPolicyEvaluated: boolean; + }; + findings: AdapterFinding[]; + timings: { + totalMs: number; + }; + provenance: { + sessionId?: string; + userId?: string; + timestamp: string; + evaluatedAt: string; + }; +} + +export interface RunGenerationAdapterOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + nodeBin?: string; + generationGuardPath?: string; + descriptorParityConfigPath?: string; + descriptorParitySurfaces?: string[]; + defaultBundleRoot?: string; +} + +function resolvePathOrDefault(value: string | undefined, fallback: string | undefined, cwd: string) { + const candidate = value ?? fallback; + if (!candidate) return undefined; + return path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate); +} + + +function resolveGuardPath(options: RunGenerationAdapterOptions, cwd: string): string { + const env = options.env ?? process.env; + const explicit = resolvePathOrDefault( + options.generationGuardPath ?? + env.SURFACES_INTERFACECTL_GENERATION_GUARD ?? + env.SURFACES_ADAPTER_GENERATION_GUARD, + undefined, + cwd, + ); + if (explicit) { + ensureReadableFile(explicit, "Generation guard"); + return explicit; + } + + ensureReadableFile(DEFAULT_GUARD_PATH, "Generation guard"); + return DEFAULT_GUARD_PATH; +} + +function normalizeAdapterRequest( + input: GenerationAdapterRequest, + options: RunGenerationAdapterOptions = {}, +): NormalizedGenerationAdapterRequest { + if (!isRecord(input)) { + throw new AdapterInputError("Adapter request must be an object."); + } + + if (typeof input.contractPath === "string" && input.contractPath.trim().length > 0) { + throw new AdapterInputError( + "contractPath is no longer supported. Use bundleRoot instead.", + { code: "adapter.input.legacy-contract-path" }, + ); + } + + const cwd = options.cwd ?? process.cwd(); + const requestId = + typeof input.requestId === "string" && input.requestId.trim().length > 0 + ? input.requestId.trim() + : crypto.randomUUID(); + + const tool = + typeof input.tool === "string" ? input.tool.trim().toLowerCase() : ""; + if (!VALID_TOOLS.has(tool)) { + throw new AdapterInputError( + `Invalid tool "${input.tool ?? ""}". Expected one of: ${[...VALID_TOOLS].join(", ")}.`, + ); + } + + const surfaceId = + typeof input.surfaceId === "string" ? input.surfaceId.trim() : ""; + if (!surfaceId) { + throw new AdapterInputError("surfaceId is required."); + } + + const mode = + typeof input.mode === "string" ? input.mode.trim() : ""; + if (!VALID_MODES.has(mode)) { + throw new AdapterInputError( + `Invalid mode "${input.mode ?? ""}". Expected workspace|descriptor.`, + ); + } + + const bundleRootInput = + typeof input.bundleRoot === "string" && input.bundleRoot.trim().length > 0 + ? input.bundleRoot.trim() + : options.defaultBundleRoot; + if (!bundleRootInput) { + throw new AdapterInputError("bundleRoot is required."); + } + const bundleRoot = path.resolve(cwd, bundleRootInput); + + let workspaceRoot: string | undefined; + if (mode === "workspace") { + const rawWorkspaceRoot = + typeof input.workspaceRoot === "string" && input.workspaceRoot.trim().length > 0 + ? input.workspaceRoot.trim() + : undefined; + if (!rawWorkspaceRoot) { + throw new AdapterInputError( + "workspaceRoot is required when mode=workspace.", + ); + } + workspaceRoot = path.resolve(cwd, rawWorkspaceRoot); + } + + let descriptor: JsonRecord[] | undefined; + if (mode === "descriptor") { + if (!Array.isArray(input.descriptor)) { + throw new AdapterInputError( + "descriptor must be an array when mode=descriptor.", + ); + } + descriptor = input.descriptor.map((entry) => { + if (!isRecord(entry)) { + throw new AdapterInputError("descriptor entries must be JSON objects."); + } + return entry; + }); + } + + const provenanceInput = isRecord(input.provenance) ? input.provenance : {}; + const provenance = { + sessionId: + typeof provenanceInput.sessionId === "string" + ? provenanceInput.sessionId + : undefined, + userId: + typeof provenanceInput.userId === "string" + ? provenanceInput.userId + : undefined, + timestamp: + typeof provenanceInput.timestamp === "string" + ? provenanceInput.timestamp + : new Date().toISOString(), + }; + + return { + requestId, + tool, + surfaceId, + mode: mode as AdapterMode, + bundleRoot, + workspaceRoot, + descriptor, + provenance, + }; +} + +function resolveBundleSurface( + request: NormalizedGenerationAdapterRequest, + cwd: string, +): BundleInfo { + const bundle = loadCompiledSurfaceBundle(request.bundleRoot, request.surfaceId, cwd); + + return { + root: bundle.root, + version: bundle.version, + contractId: bundle.contractId, + contractVersion: bundle.contractVersion, + manifestPath: bundle.manifest.path, + contractPath: bundle.contract.path, + generationPath: bundle.surface.generation.path, + sectionsPath: bundle.surface.sections.path, + componentsPath: bundle.surface.components.path, + constraintsPath: bundle.surface.constraints.path, + repairMapPath: bundle.surface.repairMap.path, + ...(bundle.surface.authoring ? { authoringPath: bundle.surface.authoring.path } : {}), + }; +} + +function resolveRuntime( + request: NormalizedGenerationAdapterRequest, + options: RunGenerationAdapterOptions = {}, +): RuntimeContext { + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + const descriptorParityConfigPath = resolvePathOrDefault( + options.descriptorParityConfigPath ?? + env.SURFACES_ADAPTER_DESCRIPTOR_PARITY_CONFIG ?? + DEFAULT_DESCRIPTOR_PARITY_CONFIG, + undefined, + cwd, + ); + + return { + cwd, + bundle: resolveBundleSurface(request, cwd), + guardPath: resolveGuardPath(options, cwd), + nodeBin: options.nodeBin ?? process.execPath, + env, + descriptorParityConfigPath, + }; +} + +function runNodeScript(runtime: RuntimeContext, scriptPath: string, args: string[]) { + const result = spawnSync(runtime.nodeBin, [scriptPath, ...args], { + cwd: runtime.cwd, + env: runtime.env, + encoding: "utf8", + }); + + if (result.error) { + throw new Error(`Failed to run ${scriptPath}: ${result.error.message}`); + } + + return { + status: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +function makeTempJsonFile(prefix: string, fileName: string, payload: unknown) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + const filePath = path.join(tempDir, fileName); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + return { tempDir, filePath }; +} + +function makeTempEmptyFile(prefix: string, fileName: string) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + return { tempDir, filePath: path.join(tempDir, fileName) }; +} + +function cleanupTempPath(tempDir?: string) { + if (!tempDir) return; + fs.rmSync(tempDir, { recursive: true, force: true }); +} + +function parseCsvList(value: unknown): string[] { + if (!value) return []; + return String(value) + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function normalizeGuardFinding(entry: unknown): AdapterFinding | null { + if (!isRecord(entry)) { + return null; + } + + const code = typeof entry.code === "string" ? entry.code.trim() : ""; + if (!code) { + return null; + } + + const severity: FindingSeverity = + entry.severity === "error" ? "error" : "warning"; + const policy: FindingPolicy = + entry.policy === "strict" || entry.policy === "off" ? entry.policy : "warn"; + const message = + typeof entry.message === "string" && entry.message.trim().length > 0 + ? entry.message + : code; + const location = isRecord(entry.location) ? entry.location : {}; + + return { + code, + severity, + policy, + message, + location: { + file: typeof location.file === "string" ? location.file : "", + line: Number.isInteger(location.line) ? (location.line as number) : 0, + }, + evidence: isRecord(entry.evidence) ? entry.evidence : {}, + }; +} + +function parseGuardFindingsFromJson(payload: unknown): AdapterFinding[] { + if (!isRecord(payload) || !Array.isArray(payload.findings)) { + return []; + } + return payload.findings + .map((entry) => normalizeGuardFinding(entry)) + .filter((entry): entry is AdapterFinding => entry !== null); +} + +function parseGuardFindingsFromText(output: string): AdapterFinding[] { + const findings: AdapterFinding[] = []; + const lines = output.split(/\r?\n/); + let section = ""; + + const primitivePattern = + /^-\s+surface=(\S+)\s+role=(\S+)\s+count=(\d+)\s+sources=(.*?)\s+disallowedSources=(.*)$/; + const colorBlockingPattern = + /^-\s+surface=(\S+)\s+rule=(\S+)\s+value=(.+?)\s+policy=(\S+)\s+source=(\S*)\s+message=(.*)$/; + const colorWarningPattern = + /^-\s+surface=(\S+)\s+rule=(\S+)\s+value=(.+?)\s+source=(\S*)\s+message=(.*)$/; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + + if (line.startsWith("shell-owned-primitive-emitted detected")) { + section = "primitive"; + continue; + } + if (line.startsWith("color-policy-blocking detected")) { + section = "color-blocking"; + continue; + } + if (line.startsWith("color-policy-warning detected")) { + section = "color-warning"; + continue; + } + if (!line.startsWith("- ")) { + continue; + } + + if (section === "primitive") { + const match = line.match(primitivePattern); + if (!match) continue; + const [, surfaceId, role, count, sourcesRaw, disallowedRaw] = match; + const sources = parseCsvList(sourcesRaw); + const disallowedSources = parseCsvList(disallowedRaw); + findings.push({ + code: "shell-owned-primitive-emitted", + severity: "error", + policy: "strict", + message: `Shell-owned primitive "${role}" emitted for surface "${surfaceId}".`, + location: { + file: disallowedSources[0] ?? sources[0] ?? "", + line: 0, + }, + evidence: { + source: "generationGuard", + surfaceId, + role, + count: Number(count), + sources, + disallowedSources, + }, + }); + continue; + } + + if (section === "color-blocking") { + const match = line.match(colorBlockingPattern); + if (!match) continue; + const [, surfaceId, rule, value, policy, source, message] = match; + findings.push({ + code: rule, + severity: "error", + policy: policy === "warn" || policy === "off" ? "warn" : "strict", + message, + location: { + file: source ?? "", + line: 0, + }, + evidence: { + source: "generationGuard", + surfaceId, + value, + }, + }); + continue; + } + + if (section === "color-warning") { + const match = line.match(colorWarningPattern); + if (!match) continue; + const [, surfaceId, rule, value, source, message] = match; + findings.push({ + code: rule, + severity: "warning", + policy: "warn", + message, + location: { + file: source ?? "", + line: 0, + }, + evidence: { + source: "generationGuard", + surfaceId, + value, + }, + }); + } + } + + return findings; +} + +function parseGuardEvaluation(payload: unknown): GuardEvaluation { + const evaluation = + isRecord(payload) && isRecord(payload.evaluation) ? payload.evaluation : {}; + return { + shellBoundaryEvaluated: evaluation.shellBoundaryEvaluated !== false, + colorPolicyEvaluated: evaluation.colorPolicyEvaluated === true, + iconPolicyEvaluated: evaluation.iconPolicyEvaluated === true, + }; +} + +function loadDescriptorParitySurfaces( + runtime: RuntimeContext, + options: RunGenerationAdapterOptions, +): Set { + if (Array.isArray(options.descriptorParitySurfaces)) { + return new Set( + options.descriptorParitySurfaces + .map((entry) => String(entry).trim()) + .filter(Boolean), + ); + } + + const envOverride = runtime.env.SURFACES_ADAPTER_DESCRIPTOR_PARITY_SURFACES; + if (typeof envOverride === "string" && envOverride.trim().length > 0) { + return new Set(parseCsvList(envOverride)); + } + + const configPath = runtime.descriptorParityConfigPath; + if (!configPath || !fs.existsSync(configPath)) { + return new Set(); + } + + const payload = readJsonFile(configPath, "descriptor parity config"); + const enforced = Array.isArray(payload.enforcedSurfaces) + ? payload.enforcedSurfaces + .map((entry: unknown) => String(entry).trim()) + .filter(Boolean) + : []; + return new Set(enforced); +} + +function mapValidateCode(code: string): string { + return VALIDATE_CODE_MAP[code] ?? code; +} + +function inferValidatePolicy(findingCode: string, severity: FindingSeverity): FindingPolicy { + if (findingCode === "color.token.namespace") { + return "warn"; + } + if (severity === "error") { + return "strict"; + } + return "warn"; +} + +function toFindingLocation(location: unknown) { + if (typeof location !== "string") { + return { file: "", line: 0 }; + } + return { file: location, line: 0 }; +} + +function parseJsonPayload(text: string): unknown { + if (!text || !text.trim()) return undefined; + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +function normalizeValidateFindings(payload: unknown): AdapterFinding[] { + if (!isRecord(payload) || !Array.isArray(payload.findings)) { + return []; + } + return payload.findings.map((findingRaw) => { + const finding = isRecord(findingRaw) ? findingRaw : {}; + const severity: FindingSeverity = + finding.severity === "error" ? "error" : "warning"; + const code = mapValidateCode( + typeof finding.code === "string" ? finding.code : "validate.finding", + ); + const policy = inferValidatePolicy(code, severity); + return { + code, + severity, + policy, + message: + typeof finding.message === "string" && finding.message.trim().length > 0 + ? finding.message + : code, + location: toFindingLocation(finding.location), + evidence: { + source: "validate", + surfaceId: typeof finding.surface === "string" ? finding.surface : undefined, + category: finding.category, + expected: finding.expected, + found: finding.found, + }, + }; + }); +} + +function uniqueFindings(findings: AdapterFinding[]): AdapterFinding[] { + const seen = new Set(); + const normalized: AdapterFinding[] = []; + for (const finding of findings) { + const key = JSON.stringify({ + code: finding.code, + severity: finding.severity, + policy: finding.policy, + message: finding.message, + file: finding.location.file, + line: finding.location.line, + surfaceId: finding.evidence.surfaceId, + }); + if (seen.has(key)) { + continue; + } + seen.add(key); + normalized.push({ + code: finding.code, + severity: finding.severity, + policy: finding.policy, + message: finding.message, + location: { + file: finding.location.file, + line: finding.location.line, + }, + evidence: finding.evidence, + }); + } + return normalized; +} + +async function runGenerationGuard( + request: NormalizedGenerationAdapterRequest, + runtime: RuntimeContext, +): Promise<{ findings: AdapterFinding[]; evaluation: GuardEvaluation }> { + let descriptorTempDir: string | undefined; + let descriptorPath: string | undefined; + const findings: AdapterFinding[] = []; + let evaluation: GuardEvaluation = { + shellBoundaryEvaluated: true, + colorPolicyEvaluated: false, + iconPolicyEvaluated: false, + }; + + try { + if (request.mode === "descriptor") { + const temp = makeTempJsonFile( + "interfacectl-generation-descriptor", + "descriptor.json", + request.descriptor ?? [], + ); + descriptorTempDir = temp.tempDir; + descriptorPath = temp.filePath; + } else { + const temp = makeTempEmptyFile( + "interfacectl-generation-describe", + "descriptor.json", + ); + descriptorTempDir = temp.tempDir; + descriptorPath = temp.filePath; + + const describeExitCode = await runDescribeCommand({ + contractPath: runtime.bundle.contractPath, + root: request.workspaceRoot, + surface: [request.surfaceId], + out: descriptorPath, + }); + + if (describeExitCode !== 0) { + findings.push({ + code: "generation.guard.describe.failed", + severity: "error", + policy: "strict", + message: "Failed to collect descriptors for generation guard.", + location: { file: "", line: 0 }, + evidence: { + source: "generationGuard", + surfaceId: request.surfaceId, + exitCode: describeExitCode, + }, + }); + return { findings, evaluation }; + } + } + + const guard = runNodeScript(runtime, runtime.guardPath, [ + "--contract", + runtime.bundle.contractPath, + "--descriptor", + descriptorPath, + "--format", + "json", + ]); + + const payload = + parseJsonPayload(guard.stdout) ?? parseJsonPayload(guard.stderr); + const parsedFindings = parseGuardFindingsFromJson(payload); + const parsedFallback = parsedFindings.length > 0 + ? [] + : parseGuardFindingsFromText(`${guard.stderr}\n${guard.stdout}`); + + if (parsedFindings.length > 0) { + findings.push(...parsedFindings); + evaluation = parseGuardEvaluation(payload); + } else { + findings.push(...parsedFallback); + } + + if (guard.status === 1) { + const errorMessage = + (isRecord(payload) && isRecord(payload.error) && typeof payload.error.message === "string" + ? payload.error.message + : isRecord(payload) && typeof payload.error === "string" + ? payload.error + : `${guard.stderr || guard.stdout}`.trim()) || "unknown error"; + throw new AdapterInputError( + `Generation guard failed due to invalid input: ${errorMessage}`, + ); + } + + if (guard.status === 2 && parsedFindings.length === 0 && parsedFallback.length === 0) { + findings.push({ + code: "generation.guard.blocked", + severity: "error", + policy: "strict", + message: "Generation guard reported violations but emitted no structured findings.", + location: { file: "", line: 0 }, + evidence: { + source: "generationGuard", + surfaceId: request.surfaceId, + exitCode: guard.status, + }, + }); + } + + if (guard.status !== 0 && guard.status !== 2) { + findings.push({ + code: "generation.guard.execution.failed", + severity: "error", + policy: "strict", + message: + `Generation guard failed unexpectedly: ${guard.stderr || guard.stdout || "unknown error"}`.trim(), + location: { file: "", line: 0 }, + evidence: { + source: "generationGuard", + surfaceId: request.surfaceId, + exitCode: guard.status, + }, + }); + } + + return { findings, evaluation }; + } finally { + cleanupTempPath(descriptorTempDir); + } +} + +async function runWorkspaceValidate( + request: NormalizedGenerationAdapterRequest, + runtime: RuntimeContext, +): Promise { + const temp = makeTempEmptyFile( + "interfacectl-generation-validate", + "validate.json", + ); + + try { + const exitCode = await runValidateCommand({ + workspaceRoot: request.workspaceRoot, + contractPath: runtime.bundle.contractPath, + surfaceFilters: [request.surfaceId], + outputFormat: "json", + outputPath: temp.filePath, + exitCodes: "v2", + }); + + if (!fs.existsSync(temp.filePath)) { + return [ + { + code: "validate.execution.failed", + severity: "error", + policy: "strict", + message: "validate command did not emit JSON output.", + location: { file: "", line: 0 }, + evidence: { + source: "validate", + surfaceId: request.surfaceId, + exitCode, + }, + }, + ]; + } + + const payload = parseJsonPayload(fs.readFileSync(temp.filePath, "utf8")); + if (!payload) { + return [ + { + code: "validate.execution.failed", + severity: "error", + policy: "strict", + message: "validate command did not return JSON output.", + location: { file: "", line: 0 }, + evidence: { + source: "validate", + surfaceId: request.surfaceId, + exitCode, + }, + }, + ]; + } + + const allFindings = normalizeValidateFindings(payload); + const findings = allFindings.filter((finding) => { + const surfaceId = finding.evidence.surfaceId; + return !surfaceId || surfaceId === request.surfaceId; + }); + + if (exitCode !== 0 && findings.length === 0 && allFindings.length > 0) { + return []; + } + + if (exitCode !== 0 && findings.length === 0) { + findings.push({ + code: "validate.blocked", + severity: "error", + policy: "strict", + message: "validate command failed without structured findings.", + location: { file: "", line: 0 }, + evidence: { + source: "validate", + surfaceId: request.surfaceId, + exitCode, + }, + }); + } + + return findings; + } finally { + cleanupTempPath(temp.tempDir); + } +} + +export function buildCoverage( + mode: AdapterMode, + detail: Partial = {}, +): GenerationAdapterResponse["coverage"] { + return { + generationGuard: true, + fullValidate: mode === "workspace", + shellBoundaryEvaluated: detail.shellBoundaryEvaluated ?? false, + colorPolicyEvaluated: detail.colorPolicyEvaluated ?? false, + iconPolicyEvaluated: detail.iconPolicyEvaluated ?? false, + }; +} + +export function computeStatusFromFindings(findings: AdapterFinding[]): "pass" | "warn" | "block" { + const hasBlocking = findings.some( + (finding) => finding.severity === "error" || finding.policy === "strict", + ); + if (hasBlocking) return "block"; + if (findings.length > 0) return "warn"; + return "pass"; +} + +export async function runGenerationAdapter( + requestInput: GenerationAdapterRequest, + options: RunGenerationAdapterOptions = {}, +): Promise { + const startedAt = Date.now(); + const request = normalizeAdapterRequest(requestInput, options); + const runtime = resolveRuntime(request, options); + const descriptorParitySurfaces = loadDescriptorParitySurfaces(runtime, options); + + ensureReadableFile(runtime.guardPath, "Generation guard"); + if (request.mode === "workspace") { + if (!request.workspaceRoot || !fs.existsSync(request.workspaceRoot)) { + throw new AdapterInputError( + `workspaceRoot does not exist: ${request.workspaceRoot ?? ""}.`, + ); + } + } + + const descriptorParityEnabled = + request.mode === "descriptor" && descriptorParitySurfaces.has(request.surfaceId); + const guardResult = await runGenerationGuard(request, runtime); + const findings: AdapterFinding[] = []; + const guardFindings = descriptorParityEnabled + ? guardResult.findings + : guardResult.findings.filter((finding) => !PARITY_FINDING_CODES.has(finding.code)); + findings.push(...guardFindings); + + if (request.mode === "workspace") { + findings.push(...(await runWorkspaceValidate(request, runtime))); + } + + const coverage = buildCoverage(request.mode, { + shellBoundaryEvaluated: guardResult.evaluation.shellBoundaryEvaluated, + colorPolicyEvaluated: + descriptorParityEnabled || request.mode === "workspace" + ? guardResult.evaluation.colorPolicyEvaluated || request.mode === "workspace" + : false, + iconPolicyEvaluated: + descriptorParityEnabled || request.mode === "workspace" + ? guardResult.evaluation.iconPolicyEvaluated || request.mode === "workspace" + : false, + }); + + const normalizedFindings = uniqueFindings(findings); + const status = computeStatusFromFindings(normalizedFindings); + + return { + requestId: request.requestId, + status, + surfaceId: request.surfaceId, + bundle: { + root: runtime.bundle.root, + version: runtime.bundle.version, + manifestPath: runtime.bundle.manifestPath, + surfacePath: runtime.bundle.generationPath, + }, + contract: { + id: runtime.bundle.contractId, + version: runtime.bundle.contractVersion, + }, + coverage, + findings: normalizedFindings, + timings: { + totalMs: Date.now() - startedAt, + }, + provenance: { + ...request.provenance, + evaluatedAt: new Date().toISOString(), + }, + }; +} diff --git a/packages/interfacectl-cli/src/commands/compile.ts b/packages/interfacectl-cli/src/commands/compile.ts index 321e463..1360c8f 100644 --- a/packages/interfacectl-cli/src/commands/compile.ts +++ b/packages/interfacectl-cli/src/commands/compile.ts @@ -1,16 +1,22 @@ 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 type { + AuthoringSource, + ContractComponent, + ContractSection, + ContractSlot, + ContractSurface, + InterfaceContract, +} from "@surfaces/interfacectl-validator"; import { validateContractStructure, getBundledContractSchema, } from "@surfaces/interfacectl-validator"; import { normalizeContract } from "../utils/normalize.js"; -const BUNDLE_VERSION = "1.0"; +const BUNDLE_VERSION = "2.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 { @@ -40,10 +46,37 @@ interface Manifest { files: ManifestFileEntry[]; } -/** - * Recursively sort object keys for deterministic JSON output. - * Array element order is preserved; only object keys are sorted. - */ +interface BundleProvenance { + contractId: string; + contractVersion: string; + bundleVersion: string; + surfaceId?: string; +} + +interface BundleFile { + path: string; + content: string; +} + +interface ComponentCatalogEntry { + id: string; + intent: string; + description?: string; + variants?: ContractComponent["variants"]; + slots: ContractComponent["slots"]; + states?: ContractComponent["states"]; + interactions?: ContractComponent["interactions"]; + implementation?: ContractComponent["implementation"]; + references?: ContractComponent["references"]; +} + +interface RepairInstruction { + code: string; + priority: "high" | "medium" | "low"; + category: string; + action: Record; +} + function sortKeysRecursive(value: unknown): unknown { if (value === null || value === undefined) { return value; @@ -61,9 +94,6 @@ function sortKeysRecursive(value: unknown): unknown { 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`; } @@ -72,9 +102,6 @@ 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, @@ -86,6 +113,599 @@ async function writeAtomic( await rename(tmpPath, filePath); } +function makeBundleProvenance( + contract: InterfaceContract, + surfaceId?: string, +): BundleProvenance { + return { + contractId: contract.contractId, + contractVersion: contract.version, + bundleVersion: BUNDLE_VERSION, + ...(surfaceId ? { surfaceId } : {}), + }; +} + +function uniqueStrings(values: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + result.push(value); + } + return result; +} + +function collectComponentIdsFromSlots(slots: ContractSlot[] | undefined): string[] { + if (!slots) return []; + return slots.flatMap((slot) => slot.acceptsComponents ?? []); +} + +function collectSectionComponentIds(section: ContractSection): string[] { + return uniqueStrings([ + section.anatomy?.defaultComponent, + ...(section.anatomy?.allowedComponents ?? []), + ...collectComponentIdsFromSlots(section.anatomy?.slots), + ]); +} + +function resolveSurfaceSections( + contract: InterfaceContract, + _surface: ContractSurface, +): ContractSection[] { + return contract.sections; +} + +function resolveSurfaceComponents( + contract: InterfaceContract, + sections: ContractSection[], +): ContractComponent[] { + const referencedIds = new Set(); + for (const section of sections) { + for (const componentId of collectSectionComponentIds(section)) { + referencedIds.add(componentId); + } + } + + return (contract.components ?? []).filter((component) => referencedIds.has(component.id)); +} + +function buildSectionOrderHints( + surface: ContractSurface, +): Map { + const sectionOrder = surface.layout.landingPattern?.sectionOrder ?? []; + const requiredTopLevel = new Set(surface.layout.landingPattern?.requireTopLevelSections ?? []); + const hints = new Map(); + + for (const sectionId of sectionOrder) { + hints.set(sectionId, { + sectionOrderIndex: sectionOrder.indexOf(sectionId), + topLevelRequired: requiredTopLevel.has(sectionId), + }); + } + + for (const sectionId of requiredTopLevel) { + const existing = hints.get(sectionId); + hints.set(sectionId, { + sectionOrderIndex: existing?.sectionOrderIndex, + topLevelRequired: true, + }); + } + + return hints; +} + +function buildSectionsPayload( + contract: InterfaceContract, + surface: ContractSurface, + sections: ContractSection[], +) { + const orderHints = buildSectionOrderHints(surface); + + return { + provenance: makeBundleProvenance(contract, surface.id), + sections: sections.map((section) => { + const hint = orderHints.get(section.id); + return { + id: section.id, + intent: section.intent, + description: section.description, + requiredBySurface: surface.requiredSections.includes(section.id), + ...(hint + ? { + orderHints: { + ...(typeof hint.sectionOrderIndex === "number" + ? { sectionOrderIndex: hint.sectionOrderIndex } + : {}), + topLevelRequired: hint.topLevelRequired, + }, + } + : {}), + ...(section.editPolicy ? { editPolicy: section.editPolicy } : {}), + ...(section.responsive ? { responsive: section.responsive } : {}), + ...(section.anatomy + ? { + anatomy: { + pattern: section.anatomy.pattern, + ...(section.anatomy.defaultComponent + ? { defaultComponentId: section.anatomy.defaultComponent } + : {}), + ...(section.anatomy.allowedComponents + ? { allowedComponentIds: section.anatomy.allowedComponents } + : {}), + ...(section.anatomy.slots + ? { + slots: section.anatomy.slots.map((slot) => ({ + id: slot.id, + kind: slot.kind, + required: slot.required, + ...(slot.repeatable !== undefined ? { repeatable: slot.repeatable } : {}), + ...(slot.minItems !== undefined ? { minItems: slot.minItems } : {}), + ...(slot.maxItems !== undefined ? { maxItems: slot.maxItems } : {}), + ...(slot.textRole ? { textRole: slot.textRole } : {}), + ...(slot.acceptsComponents + ? { acceptsComponentIds: slot.acceptsComponents } + : {}), + ...(slot.contentRules ? { contentRules: slot.contentRules } : {}), + })), + } + : {}), + }, + } + : {}), + }; + }), + }; +} + +function buildComponentsPayload( + contract: InterfaceContract, + surface: ContractSurface, + components: ContractComponent[], +) { + const catalog: ComponentCatalogEntry[] = components.map((component) => ({ + id: component.id, + intent: component.intent, + ...(component.description ? { description: component.description } : {}), + ...(component.variants ? { variants: component.variants } : {}), + slots: component.slots, + ...(component.states ? { states: component.states } : {}), + ...(component.interactions ? { interactions: component.interactions } : {}), + ...(component.implementation ? { implementation: component.implementation } : {}), + ...(component.references ? { references: component.references } : {}), + })); + + return { + provenance: makeBundleProvenance(contract, surface.id), + components: catalog, + }; +} + +function resolveProfileById( + profiles: T[] | undefined, + profileId: string | undefined, +): T | null { + if (!profiles || !profileId) return null; + return profiles.find((profile) => profile.id === profileId) ?? null; +} + +function buildConstraintsPayload( + contract: InterfaceContract, + surface: ContractSurface, +) { + const selectedLayoutProfile = resolveProfileById( + contract.marketingProfiles?.layout, + surface.layout.landingPattern?.marketingLayoutProfile, + ); + const selectedTypographyProfile = resolveProfileById( + contract.marketingProfiles?.typography, + surface.marketingTypographyProfile, + ); + + return { + provenance: makeBundleProvenance(contract, surface.id), + constraints: { + motion: contract.constraints.motion, + color: contract.color, + ...(contract.tokens ? { tokens: contract.tokens } : {}), + layoutPolicy: { + maxContentWidth: surface.layout.maxContentWidth, + requiredContainers: surface.layout.requiredContainers ?? [], + ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), + ...(surface.layout.chromePolicy ? { chromePolicy: surface.layout.chromePolicy } : {}), + ...(surface.layout.landingPattern ? { landingPattern: surface.layout.landingPattern } : {}), + ...(surface.viewports ? { viewports: surface.viewports } : {}), + }, + ...(surface.icons ? { icons: surface.icons } : {}), + ...(surface.flows ? { flows: surface.flows } : {}), + marketingProfiles: { + ...(selectedLayoutProfile ? { layout: selectedLayoutProfile } : {}), + ...(selectedTypographyProfile ? { typography: selectedTypographyProfile } : {}), + }, + }, + }; +} + +function buildGuidance( + contract: InterfaceContract, + surface: ContractSurface, + sections: ContractSection[], +): Record { + const shellOwns = contract.shell?.owns ?? []; + const mustNotEmit = surface.mustNotEmit ?? []; + const prohibitedRoles = uniqueStrings([...shellOwns, ...mustNotEmit]); + const hasLandingPattern = Boolean(surface.layout.landingPattern); + const hasResponsiveRules = sections.some((section) => section.responsive?.rules?.length); + const hasVisualPolicy = + contract.color.policy !== "off" || + Boolean(surface.icons && surface.icons.policy !== "off") || + Boolean(contract.tokens && Object.keys(contract.tokens).length > 0); + + return { + priorities: [ + { id: "boundary", rank: 1 }, + { id: "structure", rank: 2 }, + { id: "layout", rank: 3 }, + { id: "visual", rank: 4 }, + { id: "interaction", rank: 5 }, + ], + requiredOutputs: [ + { type: "surface-content", required: true }, + ...surface.requiredSections.map((sectionId) => ({ + type: "section", + sectionId, + required: true, + })), + ], + doNotEmitRoles: prohibitedRoles, + boundaryRules: [ + { + id: "shell-ownership", + shellOwns, + mustNotEmit, + ...(contract.shell?.contentSlot ? { contentSlot: contract.shell.contentSlot } : {}), + }, + ], + generationFocusOrder: uniqueStrings([ + "boundary", + "sections", + hasLandingPattern ? "landing-pattern" : undefined, + hasResponsiveRules ? "responsive" : undefined, + "layout", + hasVisualPolicy ? "visual" : undefined, + surface.flows ? "flows" : undefined, + ]), + }; +} + +function buildObservationRefs(contract: InterfaceContract): Array> { + const refs: Array> = []; + if (contract.x_extracted) { + refs.push({ + kind: "contract-field", + path: "/x_extracted", + }); + } + return refs; +} + +function buildGenerationPayload( + contract: InterfaceContract, + surface: ContractSurface, + sections: ContractSection[], +) { + const shellOwns = contract.shell?.owns ?? []; + const mustNotEmit = surface.mustNotEmit ?? []; + const requiredSections = surface.requiredSections; + const landingPattern = surface.layout.landingPattern; + const observationRefs = buildObservationRefs(contract); + + return { + identity: { + surfaceId: surface.id, + displayName: surface.displayName, + type: surface.type, + }, + provenance: makeBundleProvenance(contract, surface.id), + boundary: { + shellOwns, + contentSlot: contract.shell?.contentSlot ?? null, + mustNotEmit, + allowSources: surface.shellOwnedPrimitiveAllowSources ?? [], + }, + structure: { + requiredSectionIds: requiredSections, + sectionOrder: landingPattern?.sectionOrder ?? [], + topLevelSectionIds: landingPattern?.requireTopLevelSections ?? [], + flowSummary: surface.flows + ? { + policy: surface.flows.policy, + flowIds: surface.flows.requirements.map((flow) => flow.flowId), + requirementCount: surface.flows.requirements.length, + } + : null, + sectionCount: sections.length, + }, + layout: { + maxContentWidth: surface.layout.maxContentWidth, + requiredContainers: surface.layout.requiredContainers ?? [], + ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), + ...(surface.layout.chromePolicy ? { chromePolicy: surface.layout.chromePolicy } : {}), + ...(landingPattern ? { landingPattern } : {}), + viewportIds: (surface.viewports ?? []).map((viewport) => viewport.id), + }, + visual: { + allowedFonts: surface.allowedFonts, + color: { + policy: contract.color.policy, + allowedValuesCount: contract.color.allowedValues.length, + }, + icons: surface.icons + ? { + policy: surface.icons.policy, + allowedSourcesCount: surface.icons.allowedSources.length, + } + : null, + motion: { + allowedDurationsMs: contract.constraints.motion.allowedDurationsMs, + allowedTimingFunctions: contract.constraints.motion.allowedTimingFunctions, + }, + typography: { + marketingProfileId: surface.marketingTypographyProfile ?? null, + marketingPolicy: surface.marketingTypographyPolicy ?? "off", + tokenPolicyCategories: Object.keys(contract.tokens ?? {}), + }, + }, + guidance: buildGuidance(contract, surface, sections), + refs: { + contract: "../../contract/normalized.json", + sections: "./sections.json", + components: "./components.json", + constraints: "./constraints.json", + ...(surface.authoring ? { authoring: "./authoring.json" } : {}), + repairMap: "./repair-map.json", + ...(observationRefs.length > 0 ? { evidence: observationRefs } : {}), + }, + }; +} + +function buildAuthoringPayload( + contract: InterfaceContract, + surface: ContractSurface, +) { + if (!surface.authoring) return null; + + return { + provenance: makeBundleProvenance(contract, surface.id), + authoring: { + ...surface.authoring, + sourcePriority: (surface.authoring.sourcePriority ?? []).map( + (source: AuthoringSource) => source, + ), + }, + }; +} + +function addRepair( + repairs: RepairInstruction[], + code: string, + priority: RepairInstruction["priority"], + category: string, + action: Record, +): void { + repairs.push({ code, priority, category, action }); +} + +function buildRepairMapPayload( + contract: InterfaceContract, + surface: ContractSurface, + sections: ContractSection[], +) { + const repairs: RepairInstruction[] = []; + const shellOwns = contract.shell?.owns ?? []; + const mustNotEmit = surface.mustNotEmit ?? []; + const prohibitedRoles = uniqueStrings([...shellOwns, ...mustNotEmit]); + + if (prohibitedRoles.length > 0) { + addRepair(repairs, "shell.primitive.disallowed", "high", "boundary", { + type: "remove-prohibited-primitives", + prohibitedRoles, + ...(contract.shell?.contentSlot ? { contentSlot: contract.shell.contentSlot } : {}), + allowSources: surface.shellOwnedPrimitiveAllowSources ?? [], + }); + } + + if (surface.requiredSections.length > 0) { + addRepair(repairs, "section.missing", "high", "structure", { + type: "ensure-required-sections", + sectionIds: surface.requiredSections, + }); + } + + if (sections.length > 0) { + addRepair(repairs, "section.unexpected", "medium", "structure", { + type: "restrict-section-set", + allowedSectionIds: sections.map((section) => section.id), + }); + } + + addRepair(repairs, "font.disallowed", "medium", "visual", { + type: "restrict-fonts", + allowedFonts: surface.allowedFonts, + }); + + if (contract.color.policy !== "off" || contract.color.allowedValues.length > 0) { + addRepair(repairs, "color.disallowed", "medium", "visual", { + type: "restrict-colors", + policy: contract.color.policy, + allowedValues: contract.color.allowedValues, + }); + } + + if (surface.icons && (surface.icons.policy !== "off" || surface.icons.allowedSources.length > 0)) { + addRepair(repairs, "icon.source-disallowed", "medium", "visual", { + type: "restrict-icon-sources", + policy: surface.icons.policy, + allowedSources: surface.icons.allowedSources, + }); + } + + if (contract.tokens?.typography && contract.tokens.typography.policy !== "off") { + addRepair(repairs, "token.disallowed", "medium", "visual", { + type: "restrict-tokens", + tokenCategory: "typography", + policy: contract.tokens.typography.policy, + allowedTokens: contract.tokens.typography.allowedTokens, + }); + } + + if (contract.tokens?.layout && contract.tokens.layout.policy !== "off") { + addRepair(repairs, "token.disallowed", "medium", "layout", { + type: "restrict-tokens", + tokenCategory: "layout", + policy: contract.tokens.layout.policy, + allowedTokens: contract.tokens.layout.allowedTokens, + }); + } + + if (contract.tokens?.motion && contract.tokens.motion.policy !== "off") { + addRepair(repairs, "token.disallowed", "medium", "visual", { + type: "restrict-tokens", + tokenCategory: "motion", + policy: contract.tokens.motion.policy, + allowedTokens: contract.tokens.motion.allowedTokens, + }); + } + + addRepair(repairs, "motion.duration", "medium", "visual", { + type: "restrict-motion-durations", + allowedDurationsMs: contract.constraints.motion.allowedDurationsMs, + }); + addRepair(repairs, "motion.timing", "medium", "visual", { + type: "restrict-motion-timing", + allowedTimingFunctions: contract.constraints.motion.allowedTimingFunctions, + }); + + if (surface.layout.maxContentWidth !== undefined) { + addRepair(repairs, "layout.width-exceeded", "medium", "layout", { + type: "reduce-content-width", + maxContentWidth: surface.layout.maxContentWidth, + }); + } + + if ((surface.layout.requiredContainers ?? []).length > 0) { + addRepair(repairs, "layout.container-missing", "medium", "layout", { + type: "ensure-required-containers", + requiredContainers: surface.layout.requiredContainers, + }); + } + + if (surface.layout.pageFrame) { + addRepair(repairs, "layout.pageframe.maxwidth-mismatch", "medium", "layout", { + type: "align-page-frame", + pageFrame: surface.layout.pageFrame, + }); + addRepair(repairs, "layout.pageframe.padding-mismatch", "medium", "layout", { + type: "align-page-frame", + pageFrame: surface.layout.pageFrame, + }); + } + + if (surface.layout.landingPattern && surface.layout.landingPattern.policy !== "off") { + addRepair(repairs, "landing.pattern.signal-missing", "high", "layout", { + type: "restore-landing-pattern-signals", + landingPattern: surface.layout.landingPattern, + }); + addRepair(repairs, "landing.pattern.top-level-missing", "high", "layout", { + type: "restore-top-level-sections", + sectionIds: surface.layout.landingPattern.requireTopLevelSections ?? [], + }); + addRepair(repairs, "landing.pattern.section-order", "medium", "layout", { + type: "restore-section-order", + sectionOrder: surface.layout.landingPattern.sectionOrder ?? [], + }); + addRepair(repairs, "landing.pattern.background-mode", "medium", "layout", { + type: "restore-page-background-mode", + pageBackgroundMode: surface.layout.landingPattern.pageBackgroundMode ?? null, + }); + } + + if (surface.marketingTypographyProfile && surface.marketingTypographyPolicy !== "off") { + addRepair(repairs, "marketing.typography.profile-missing", "medium", "visual", { + type: "restore-marketing-typography-profile", + profileId: surface.marketingTypographyProfile, + policy: surface.marketingTypographyPolicy ?? "warn", + }); + } + + if (surface.flows && surface.flows.policy !== "off") { + addRepair(repairs, "flow.required.missing", "high", "interaction", { + type: "restore-required-flows", + requirements: surface.flows.requirements, + }); + addRepair(repairs, "flow.steps.required", "medium", "interaction", { + type: "restore-required-flow-steps", + requirements: surface.flows.requirements, + }); + addRepair(repairs, "flow.transition.required", "medium", "interaction", { + type: "restore-required-transitions", + requirements: surface.flows.requirements, + }); + } + + return { + provenance: makeBundleProvenance(contract, surface.id), + repairs, + }; +} + +function buildSurfaceBundleFiles( + contract: InterfaceContract, + surface: ContractSurface, +): BundleFile[] { + const surfaceDir = `surfaces/${surface.id}`; + const sections = resolveSurfaceSections(contract, surface); + const components = resolveSurfaceComponents(contract, sections); + const constraintsPayload = buildConstraintsPayload(contract, surface); + const generationPayload = buildGenerationPayload(contract, surface, sections); + const sectionsPayload = buildSectionsPayload(contract, surface, sections); + const componentsPayload = buildComponentsPayload(contract, surface, components); + const repairMapPayload = buildRepairMapPayload(contract, surface, sections); + const authoringPayload = buildAuthoringPayload(contract, surface); + + const files: BundleFile[] = [ + { + path: `${surfaceDir}/generation.json`, + content: stringifyDeterministic(generationPayload), + }, + { + path: `${surfaceDir}/sections.json`, + content: stringifyDeterministic(sectionsPayload), + }, + { + path: `${surfaceDir}/components.json`, + content: stringifyDeterministic(componentsPayload), + }, + { + path: `${surfaceDir}/constraints.json`, + content: stringifyDeterministic(constraintsPayload), + }, + { + path: `${surfaceDir}/repair-map.json`, + content: stringifyDeterministic(repairMapPayload), + }, + ]; + + if (authoringPayload) { + files.push({ + path: `${surfaceDir}/authoring.json`, + content: stringifyDeterministic(authoringPayload), + }); + } + + return files; +} + export async function runCompileCommand( options: CompileCommandOptions, toolVersion: string, @@ -143,30 +763,16 @@ export async function runCompileCommand( 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 }, + const bundleFiles: BundleFile[] = [ + { + path: "contract/normalized.json", + content: stringifyDeterministic(normalizedContract), + }, + ...normalizedContract.surfaces.flatMap((surface) => + buildSurfaceBundleFiles(normalizedContract, surface), + ), ]; - 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), ); @@ -202,16 +808,3 @@ export async function runCompileCommand( return 0; } - -function surfaceToBundlePayload(surface: ContractSurface): Record { - return { - id: surface.id, - displayName: surface.displayName, - type: surface.type, - requiredSections: surface.requiredSections, - allowedFonts: surface.allowedFonts, - layout: surface.layout, - ...(surface.icons ? { icons: surface.icons } : {}), - ...(surface.flows ? { flows: surface.flows } : {}), - }; -} diff --git a/packages/interfacectl-cli/src/commands/prepare-generation.ts b/packages/interfacectl-cli/src/commands/prepare-generation.ts new file mode 100644 index 0000000..bb31e37 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/prepare-generation.ts @@ -0,0 +1,275 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + AdapterInputError, + isRecord, + loadCompiledSurfaceBundle, + type JsonRecord, + type LoadedCompiledSurfaceBundle, +} from "../adapter/bundle.js"; + +export interface PrepareGenerationCommandOptions { + bundleRoot?: string; + surfaceId?: string; + outPath?: string; +} + +type RepairPriority = "high" | "medium" | "low"; + +interface SummaryRepairItem { + code: string; + priority: RepairPriority; + category: string; + actionType: string; +} + +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 key of Object.keys(value as Record).sort()) { + sorted[key] = sortKeysRecursive((value as Record)[key]); + } + return sorted; + } + return value; +} + +function stringifyDeterministic(value: unknown): string { + return `${JSON.stringify(sortKeysRecursive(value), null, 2)}\n`; +} + +function asRecord(value: unknown): JsonRecord { + return isRecord(value) ? value : {}; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +function getRepairPriorityRank(priority: RepairPriority): number { + switch (priority) { + case "high": + return 0; + case "medium": + return 1; + case "low": + return 2; + default: + return 3; + } +} + +function getRepairSummary(repairMap: JsonRecord): SummaryRepairItem[] { + const repairs = Array.isArray(repairMap.repairs) ? repairMap.repairs : []; + return repairs + .filter((entry): entry is JsonRecord => isRecord(entry)) + .map((entry) => { + const action = asRecord(entry.action); + return { + code: asString(entry.code) ?? "unknown", + priority: (asString(entry.priority) as RepairPriority | undefined) ?? "medium", + category: asString(entry.category) ?? "unknown", + actionType: asString(action.type) ?? "unknown", + }; + }) + .sort((a, b) => + getRepairPriorityRank(a.priority) - getRepairPriorityRank(b.priority) || + a.category.localeCompare(b.category) || + a.code.localeCompare(b.code), + ); +} + +function buildSummary(bundle: LoadedCompiledSurfaceBundle) { + const generation = asRecord(bundle.surface.generation.value); + const boundary = asRecord(generation.boundary); + const structure = asRecord(generation.structure); + const guidance = asRecord(generation.guidance); + const repairs = getRepairSummary(bundle.surface.repairMap.value); + + const focusOrder = asStringArray(guidance.generationFocusOrder); + const requiredSectionIds = asStringArray(structure.requiredSectionIds); + const prohibitedRoles = uniqueStrings([ + ...asStringArray(guidance.doNotEmitRoles), + ...asStringArray(boundary.shellOwns), + ...asStringArray(boundary.mustNotEmit), + ]); + + const checklist: Array<{ id: string; label: string; detail: string }> = []; + if (prohibitedRoles.length > 0) { + checklist.push({ + id: "boundary", + label: "Stay inside the surface boundary", + detail: `Do not emit shell-owned roles: ${prohibitedRoles.join(", ")}.`, + }); + } + if (requiredSectionIds.length > 0) { + checklist.push({ + id: "sections", + label: "Include every required section", + detail: `Required sections: ${requiredSectionIds.join(", ")}.`, + }); + } + if (focusOrder.length > 0) { + checklist.push({ + id: "focus-order", + label: "Follow the generation focus order", + detail: `Focus order: ${focusOrder.join(" -> ")}.`, + }); + } + if (repairs.length > 0) { + checklist.push({ + id: "repair-priorities", + label: "Use repair priorities when correcting drift", + detail: `Highest-priority repair codes: ${repairs + .slice(0, 3) + .map((repair) => repair.code) + .join(", ")}.`, + }); + } + + const textParts: string[] = []; + if (focusOrder.length > 0) { + textParts.push(`Focus on ${focusOrder.join(", ")}`); + } + if (requiredSectionIds.length > 0) { + textParts.push(`include required sections ${requiredSectionIds.join(", ")}`); + } + if (prohibitedRoles.length > 0) { + textParts.push(`avoid shell-owned roles ${prohibitedRoles.join(", ")}`); + } + if (repairs.length > 0) { + textParts.push(`prioritize repairs ${repairs.slice(0, 3).map((repair) => repair.code).join(", ")}`); + } + + return { + text: textParts.length > 0 ? `${textParts.join("; ")}.` : "Use the compiled surface bundle as the authoritative generation input.", + focusOrder, + requiredSectionIds, + prohibitedRoles, + checklist, + topRepairs: repairs.slice(0, 5), + }; +} + +function buildPreparedGenerationPayload(bundle: LoadedCompiledSurfaceBundle) { + const generation = asRecord(bundle.surface.generation.value); + const generationRefs = asRecord(generation.refs); + const identity = asRecord(generation.identity); + const sectionsDoc = asRecord(bundle.surface.sections.value); + const componentsDoc = asRecord(bundle.surface.components.value); + const constraintsDoc = asRecord(bundle.surface.constraints.value); + const repairMapDoc = asRecord(bundle.surface.repairMap.value); + const authoringDoc = bundle.surface.authoring + ? asRecord(bundle.surface.authoring.value) + : undefined; + + return { + surface: { + surfaceId: asString(identity.surfaceId) ?? bundle.surface.id, + displayName: asString(identity.displayName) ?? bundle.surface.id, + type: asString(identity.type) ?? "unknown", + }, + bundle: { + root: bundle.root, + version: bundle.version, + manifestPath: bundle.manifest.path, + sourcePaths: { + contract: bundle.contract.path, + generation: bundle.surface.generation.path, + sections: bundle.surface.sections.path, + components: bundle.surface.components.path, + constraints: bundle.surface.constraints.path, + repairMap: bundle.surface.repairMap.path, + ...(bundle.surface.authoring ? { authoring: bundle.surface.authoring.path } : {}), + }, + }, + contract: { + id: bundle.contractId, + version: bundle.contractVersion, + normalizedPath: bundle.contract.path, + }, + summary: buildSummary(bundle), + generation: { + boundary: asRecord(generation.boundary), + structure: asRecord(generation.structure), + layout: asRecord(generation.layout), + visual: asRecord(generation.visual), + guidance: asRecord(generation.guidance), + }, + sections: Array.isArray(sectionsDoc.sections) ? sectionsDoc.sections : [], + components: Array.isArray(componentsDoc.components) ? componentsDoc.components : [], + constraints: asRecord(constraintsDoc.constraints), + repairMap: Array.isArray(repairMapDoc.repairs) ? repairMapDoc.repairs : [], + ...(authoringDoc && isRecord(authoringDoc.authoring) + ? { authoring: authoringDoc.authoring } + : {}), + evidenceRefs: Array.isArray(generationRefs.evidence) ? generationRefs.evidence : [], + }; +} + +function writeError(error: AdapterInputError | Error, code: string) { + process.stderr.write( + `${JSON.stringify( + { + status: "error", + code, + error: error.message, + }, + null, + 2, + )}\n`, + ); +} + +export async function runPrepareGenerationCommand( + options: PrepareGenerationCommandOptions, +): Promise { + try { + if (!options.bundleRoot) { + throw new AdapterInputError("--bundle-root is required."); + } + if (!options.surfaceId) { + throw new AdapterInputError("--surface is required."); + } + + const bundle = loadCompiledSurfaceBundle(options.bundleRoot, options.surfaceId, process.cwd()); + const payload = buildPreparedGenerationPayload(bundle); + const serialized = stringifyDeterministic(payload); + + if (options.outPath) { + const outPath = path.resolve(options.outPath); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, serialized, "utf8"); + return 0; + } + + process.stdout.write(serialized); + return 0; + } catch (error) { + if (error instanceof AdapterInputError) { + writeError(error, error.code); + return 10; + } + + const internalError = error instanceof Error ? error : new Error(String(error)); + writeError(internalError, "prepare-generation.internal"); + return 1; + } +} diff --git a/packages/interfacectl-cli/src/commands/serve-generation-adapter.ts b/packages/interfacectl-cli/src/commands/serve-generation-adapter.ts new file mode 100644 index 0000000..5618158 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/serve-generation-adapter.ts @@ -0,0 +1,225 @@ +import http from "node:http"; +import { URL } from "node:url"; +import { + AdapterInputError, + runGenerationAdapter, + type GenerationAdapterRequest, + type RunGenerationAdapterOptions, +} from "../adapter/core.js"; + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 7777; +const DEFAULT_TOKEN_HEADER = "x-surfaces-adapter-token"; +const MAX_REQUEST_BYTES = 1_000_000; + +export interface ServeGenerationAdapterCommandOptions { + host?: string; + port?: number; + token?: string; + tokenHeader?: string; + bundleRoot?: string; + descriptorParityConfigPath?: string; +} + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function redactSecrets(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => redactSecrets(entry)); + } + + if (!isObject(value)) { + return value; + } + + const output: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (/token|secret|password|authorization|api[_-]?key/i.test(key)) { + output[key] = "[REDACTED]"; + continue; + } + output[key] = redactSecrets(entry); + } + return output; +} + +function sendJson(res: http.ServerResponse, statusCode: number, payload: unknown) { + const body = `${JSON.stringify(payload, null, 2)}\n`; + res.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(body), + }); + res.end(body); +} + +async function readBody(req: http.IncomingMessage): Promise> { + const chunks: Buffer[] = []; + let bytes = 0; + + for await (const chunk of req) { + bytes += chunk.length; + if (bytes > MAX_REQUEST_BYTES) { + throw new AdapterInputError( + `Request body exceeds ${MAX_REQUEST_BYTES} bytes.`, + ); + } + chunks.push(chunk); + } + + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + throw new AdapterInputError("Request body must be valid JSON."); + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!isObject(parsed)) { + throw new Error("Request body must be a JSON object."); + } + return parsed; + } catch (error) { + throw new AdapterInputError(`Invalid JSON body: ${(error as Error).message}`); + } +} + +export function createGenerationAdapterServer(config: { + host?: string; + token?: string; + tokenHeader?: string; + bundleRoot?: string; + descriptorParityConfigPath?: string; +} = {}) { + const host = config.host ?? DEFAULT_HOST; + const token = config.token; + const tokenHeader = (config.tokenHeader ?? DEFAULT_TOKEN_HEADER).toLowerCase(); + const defaultBundleRoot = config.bundleRoot; + const runtimeOptions: RunGenerationAdapterOptions = { + cwd: process.cwd(), + defaultBundleRoot, + descriptorParityConfigPath: config.descriptorParityConfigPath, + }; + + if (host !== "127.0.0.1" && !token) { + throw new AdapterInputError( + "Non-local bind requires a token. Set --token or SURFACES_ADAPTER_TOKEN.", + ); + } + + return http.createServer(async (req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host || host}`); + if (req.method !== "POST" || requestUrl.pathname !== "/surfaces.validateGeneration") { + sendJson(res, 404, { + error: "Not found", + path: requestUrl.pathname, + }); + return; + } + + if (token) { + const providedToken = req.headers[tokenHeader]; + const headerValue = Array.isArray(providedToken) + ? providedToken[0] + : providedToken; + + if (headerValue !== token) { + sendJson(res, 401, { + error: "Unauthorized", + code: "adapter.auth", + }); + return; + } + } + + const payload = await readBody(req); + const requestPayload = { + ...payload, + ...(defaultBundleRoot ? { bundleRoot: defaultBundleRoot } : {}), + } as GenerationAdapterRequest; + + const response = await runGenerationAdapter(requestPayload, runtimeOptions); + const statusCode = response.status === "block" ? 422 : 200; + + sendJson(res, statusCode, response); + console.log( + JSON.stringify({ + event: "surfaces.validateGeneration", + status: response.status, + requestId: response.requestId, + tool: payload.tool, + surfaceId: payload.surfaceId, + findings: response.findings.length, + timings: response.timings, + payload: redactSecrets({ + mode: payload.mode, + provenance: payload.provenance, + }), + }), + ); + } catch (error) { + if (error instanceof AdapterInputError) { + sendJson(res, 400, { + error: error.message, + code: error.code, + }); + return; + } + + sendJson(res, 500, { + error: error instanceof Error ? error.message : String(error), + code: "adapter.internal", + }); + } + }); +} + +export async function runServeGenerationAdapterCommand( + options: ServeGenerationAdapterCommandOptions, +): Promise { + if (!options.bundleRoot) { + throw new AdapterInputError("--bundle-root is required."); + } + + const host = options.host ?? process.env.SURFACES_ADAPTER_HOST ?? DEFAULT_HOST; + const portRaw = + options.port ?? Number(process.env.SURFACES_ADAPTER_PORT ?? DEFAULT_PORT); + const port = Number(portRaw); + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new AdapterInputError(`Invalid port: ${portRaw}`); + } + + const token = options.token ?? process.env.SURFACES_ADAPTER_TOKEN; + const tokenHeader = + options.tokenHeader ?? + (process.env.SURFACES_ADAPTER_TOKEN_HEADER || DEFAULT_TOKEN_HEADER).toLowerCase(); + + const server = createGenerationAdapterServer({ + host, + token, + tokenHeader, + bundleRoot: options.bundleRoot, + descriptorParityConfigPath: options.descriptorParityConfigPath, + }); + + await new Promise((resolve) => { + server.listen(port, host, () => { + const address = server.address(); + const displayPort = typeof address === "object" && address ? address.port : port; + console.log( + `Generation adapter listening on http://${host}:${displayPort}/surfaces.validateGeneration`, + ); + resolve(); + }); + }); + + const shutdown = () => { + server.close(() => { + process.exit(0); + }); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} diff --git a/packages/interfacectl-cli/src/commands/validate-generation.ts b/packages/interfacectl-cli/src/commands/validate-generation.ts new file mode 100644 index 0000000..cf972b8 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/validate-generation.ts @@ -0,0 +1,143 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { + AdapterInputError, + runGenerationAdapter, + type RunGenerationAdapterOptions, +} from "../adapter/core.js"; + +export interface ValidateGenerationCommandOptions { + tool?: string; + surfaceId?: string; + mode?: string; + bundleRoot?: string; + workspaceRoot?: string; + descriptorPath?: string; + outPath?: string; + requestId?: string; + descriptorParityConfigPath?: string; +} + +function readDescriptor(descriptorPath: string) { + const resolvedPath = path.resolve(descriptorPath); + if (!fs.existsSync(resolvedPath)) { + throw new AdapterInputError(`Descriptor file not found: ${resolvedPath}.`); + } + + try { + const parsed = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + if (!Array.isArray(parsed)) { + throw new AdapterInputError( + `Descriptor file must contain a JSON array: ${resolvedPath}.`, + ); + } + return parsed; + } catch (error) { + if (error instanceof AdapterInputError) { + throw error; + } + throw new AdapterInputError( + `Failed to read descriptor JSON at ${resolvedPath}: ${(error as Error).message}`, + ); + } +} + +export async function runValidateGenerationCommand( + options: ValidateGenerationCommandOptions, +): Promise { + const request = { + requestId: options.requestId ?? crypto.randomUUID(), + tool: options.tool, + surfaceId: options.surfaceId, + mode: options.mode, + bundleRoot: options.bundleRoot, + provenance: { + timestamp: new Date().toISOString(), + sessionId: process.env.SURFACES_ADAPTER_SESSION_ID, + userId: process.env.SURFACES_ADAPTER_USER_ID, + }, + } as { + requestId: string; + tool?: string; + surfaceId?: string; + mode?: string; + bundleRoot?: string; + workspaceRoot?: string; + descriptor?: Record[]; + provenance: { + timestamp: string; + sessionId?: string; + userId?: string; + }; + }; + + if (options.mode === "workspace") { + request.workspaceRoot = + typeof options.workspaceRoot === "string" + ? path.resolve(options.workspaceRoot) + : undefined; + } else if (options.mode === "descriptor") { + if (!options.descriptorPath) { + throw new AdapterInputError( + "--descriptor-path is required when --mode descriptor.", + ); + } + request.descriptor = readDescriptor(options.descriptorPath) as Record[]; + } + + if (options.mode === "workspace" && !options.workspaceRoot) { + throw new AdapterInputError( + "--workspace-root is required when --mode workspace.", + ); + } + + const runtimeOptions: RunGenerationAdapterOptions = { + cwd: process.cwd(), + descriptorParityConfigPath: options.descriptorParityConfigPath, + }; + + try { + const response = await runGenerationAdapter(request, runtimeOptions); + const serialized = `${JSON.stringify(response, null, 2)}\n`; + + if (options.outPath) { + const outPath = path.resolve(options.outPath); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, serialized, "utf8"); + } + + process.stdout.write(serialized); + return response.status === "block" ? 30 : 0; + } catch (error) { + if (error instanceof AdapterInputError) { + process.stderr.write( + `${JSON.stringify( + { + requestId: request.requestId, + status: "block", + error: error.message, + code: error.code, + }, + null, + 2, + )}\n`, + ); + return 10; + } + + process.stderr.write( + `${JSON.stringify( + { + requestId: request.requestId, + status: "block", + error: error instanceof Error ? error.message : String(error), + code: "adapter.internal", + }, + null, + 2, + )}\n`, + ); + return 1; + } +} diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index 7c45bed..4447cf1 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -10,6 +10,9 @@ import { runGenerateContractCommand } from "./commands/generate-contract.js"; import { runMigrateColorPolicyCommand } from "./commands/migrate-color-policy.js"; import { runValidateExtractedCommand } from "./commands/validate-extracted.js"; import { runDescribeCommand } from "./commands/describe.js"; +import { runPrepareGenerationCommand } from "./commands/prepare-generation.js"; +import { runValidateGenerationCommand } from "./commands/validate-generation.js"; +import { runServeGenerationAdapterCommand } from "./commands/serve-generation-adapter.js"; import { runInitCommand } from "./commands/init.js"; import { runAnalyzeCommand } from "./commands/analyze.js"; import { @@ -285,6 +288,66 @@ program process.exitCode = exitCode; }); +program + .command("prepare-generation") + .description("Resolve a compiled generation bundle into one agent-ready JSON payload") + .requiredOption("--bundle-root ", "Path to the compiled generation bundle directory") + .requiredOption("--surface ", "Surface identifier") + .option("--out ", "Write the prepared JSON payload to the provided file") + .action(async (options) => { + process.exitCode = await runPrepareGenerationCommand({ + bundleRoot: options.bundleRoot, + surfaceId: options.surface, + outPath: options.out, + }); + }); + +program + .command("validate-generation") + .description("Validate generated UI against a compiled generation bundle") + .requiredOption("--tool ", "Generator tool identifier") + .requiredOption("--surface ", "Surface identifier") + .requiredOption("--mode ", "Validation mode") + .requiredOption("--bundle-root ", "Path to the compiled generation bundle directory") + .option("--workspace-root ", "Required when mode=workspace") + .option("--descriptor-path ", "Required when mode=descriptor") + .option("--descriptor-parity-config ", "Optional descriptor parity config path") + .option("--out ", "Write output JSON to the provided file") + .option("--request-id ", "Optional request identifier") + .action(async (options) => { + process.exitCode = await runValidateGenerationCommand({ + tool: options.tool, + surfaceId: options.surface, + mode: options.mode, + bundleRoot: options.bundleRoot, + workspaceRoot: options.workspaceRoot, + descriptorPath: options.descriptorPath, + descriptorParityConfigPath: options.descriptorParityConfig, + outPath: options.out, + requestId: options.requestId, + }); + }); + +program + .command("serve-generation-adapter") + .description("Serve the generation adapter over HTTP for a compiled generation bundle") + .requiredOption("--bundle-root ", "Path to the compiled generation bundle directory") + .option("--host ", "Bind host (default: 127.0.0.1)") + .option("--port ", "Bind port (default: 7777)") + .option("--token ", "Optional auth token") + .option("--token-header ", "Token header name (default: x-surfaces-adapter-token)") + .option("--descriptor-parity-config ", "Optional descriptor parity config path") + .action(async (options) => { + await runServeGenerationAdapterCommand({ + host: options.host, + port: options.port ? Number(options.port) : undefined, + token: options.token, + tokenHeader: options.tokenHeader, + bundleRoot: options.bundleRoot, + descriptorParityConfigPath: options.descriptorParityConfig, + }); + }); + program .command("generate-contract") .description("Extract a contract from a Next.js app (Phase 0: extraction only)") diff --git a/packages/interfacectl-cli/test/compile.test.mjs b/packages/interfacectl-cli/test/compile.test.mjs index 0967995..cda0591 100644 --- a/packages/interfacectl-cli/test/compile.test.mjs +++ b/packages/interfacectl-cli/test/compile.test.mjs @@ -11,6 +11,7 @@ import { readdir, rm, stat, + writeFile, } from "node:fs/promises"; const __filename = fileURLToPath(import.meta.url); @@ -55,6 +56,10 @@ async function readJson(p) { return JSON.parse(raw); } +async function assertNoPath(targetPath) { + await assert.rejects(stat(targetPath), /ENOENT/); +} + test("compile: structure - required files exist and no extra files", async () => { const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-structure-")); try { @@ -62,16 +67,20 @@ test("compile: structure - required files exist and no extra files", async () => 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.bundleVersion, "2.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); + assert.ok(manifest.files.length >= 6); 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"); + assert.ok(paths.includes("contract/normalized.json"), "bundle must include contract/normalized.json"); + assert.ok(paths.includes("surfaces/demo-surface/generation.json"), "bundle must include generation.json"); + assert.ok(paths.includes("surfaces/demo-surface/sections.json"), "bundle must include sections.json"); + assert.ok(paths.includes("surfaces/demo-surface/components.json"), "bundle must include components.json"); + assert.ok(paths.includes("surfaces/demo-surface/constraints.json"), "bundle must include constraints.json"); + assert.ok(paths.includes("surfaces/demo-surface/repair-map.json"), "bundle must include repair-map.json"); + assert.ok(!paths.includes("surfaces/demo-surface/authoring.json"), "authoring.json should be omitted when authoring is absent"); for (const entry of manifest.files) { assert.ok(!entry.path.includes("manifest.json"), "files must not include manifest.json"); @@ -81,22 +90,19 @@ test("compile: structure - required files exist and no extra files", async () => const sortedPaths = [...paths].sort(); assert.deepEqual(paths, sortedPaths, "manifest.files must be sorted by path"); - const contractNorm = path.join(outDir, "contract.normalized.json"); + 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"); + assert.ok(contractStat.isFile(), "contract/normalized.json must be a file"); + + const surfaceDir = path.join(outDir, "surfaces", "demo-surface"); + const surfaceDirStat = await stat(surfaceDir); + assert.ok(surfaceDirStat.isDirectory(), "surface bundle directory must exist"); + const surfaceFiles = await readdir(surfaceDir); + assert.deepEqual( + surfaceFiles.sort(), + ["components.json", "constraints.json", "generation.json", "repair-map.json", "sections.json"], + "surface bundle should only include the expected generation files for the base fixture", + ); } finally { await rm(outDir, { recursive: true, force: true }); } @@ -125,23 +131,190 @@ test("compile: determinism - two runs produce identical manifest.files", async ( } }); -test("compile: golden - generated contract.normalized.json and surface/constraint files match expected", async () => { +test("compile: golden - generated generation bundle 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 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"); + for (const filename of ["generation.json", "sections.json", "components.json", "constraints.json", "repair-map.json"]) { + const expected = await readJson(path.join(expectedDir, "surfaces", "demo-surface", filename)); + const generated = await readJson(path.join(outDir, "surfaces", "demo-surface", filename)); + assert.deepEqual(generated, expected, `${filename} must match expected`); + } + } finally { + await rm(outDir, { recursive: true, force: true }); + } +}); - 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"); +test("compile: includes component catalog refs, authoring hints, and observation refs without inline evidence", async () => { + const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-rich-")); + const richContractPath = path.join(outDir, "rich-contract.json"); + try { + await writeFile( + richContractPath, + JSON.stringify( + { + contractId: "rich-demo", + version: "1.0.0", + shell: { + owns: ["header", "footer"], + contentSlot: "main-content", + }, + surfaces: [ + { + id: "demo-surface", + displayName: "Demo Surface", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter", "var(--font-body)"], + marketingTypographyProfile: "marketing-core", + marketingTypographyPolicy: "warn", + layout: { + maxContentWidth: 960, + requiredContainers: ["contract-container"], + landingPattern: { + policy: "warn", + requireTopLevelSections: ["main.hero"], + sectionOrder: ["main.hero", "main.cta"], + pageBackgroundMode: "solid", + marketingLayoutProfile: "marketing-landing", + marketingLayoutPolicy: "warn", + }, + }, + authoring: { + framework: "next", + routing: "app-router", + styling: { + strategy: "css-modules", + tokenPrefix: "--demo", + }, + preferredLibraries: { + components: ["@surfaces/ui"], + icons: ["lucide-react"], + }, + sourcePriority: ["contract", "code"], + }, + }, + ], + sections: [ + { + id: "main.hero", + intent: "primary-intro", + description: "Hero section", + anatomy: { + pattern: "hero", + defaultComponent: "hero-banner", + allowedComponents: ["hero-banner", "cta-group"], + slots: [ + { + id: "actions", + kind: "container", + required: true, + acceptsComponents: ["cta-group"], + }, + ], + }, + }, + { + id: "main.cta", + intent: "conversion", + description: "CTA section", + }, + ], + components: [ + { + id: "hero-banner", + intent: "hero", + slots: [ + { id: "title", kind: "text", required: true }, + ], + implementation: { + preferredSource: "contract", + }, + }, + { + id: "cta-group", + intent: "actions", + slots: [ + { id: "primary", kind: "action", required: true }, + ], + references: [ + { system: "code", kind: "component", ref: "app/components/cta-group.tsx" }, + ], + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: ["#ffffff", "#111111"], + }, + marketingProfiles: { + layout: [ + { + id: "marketing-landing", + heroContainerMode: "framed", + heroVisualPlacement: "inline-end", + sectionDividerMode: "border-top", + sectionSpacingProfile: "roomy", + }, + ], + typography: [ + { + id: "marketing-core", + roles: [ + { + role: "heroTitle", + allowedTokens: ["var(--font-body)"], + }, + ], + }, + ], + }, + x_extracted: { + routes: ["/", "/pricing"], + hasShell: true, + designSystemComponents: ["HeroBanner", "CtaGroup"], + }, + }, + null, + 2, + ), + "utf8", + ); + + const result = await runCompile(richContractPath, outDir); + assert.equal(result.exitCode, 0, `compile should exit 0: ${result.stderr}`); + + const authoring = await readJson(path.join(outDir, "surfaces", "demo-surface", "authoring.json")); + assert.equal(authoring.authoring.framework, "next"); + assert.deepEqual(authoring.authoring.sourcePriority, ["contract", "code"]); + + const components = await readJson(path.join(outDir, "surfaces", "demo-surface", "components.json")); + assert.deepEqual( + components.components.map((component) => component.id), + ["hero-banner", "cta-group"], + ); + + const sections = await readJson(path.join(outDir, "surfaces", "demo-surface", "sections.json")); + assert.equal(sections.sections[0].anatomy.defaultComponentId, "hero-banner"); + assert.deepEqual(sections.sections[0].anatomy.allowedComponentIds, ["hero-banner", "cta-group"]); + assert.deepEqual(sections.sections[0].anatomy.slots[0].acceptsComponentIds, ["cta-group"]); + + const generation = await readJson(path.join(outDir, "surfaces", "demo-surface", "generation.json")); + assert.equal(generation.refs.authoring, "./authoring.json"); + assert.deepEqual(generation.refs.evidence, [{ kind: "contract-field", path: "/x_extracted" }]); + assert.equal("x_extracted" in generation, false, "generation payload must not inline x_extracted evidence"); + assert.equal("observations" in generation, false, "generation payload must not inline observation evidence"); } finally { await rm(outDir, { recursive: true, force: true }); } @@ -151,7 +324,6 @@ test("compile: includes surface icons policy when present in contract", async () const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-icons-")); const contractWithIconsPath = path.join(outDir, "contract-with-icons.json"); try { - const { writeFile } = await import("node:fs/promises"); await writeFile( contractWithIconsPath, JSON.stringify( @@ -195,10 +367,10 @@ test("compile: includes surface icons policy when present in contract", async () const result = await runCompile(contractWithIconsPath, outDir); assert.equal(result.exitCode, 0, `compile should exit 0: ${result.stderr}`); - const generatedSurface = await readJson( - path.join(outDir, "surfaces", "demo-surface.json"), + const generatedConstraints = await readJson( + path.join(outDir, "surfaces", "demo-surface", "constraints.json"), ); - assert.deepEqual(generatedSurface.icons, { + assert.deepEqual(generatedConstraints.constraints.icons, { policy: "warn", allowedSources: ["lucide-react"], }); @@ -211,7 +383,6 @@ test("compile: includes surface flow policy when present in contract", async () const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-flows-")); const contractWithFlowsPath = path.join(outDir, "contract-with-flows.json"); try { - const { writeFile } = await import("node:fs/promises"); await writeFile( contractWithFlowsPath, JSON.stringify( @@ -263,31 +434,34 @@ test("compile: includes surface flow policy when present in contract", async () const result = await runCompile(contractWithFlowsPath, outDir); assert.equal(result.exitCode, 0, `compile should exit 0: ${result.stderr}`); - const generatedSurface = await readJson( - path.join(outDir, "surfaces", "demo-surface.json"), + const generation = await readJson( + path.join(outDir, "surfaces", "demo-surface", "generation.json"), ); - assert.deepEqual(generatedSurface.flows, { + assert.deepEqual(generation.structure.flowSummary, { policy: "warn", - requirements: [ - { - flowId: "checkout", - minSteps: 2, - requiredSteps: ["start", "review"], - requiredTransitions: [{ from: "start", to: "review" }], - terminalSteps: ["review"], - }, - ], + flowIds: ["checkout"], + requirementCount: 1, }); } finally { await rm(outDir, { recursive: true, force: true }); } }); +test("compile: omits authoring.json when authoring metadata is absent", async () => { + const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-no-authoring-")); + try { + const result = await runCompile(contractPath, outDir); + assert.equal(result.exitCode, 0, `compile should exit 0: ${result.stderr}`); + await assertNoPath(path.join(outDir, "surfaces", "demo-surface", "authoring.json")); + } 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({ @@ -310,7 +484,6 @@ 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({ diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/constraints/motion.json b/packages/interfacectl-cli/test/fixtures/compile/expected/constraints/motion.json deleted file mode 100644 index d4d3deb..0000000 --- a/packages/interfacectl-cli/test/fixtures/compile/expected/constraints/motion.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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 similarity index 90% rename from packages/interfacectl-cli/test/fixtures/compile/expected/contract.normalized.json rename to packages/interfacectl-cli/test/fixtures/compile/expected/contract/normalized.json index 5251d60..73f1d12 100644 --- a/packages/interfacectl-cli/test/fixtures/compile/expected/contract.normalized.json +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/contract/normalized.json @@ -1,4 +1,8 @@ { + "color": { + "allowedValues": [], + "policy": "off" + }, "constraints": { "motion": { "allowedDurationsMs": [ @@ -35,9 +39,5 @@ "type": "web" } ], - "version": "1.0.0", - "color": { - "policy": "off", - "allowedValues": [] - } + "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 index 03bebcd..35ee0d4 100644 --- a/packages/interfacectl-cli/test/fixtures/compile/expected/manifest.json +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/manifest.json @@ -1,5 +1,5 @@ { - "bundleVersion": "1.0", + "bundleVersion": "2.0", "contractId": "demo-ui", "contractVersion": "1.0.0", "schemaVersion": "surfaces.web.contract@1", @@ -13,15 +13,27 @@ }, "files": [ { - "path": "constraints/motion.json", + "path": "contract/normalized.json", "sha256": "" }, { - "path": "contract.normalized.json", + "path": "surfaces/demo-surface/components.json", "sha256": "" }, { - "path": "surfaces/demo-surface.json", + "path": "surfaces/demo-surface/constraints.json", + "sha256": "" + }, + { + "path": "surfaces/demo-surface/generation.json", + "sha256": "" + }, + { + "path": "surfaces/demo-surface/repair-map.json", + "sha256": "" + }, + { + "path": "surfaces/demo-surface/sections.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 deleted file mode 100644 index 819afcc..0000000 --- a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "allowedFonts": [ - "Demo Sans", - "sans-serif", - "var(--font-demo)" - ], - "displayName": "Demo Surface", - "id": "demo-surface", - "layout": { - "maxContentWidth": 960 - }, - "requiredSections": ["main.hero"], - "type": "web" -} diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/components.json b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/components.json new file mode 100644 index 0000000..9c4304f --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/components.json @@ -0,0 +1,9 @@ +{ + "components": [], + "provenance": { + "bundleVersion": "2.0", + "contractId": "demo-ui", + "contractVersion": "1.0.0", + "surfaceId": "demo-surface" + } +} diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/constraints.json b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/constraints.json new file mode 100644 index 0000000..e326e32 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/constraints.json @@ -0,0 +1,27 @@ +{ + "constraints": { + "color": { + "allowedValues": [], + "policy": "off" + }, + "layoutPolicy": { + "maxContentWidth": 960, + "requiredContainers": [] + }, + "marketingProfiles": {}, + "motion": { + "allowedDurationsMs": [ + 120 + ], + "allowedTimingFunctions": [ + "linear" + ] + } + }, + "provenance": { + "bundleVersion": "2.0", + "contractId": "demo-ui", + "contractVersion": "1.0.0", + "surfaceId": "demo-surface" + } +} diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/generation.json b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/generation.json new file mode 100644 index 0000000..31d4592 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/generation.json @@ -0,0 +1,113 @@ +{ + "boundary": { + "allowSources": [], + "contentSlot": null, + "mustNotEmit": [], + "shellOwns": [] + }, + "guidance": { + "boundaryRules": [ + { + "id": "shell-ownership", + "mustNotEmit": [], + "shellOwns": [] + } + ], + "doNotEmitRoles": [], + "generationFocusOrder": [ + "boundary", + "sections", + "layout" + ], + "priorities": [ + { + "id": "boundary", + "rank": 1 + }, + { + "id": "structure", + "rank": 2 + }, + { + "id": "layout", + "rank": 3 + }, + { + "id": "visual", + "rank": 4 + }, + { + "id": "interaction", + "rank": 5 + } + ], + "requiredOutputs": [ + { + "required": true, + "type": "surface-content" + }, + { + "required": true, + "sectionId": "main.hero", + "type": "section" + } + ] + }, + "identity": { + "displayName": "Demo Surface", + "surfaceId": "demo-surface", + "type": "web" + }, + "layout": { + "maxContentWidth": 960, + "requiredContainers": [], + "viewportIds": [] + }, + "provenance": { + "bundleVersion": "2.0", + "contractId": "demo-ui", + "contractVersion": "1.0.0", + "surfaceId": "demo-surface" + }, + "refs": { + "components": "./components.json", + "constraints": "./constraints.json", + "contract": "../../contract/normalized.json", + "repairMap": "./repair-map.json", + "sections": "./sections.json" + }, + "structure": { + "flowSummary": null, + "requiredSectionIds": [ + "main.hero" + ], + "sectionCount": 1, + "sectionOrder": [], + "topLevelSectionIds": [] + }, + "visual": { + "allowedFonts": [ + "Demo Sans", + "sans-serif", + "var(--font-demo)" + ], + "color": { + "allowedValuesCount": 0, + "policy": "off" + }, + "icons": null, + "motion": { + "allowedDurationsMs": [ + 120 + ], + "allowedTimingFunctions": [ + "linear" + ] + }, + "typography": { + "marketingPolicy": "off", + "marketingProfileId": null, + "tokenPolicyCategories": [] + } + } +} diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/repair-map.json b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/repair-map.json new file mode 100644 index 0000000..1dc8a04 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/repair-map.json @@ -0,0 +1,76 @@ +{ + "provenance": { + "bundleVersion": "2.0", + "contractId": "demo-ui", + "contractVersion": "1.0.0", + "surfaceId": "demo-surface" + }, + "repairs": [ + { + "action": { + "sectionIds": [ + "main.hero" + ], + "type": "ensure-required-sections" + }, + "category": "structure", + "code": "section.missing", + "priority": "high" + }, + { + "action": { + "allowedSectionIds": [ + "main.hero" + ], + "type": "restrict-section-set" + }, + "category": "structure", + "code": "section.unexpected", + "priority": "medium" + }, + { + "action": { + "allowedFonts": [ + "Demo Sans", + "sans-serif", + "var(--font-demo)" + ], + "type": "restrict-fonts" + }, + "category": "visual", + "code": "font.disallowed", + "priority": "medium" + }, + { + "action": { + "allowedDurationsMs": [ + 120 + ], + "type": "restrict-motion-durations" + }, + "category": "visual", + "code": "motion.duration", + "priority": "medium" + }, + { + "action": { + "allowedTimingFunctions": [ + "linear" + ], + "type": "restrict-motion-timing" + }, + "category": "visual", + "code": "motion.timing", + "priority": "medium" + }, + { + "action": { + "maxContentWidth": 960, + "type": "reduce-content-width" + }, + "category": "layout", + "code": "layout.width-exceeded", + "priority": "medium" + } + ] +} diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/sections.json b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/sections.json new file mode 100644 index 0000000..d7421a0 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/sections.json @@ -0,0 +1,16 @@ +{ + "provenance": { + "bundleVersion": "2.0", + "contractId": "demo-ui", + "contractVersion": "1.0.0", + "surfaceId": "demo-surface" + }, + "sections": [ + { + "description": "Demo hero section", + "id": "main.hero", + "intent": "primary-intro", + "requiredBySurface": true + } + ] +} diff --git a/packages/interfacectl-cli/test/generation-adapter.test.mjs b/packages/interfacectl-cli/test/generation-adapter.test.mjs new file mode 100644 index 0000000..2acff37 --- /dev/null +++ b/packages/interfacectl-cli/test/generation-adapter.test.mjs @@ -0,0 +1,433 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const cliPath = path.resolve(__dirname, "..", "dist", "index.js"); +const corePath = path.resolve(__dirname, "..", "dist", "adapter", "core.js"); +const serverCommandPath = path.resolve( + __dirname, + "..", + "dist", + "commands", + "serve-generation-adapter.js", +); +const minimalProjectRoot = path.resolve(__dirname, "fixtures", "minimal-project"); +const minimalProjectContract = path.join(minimalProjectRoot, "contracts", "ui.contract.json"); + +async function importDist(modulePath) { + return import(pathToFileURL(modulePath).href); +} + +async function runCli(args, cwd = __dirname) { + const child = spawn("node", [cliPath, ...args], { + cwd, + env: process.env, + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const [exitCode] = await once(child, "exit"); + return { + exitCode: Number(exitCode), + stdout, + stderr, + }; +} + +async function compileBundle(contractPath, outDir, cwd = path.dirname(contractPath)) { + const result = await runCli( + ["compile", "--contract", contractPath, "--out", outDir], + cwd, + ); + assert.equal(result.exitCode, 0, `compile should exit 0: ${result.stderr}`); + return outDir; +} + +async function writeJson(filePath, value) { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function buildContract(overrides = {}) { + return { + contractId: "adapter-demo", + version: "1.0.0", + surfaces: [ + { + id: "demo-surface", + displayName: "Demo Surface", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Demo Sans", "sans-serif", "var(--font-demo)"], + layout: { + maxContentWidth: 960, + }, + ...overrides.surface, + }, + ], + sections: [ + { + id: "main.hero", + intent: "primary-intro", + description: "Demo hero section", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "off", + allowedValues: [], + }, + ...overrides.contract, + }; +} + +function buildDescriptor(overrides = {}) { + return [ + { + surfaceId: "demo-surface", + primitives: [], + colors: [], + ...overrides, + }, + ]; +} + +test("generation adapter: workspace mode uses contract/normalized.json from the bundle", async () => { + const { runGenerationAdapter } = await importDist(corePath); + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-workspace-")); + const contractCopyPath = path.join(tempDir, "ui.contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await fsp.copyFile(minimalProjectContract, contractCopyPath); + await compileBundle(contractCopyPath, bundleRoot, tempDir); + await fsp.writeFile(contractCopyPath, "{invalid json", "utf8"); + + const response = await runGenerationAdapter({ + tool: "codex", + surfaceId: "demo-surface", + mode: "workspace", + bundleRoot, + workspaceRoot: minimalProjectRoot, + }); + + assert.equal(response.status, "pass"); + assert.equal(response.contract.id, "portable.fixture"); + assert.equal(response.bundle.version, "2.0"); + assert.equal( + response.bundle.manifestPath, + path.join(bundleRoot, "manifest.json"), + ); + assert.equal( + response.bundle.surfacePath, + path.join(bundleRoot, "surfaces", "demo-surface", "generation.json"), + ); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("generation adapter: descriptor mode loads a valid bundle and returns bundle provenance", async () => { + const { runGenerationAdapter } = await importDist(corePath); + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-descriptor-")); + const contractPath = path.join(tempDir, "contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson(contractPath, buildContract()); + await compileBundle(contractPath, bundleRoot, tempDir); + + const response = await runGenerationAdapter({ + tool: "cursor", + surfaceId: "demo-surface", + mode: "descriptor", + bundleRoot, + descriptor: buildDescriptor(), + }); + + assert.equal(response.status, "pass"); + assert.equal(response.contract.id, "adapter-demo"); + assert.equal(response.contract.version, "1.0.0"); + assert.equal(response.bundle.version, "2.0"); + assert.equal(response.findings.length, 0); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("generation adapter: rejects missing bundle manifest", async () => { + const { runGenerationAdapter, AdapterInputError } = await importDist(corePath); + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-missing-manifest-")); + + try { + await assert.rejects( + runGenerationAdapter({ + tool: "codex", + surfaceId: "demo-surface", + mode: "descriptor", + bundleRoot: tempDir, + descriptor: buildDescriptor(), + }), + (error) => + error instanceof AdapterInputError && + /Bundle manifest file not found/.test(error.message), + ); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("generation adapter: rejects unsupported bundle version", async () => { + const { runGenerationAdapter, AdapterInputError } = await importDist(corePath); + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-bundle-version-")); + const contractPath = path.join(tempDir, "contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson(contractPath, buildContract()); + await compileBundle(contractPath, bundleRoot, tempDir); + const manifestPath = path.join(bundleRoot, "manifest.json"); + const manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8")); + manifest.bundleVersion = "1.0"; + await writeJson(manifestPath, manifest); + + await assert.rejects( + runGenerationAdapter({ + tool: "codex", + surfaceId: "demo-surface", + mode: "descriptor", + bundleRoot, + descriptor: buildDescriptor(), + }), + (error) => + error instanceof AdapterInputError && + error.code === "adapter.bundle.version-unsupported", + ); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("generation adapter: rejects missing surface bundle entry", async () => { + const { runGenerationAdapter, AdapterInputError } = await importDist(corePath); + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-missing-surface-")); + const contractPath = path.join(tempDir, "contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson(contractPath, buildContract()); + await compileBundle(contractPath, bundleRoot, tempDir); + + await assert.rejects( + runGenerationAdapter({ + tool: "codex", + surfaceId: "missing-surface", + mode: "descriptor", + bundleRoot, + descriptor: buildDescriptor({ surfaceId: "missing-surface" }), + }), + (error) => + error instanceof AdapterInputError && + /Surface bundle directory not found/.test(error.message), + ); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("generation adapter: rejects missing sibling ref files", async () => { + const { runGenerationAdapter, AdapterInputError } = await importDist(corePath); + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-missing-ref-")); + const contractPath = path.join(tempDir, "contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson(contractPath, buildContract()); + await compileBundle(contractPath, bundleRoot, tempDir); + await fsp.unlink( + path.join(bundleRoot, "surfaces", "demo-surface", "sections.json"), + ); + + await assert.rejects( + runGenerationAdapter({ + tool: "codex", + surfaceId: "demo-surface", + mode: "descriptor", + bundleRoot, + descriptor: buildDescriptor(), + }), + (error) => + error instanceof AdapterInputError && + /sections bundle file not found/i.test(error.message), + ); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("generation adapter: rejects legacy contractPath request field", async () => { + const { runGenerationAdapter, AdapterInputError } = await importDist(corePath); + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-legacy-contract-")); + const contractPath = path.join(tempDir, "contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson(contractPath, buildContract()); + await compileBundle(contractPath, bundleRoot, tempDir); + + await assert.rejects( + runGenerationAdapter({ + tool: "codex", + surfaceId: "demo-surface", + mode: "descriptor", + bundleRoot, + contractPath, + descriptor: buildDescriptor(), + }), + (error) => + error instanceof AdapterInputError && + error.code === "adapter.input.legacy-contract-path", + ); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("generation adapter CLI maps block findings to exit code 30", async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-cli-block-")); + const contractPath = path.join(tempDir, "contract.json"); + const descriptorPath = path.join(tempDir, "descriptor.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson( + contractPath, + buildContract({ + contract: { + shell: { + owns: ["navigation"], + }, + }, + }), + ); + await writeJson( + descriptorPath, + buildDescriptor({ + primitives: [ + { + role: "navigation", + count: 1, + sources: ["generated.tsx"], + }, + ], + }), + ); + await compileBundle(contractPath, bundleRoot, tempDir); + + const result = await runCli( + [ + "validate-generation", + "--tool", + "codex", + "--surface", + "demo-surface", + "--mode", + "descriptor", + "--bundle-root", + bundleRoot, + "--descriptor-path", + descriptorPath, + ], + tempDir, + ); + + assert.equal(result.exitCode, 30); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "block"); + assert.equal(payload.bundle.version, "2.0"); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("generation adapter server returns HTTP 422 for block findings", async () => { + const { createGenerationAdapterServer } = await importDist(serverCommandPath); + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-server-block-")); + const contractPath = path.join(tempDir, "contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson( + contractPath, + buildContract({ + contract: { + shell: { + owns: ["navigation"], + }, + }, + }), + ); + await compileBundle(contractPath, bundleRoot, tempDir); + + const server = createGenerationAdapterServer({ + host: "127.0.0.1", + bundleRoot, + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert.ok(address && typeof address === "object"); + + const response = await fetch( + `http://127.0.0.1:${address.port}/surfaces.validateGeneration`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + requestId: "req-1", + tool: "codex", + surfaceId: "demo-surface", + mode: "descriptor", + descriptor: buildDescriptor({ + primitives: [ + { + role: "navigation", + count: 1, + sources: ["generated.tsx"], + }, + ], + }), + }), + }, + ); + + assert.equal(response.status, 422); + const payload = await response.json(); + assert.equal(payload.status, "block"); + assert.equal(payload.contract.id, "adapter-demo"); + + await new Promise((resolve) => server.close(resolve)); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/interfacectl-cli/test/prepare-generation.test.mjs b/packages/interfacectl-cli/test/prepare-generation.test.mjs new file mode 100644 index 0000000..e56f3fa --- /dev/null +++ b/packages/interfacectl-cli/test/prepare-generation.test.mjs @@ -0,0 +1,350 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const cliPath = path.resolve(__dirname, "..", "dist", "index.js"); + +async function runCli(args, cwd = __dirname) { + const child = spawn("node", [cliPath, ...args], { + cwd, + env: process.env, + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const [exitCode] = await once(child, "exit"); + return { + exitCode: Number(exitCode), + stdout, + stderr, + }; +} + +async function writeJson(filePath, value) { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function buildContract(overrides = {}) { + return { + contractId: "prepare-demo", + version: "1.0.0", + shell: { + owns: ["header", "footer"], + contentSlot: "main-content", + }, + surfaces: [ + { + id: "demo-surface", + displayName: "Demo Surface", + type: "web", + requiredSections: ["main.hero"], + mustNotEmit: ["header"], + allowedFonts: ["Demo Sans", "sans-serif", "var(--font-demo)"], + layout: { + maxContentWidth: 960, + requiredContainers: ["contract-container"], + }, + authoring: { + framework: "next", + routing: "app-router", + styling: { + strategy: "css-modules", + tokenPrefix: "--demo", + }, + preferredLibraries: { + components: ["@surfaces/ui"], + icons: ["lucide-react"], + }, + sourcePriority: ["contract", "code"], + }, + ...overrides.surface, + }, + ], + sections: [ + { + id: "main.hero", + intent: "primary-intro", + description: "Demo hero section", + anatomy: { + pattern: "hero", + defaultComponent: "hero-banner", + allowedComponents: ["hero-banner"], + }, + }, + ], + components: [ + { + id: "hero-banner", + intent: "hero", + slots: [ + { id: "title", kind: "text", required: true }, + ], + implementation: { + preferredSource: "contract", + }, + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: ["#ffffff", "#111111"], + }, + x_extracted: { + evidenceMarker: "EXTRACTED-SHOULD-STAY-BY-REF", + }, + ...overrides.contract, + }; +} + +async function compileBundle(contractPath, outDir, cwd = path.dirname(contractPath)) { + const result = await runCli( + ["compile", "--contract", contractPath, "--out", outDir], + cwd, + ); + assert.equal(result.exitCode, 0, `compile should exit 0: ${result.stderr}`); + return outDir; +} + +function buildDescriptor(overrides = {}) { + return [ + { + surfaceId: "demo-surface", + primitives: [], + colors: [], + ...overrides, + }, + ]; +} + +test("prepare-generation: emits resolved payload with summary, provenance, authoring, and evidence refs", async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-prepare-generation-rich-")); + const contractPath = path.join(tempDir, "contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson(contractPath, buildContract()); + await compileBundle(contractPath, bundleRoot, tempDir); + + const result = await runCli( + ["prepare-generation", "--bundle-root", bundleRoot, "--surface", "demo-surface"], + tempDir, + ); + + assert.equal(result.exitCode, 0, result.stderr); + const payload = JSON.parse(result.stdout); + + assert.equal(payload.surface.surfaceId, "demo-surface"); + assert.equal(payload.bundle.version, "2.0"); + assert.equal(payload.bundle.manifestPath, path.join(bundleRoot, "manifest.json")); + assert.equal(payload.bundle.sourcePaths.contract, path.join(bundleRoot, "contract", "normalized.json")); + assert.equal(payload.contract.id, "prepare-demo"); + assert.equal(payload.contract.version, "1.0.0"); + assert.equal(payload.contract.normalizedPath, path.join(bundleRoot, "contract", "normalized.json")); + assert.equal(payload.summary.focusOrder[0], "boundary"); + assert.ok(payload.summary.text.includes("Focus on"), "summary should include human-readable text"); + assert.ok(Array.isArray(payload.summary.checklist)); + assert.deepEqual(payload.generation.boundary.shellOwns, ["header", "footer"]); + assert.equal(payload.sections.length, 1); + assert.equal(payload.components.length, 1); + assert.equal(payload.constraints.color.policy, "warn"); + assert.equal(payload.repairMap[0].code, "shell.primitive.disallowed"); + assert.equal(payload.authoring.framework, "next"); + assert.deepEqual(payload.evidenceRefs, [{ kind: "contract-field", path: "/x_extracted" }]); + + const serialized = JSON.stringify(payload); + assert.ok(!serialized.includes("EXTRACTED-SHOULD-STAY-BY-REF")); + assert.equal(payload.refs, undefined); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("prepare-generation: --out writes the payload file and suppresses stdout", async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-prepare-generation-out-")); + const contractPath = path.join(tempDir, "contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + const outPath = path.join(tempDir, "prepared", "demo-surface.json"); + + try { + await writeJson(contractPath, buildContract()); + await compileBundle(contractPath, bundleRoot, tempDir); + + const result = await runCli( + [ + "prepare-generation", + "--bundle-root", + bundleRoot, + "--surface", + "demo-surface", + "--out", + outPath, + ], + tempDir, + ); + + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.stdout, ""); + const written = JSON.parse(await fsp.readFile(outPath, "utf8")); + assert.equal(written.surface.surfaceId, "demo-surface"); + assert.equal(written.bundle.sourcePaths.generation, path.join(bundleRoot, "surfaces", "demo-surface", "generation.json")); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("prepare-generation: repeated runs on the same bundle are byte-stable", async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-prepare-generation-determinism-")); + const contractPath = path.join(tempDir, "contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson(contractPath, buildContract()); + await compileBundle(contractPath, bundleRoot, tempDir); + + const first = await runCli( + ["prepare-generation", "--bundle-root", bundleRoot, "--surface", "demo-surface"], + tempDir, + ); + const second = await runCli( + ["prepare-generation", "--bundle-root", bundleRoot, "--surface", "demo-surface"], + tempDir, + ); + + assert.equal(first.exitCode, 0, first.stderr); + assert.equal(second.exitCode, 0, second.stderr); + assert.equal(first.stdout, second.stdout); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("prepare-generation: rejects missing manifest, unsupported bundle versions, missing surface files, and unreadable normalized contracts", async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-prepare-generation-errors-")); + const contractPath = path.join(tempDir, "contract.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson(contractPath, buildContract()); + await compileBundle(contractPath, bundleRoot, tempDir); + + const missingManifestDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-prepare-generation-no-manifest-")); + try { + const missingManifest = await runCli( + ["prepare-generation", "--bundle-root", missingManifestDir, "--surface", "demo-surface"], + tempDir, + ); + assert.equal(missingManifest.exitCode, 10); + assert.match(missingManifest.stderr, /Bundle manifest file not found/i); + } finally { + await fsp.rm(missingManifestDir, { recursive: true, force: true }); + } + + const missingSurface = await runCli( + ["prepare-generation", "--bundle-root", bundleRoot, "--surface", "missing-surface"], + tempDir, + ); + assert.equal(missingSurface.exitCode, 10); + assert.match(missingSurface.stderr, /Surface bundle directory not found/i); + + const manifestPath = path.join(bundleRoot, "manifest.json"); + const manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8")); + manifest.bundleVersion = "1.0"; + await writeJson(manifestPath, manifest); + const unsupported = await runCli( + ["prepare-generation", "--bundle-root", bundleRoot, "--surface", "demo-surface"], + tempDir, + ); + assert.equal(unsupported.exitCode, 10); + assert.match(unsupported.stderr, /Unsupported bundle version/i); + + manifest.bundleVersion = "2.0"; + await writeJson(manifestPath, manifest); + await fsp.unlink(path.join(bundleRoot, "surfaces", "demo-surface", "sections.json")); + const missingSibling = await runCli( + ["prepare-generation", "--bundle-root", bundleRoot, "--surface", "demo-surface"], + tempDir, + ); + assert.equal(missingSibling.exitCode, 10); + assert.match(missingSibling.stderr, /sections bundle file not found/i); + + await compileBundle(contractPath, bundleRoot, tempDir); + await fsp.writeFile(path.join(bundleRoot, "contract", "normalized.json"), "{invalid json", "utf8"); + const unreadableContract = await runCli( + ["prepare-generation", "--bundle-root", bundleRoot, "--surface", "demo-surface"], + tempDir, + ); + assert.equal(unreadableContract.exitCode, 10); + assert.match(unreadableContract.stderr, /Failed to read Compiled contract JSON/i); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("prepare-generation: integration stays aligned with validate-generation provenance", async () => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-prepare-generation-integration-")); + const contractPath = path.join(tempDir, "contract.json"); + const descriptorPath = path.join(tempDir, "descriptor.json"); + const bundleRoot = path.join(tempDir, "bundle"); + + try { + await writeJson(contractPath, buildContract()); + await writeJson(descriptorPath, buildDescriptor()); + await compileBundle(contractPath, bundleRoot, tempDir); + + const prepared = await runCli( + ["prepare-generation", "--bundle-root", bundleRoot, "--surface", "demo-surface"], + tempDir, + ); + const validated = await runCli( + [ + "validate-generation", + "--tool", + "codex", + "--surface", + "demo-surface", + "--mode", + "descriptor", + "--bundle-root", + bundleRoot, + "--descriptor-path", + descriptorPath, + ], + tempDir, + ); + + assert.equal(prepared.exitCode, 0, prepared.stderr); + assert.equal(validated.exitCode, 0, validated.stderr); + + const preparedPayload = JSON.parse(prepared.stdout); + const validatedPayload = JSON.parse(validated.stdout); + + assert.equal(preparedPayload.bundle.version, validatedPayload.bundle.version); + assert.equal(preparedPayload.bundle.manifestPath, validatedPayload.bundle.manifestPath); + assert.equal(preparedPayload.contract.id, validatedPayload.contract.id); + assert.equal(preparedPayload.contract.version, validatedPayload.contract.version); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } +}); From b70dfc9975df2b0aee457590ff730045ddb447b4 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Thu, 12 Mar 2026 22:12:37 -0700 Subject: [PATCH 2/5] feat: add prepare-generation output schema --- docs/ai-tool-playbooks.md | 3 + .../prepare-generation-output.schema.json | 259 ++++++++++++++++++ .../test/prepare-generation.test.mjs | 18 ++ 3 files changed, 280 insertions(+) create mode 100644 packages/interfacectl-cli/schemas/prepare-generation-output.schema.json diff --git a/docs/ai-tool-playbooks.md b/docs/ai-tool-playbooks.md index fb56969..d6e31ce 100644 --- a/docs/ai-tool-playbooks.md +++ b/docs/ai-tool-playbooks.md @@ -29,6 +29,8 @@ Recommended flow: These tools should consume the prepared generation payload, not the raw contract or sibling bundle files directly. +When you want tracked iteration evidence around a local-agent loop, pair `prepare-generation` and `validate-generation` with a repo-local session harness that freezes one bundle revision, records each assessment, and aggregates recurring repair codes. + ## Hosted builders Examples: Lovable, Figma Make, or other tools that produce UI outside the repo. @@ -74,4 +76,5 @@ Recommended flow: - [AI Generator Adapter Quick Start](./ai-generator-adapter-quickstart.md) - [AI Generator Adapter API](./ai-generator-adapter-api.md) - [Generator-Aware Contract Consumption](./generator-consumption.md) +- `packages/interfacectl-cli/schemas/prepare-generation-output.schema.json` - [Shell Boundary Semantics](./shell-boundary.md) diff --git a/packages/interfacectl-cli/schemas/prepare-generation-output.schema.json b/packages/interfacectl-cli/schemas/prepare-generation-output.schema.json new file mode 100644 index 0000000..c228024 --- /dev/null +++ b/packages/interfacectl-cli/schemas/prepare-generation-output.schema.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/prepare-generation-output.schema.json", + "title": "PrepareGenerationOutput", + "type": "object", + "additionalProperties": false, + "required": [ + "surface", + "bundle", + "contract", + "summary", + "generation", + "sections", + "components", + "constraints", + "repairMap", + "evidenceRefs" + ], + "properties": { + "surface": { + "type": "object", + "additionalProperties": false, + "required": ["surfaceId", "displayName", "type"], + "properties": { + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "displayName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "minLength": 1 + } + } + }, + "bundle": { + "type": "object", + "additionalProperties": false, + "required": ["root", "version", "manifestPath", "sourcePaths"], + "properties": { + "root": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "manifestPath": { + "type": "string", + "minLength": 1 + }, + "sourcePaths": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "generation", + "sections", + "components", + "constraints", + "repairMap" + ], + "properties": { + "contract": { + "type": "string", + "minLength": 1 + }, + "generation": { + "type": "string", + "minLength": 1 + }, + "sections": { + "type": "string", + "minLength": 1 + }, + "components": { + "type": "string", + "minLength": 1 + }, + "constraints": { + "type": "string", + "minLength": 1 + }, + "repairMap": { + "type": "string", + "minLength": 1 + }, + "authoring": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "contract": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "normalizedPath"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "normalizedPath": { + "type": "string", + "minLength": 1 + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "text", + "focusOrder", + "requiredSectionIds", + "prohibitedRoles", + "checklist", + "topRepairs" + ], + "properties": { + "text": { + "type": "string", + "minLength": 1 + }, + "focusOrder": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "requiredSectionIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "prohibitedRoles": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "checklist": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "detail"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "detail": { + "type": "string", + "minLength": 1 + } + } + } + }, + "topRepairs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "priority", "category", "actionType"], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "priority": { + "type": "string", + "enum": ["high", "medium", "low"] + }, + "category": { + "type": "string", + "minLength": 1 + }, + "actionType": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "generation": { + "type": "object", + "additionalProperties": false, + "required": ["boundary", "structure", "layout", "visual", "guidance"], + "properties": { + "boundary": { + "type": "object" + }, + "structure": { + "type": "object" + }, + "layout": { + "type": "object" + }, + "visual": { + "type": "object" + }, + "guidance": { + "type": "object" + } + } + }, + "sections": { + "type": "array", + "items": { + "type": "object" + } + }, + "components": { + "type": "array", + "items": { + "type": "object" + } + }, + "constraints": { + "type": "object" + }, + "repairMap": { + "type": "array", + "items": { + "type": "object" + } + }, + "authoring": { + "type": "object" + }, + "evidenceRefs": { + "type": "array", + "items": { + "type": "object" + } + } + } +} diff --git a/packages/interfacectl-cli/test/prepare-generation.test.mjs b/packages/interfacectl-cli/test/prepare-generation.test.mjs index e56f3fa..973ea15 100644 --- a/packages/interfacectl-cli/test/prepare-generation.test.mjs +++ b/packages/interfacectl-cli/test/prepare-generation.test.mjs @@ -7,6 +7,8 @@ import path from "node:path"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { fileURLToPath } from "node:url"; +import { validateDiffOutput } from "@surfaces/interfacectl-validator"; +import prepareGenerationOutputSchema from "../schemas/prepare-generation-output.schema.json" with { type: "json" }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -41,6 +43,10 @@ async function writeJson(filePath, value) { await fsp.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } +function validatePreparedGenerationOutput(payload) { + return validateDiffOutput(payload, prepareGenerationOutputSchema); +} + function buildContract(overrides = {}) { return { contractId: "prepare-demo", @@ -172,6 +178,12 @@ test("prepare-generation: emits resolved payload with summary, provenance, autho assert.equal(payload.repairMap[0].code, "shell.primitive.disallowed"); assert.equal(payload.authoring.framework, "next"); assert.deepEqual(payload.evidenceRefs, [{ kind: "contract-field", path: "/x_extracted" }]); + const schemaValidation = validatePreparedGenerationOutput(payload); + assert.equal( + schemaValidation.ok, + true, + `prepare-generation output should satisfy schema: ${JSON.stringify(schemaValidation.errors)}`, + ); const serialized = JSON.stringify(payload); assert.ok(!serialized.includes("EXTRACTED-SHOULD-STAY-BY-REF")); @@ -209,6 +221,12 @@ test("prepare-generation: --out writes the payload file and suppresses stdout", const written = JSON.parse(await fsp.readFile(outPath, "utf8")); assert.equal(written.surface.surfaceId, "demo-surface"); assert.equal(written.bundle.sourcePaths.generation, path.join(bundleRoot, "surfaces", "demo-surface", "generation.json")); + const schemaValidation = validatePreparedGenerationOutput(written); + assert.equal( + schemaValidation.ok, + true, + `written prepare-generation output should satisfy schema: ${JSON.stringify(schemaValidation.errors)}`, + ); } finally { await fsp.rm(tempDir, { recursive: true, force: true }); } From 8fdc82c9464466e828132ca2a2339ef2d89045ad Mon Sep 17 00:00:00 2001 From: Mike Long Date: Thu, 12 Mar 2026 22:38:54 -0700 Subject: [PATCH 3/5] feat: own generation sessions and run artifacts --- API.md | 92 +++ README.md | 35 + docs/ai-generator-adapter-api.md | 2 +- docs/ai-generator-adapter-quickstart.md | 28 +- docs/ai-tool-playbooks.md | 12 +- docs/generator-consumption.md | 19 +- .../dist/commands/emit-run-artifact.d.ts | 16 + .../dist/commands/emit-run-artifact.d.ts.map | 1 + .../dist/commands/emit-run-artifact.js | 49 ++ .../dist/commands/generation-session.d.ts | 19 + .../dist/commands/generation-session.d.ts.map | 1 + .../dist/commands/generation-session.js | 440 ++++++++++++ .../dist/commands/prepare-generation.d.ts | 111 +++ .../dist/commands/prepare-generation.d.ts.map | 2 +- .../dist/commands/prepare-generation.js | 31 +- packages/interfacectl-cli/dist/index.js | 72 ++ .../dist/utils/deterministic-json.d.ts | 4 + .../dist/utils/deterministic-json.d.ts.map | 1 + .../dist/utils/deterministic-json.js | 17 + .../dist/utils/onboarding.d.ts | 5 +- .../dist/utils/onboarding.d.ts.map | 2 +- .../interfacectl-cli/dist/utils/onboarding.js | 81 +-- .../dist/utils/run-artifacts.d.ts | 73 ++ .../dist/utils/run-artifacts.d.ts.map | 1 + .../dist/utils/run-artifacts.js | 194 ++++++ .../schemas/contract-lineage.schema.json | 88 +++ .../schemas/contract-runs.schema.json | 123 ++++ .../schemas/generation-assessment.schema.json | 33 + .../generation-session-summary.schema.json | 215 ++++++ .../schemas/generation-session.schema.json | 83 +++ .../src/commands/emit-run-artifact.ts | 71 ++ .../src/commands/generation-session.ts | 635 ++++++++++++++++++ .../src/commands/prepare-generation.ts | 38 +- packages/interfacectl-cli/src/index.ts | 80 +++ .../src/utils/deterministic-json.ts | 20 + .../interfacectl-cli/src/utils/onboarding.ts | 140 +--- .../src/utils/run-artifacts.ts | 310 +++++++++ .../test/generation-session.test.mjs | 488 ++++++++++++++ .../interfacectl-cli/test/init-auth.test.mjs | 11 +- .../test/run-artifacts.test.mjs | 128 ++++ 40 files changed, 3499 insertions(+), 272 deletions(-) create mode 100644 packages/interfacectl-cli/dist/commands/emit-run-artifact.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/emit-run-artifact.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/emit-run-artifact.js create mode 100644 packages/interfacectl-cli/dist/commands/generation-session.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/generation-session.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/generation-session.js create mode 100644 packages/interfacectl-cli/dist/utils/deterministic-json.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/deterministic-json.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/deterministic-json.js create mode 100644 packages/interfacectl-cli/dist/utils/run-artifacts.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/run-artifacts.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/run-artifacts.js create mode 100644 packages/interfacectl-cli/schemas/contract-lineage.schema.json create mode 100644 packages/interfacectl-cli/schemas/contract-runs.schema.json create mode 100644 packages/interfacectl-cli/schemas/generation-assessment.schema.json create mode 100644 packages/interfacectl-cli/schemas/generation-session-summary.schema.json create mode 100644 packages/interfacectl-cli/schemas/generation-session.schema.json create mode 100644 packages/interfacectl-cli/src/commands/emit-run-artifact.ts create mode 100644 packages/interfacectl-cli/src/commands/generation-session.ts create mode 100644 packages/interfacectl-cli/src/utils/deterministic-json.ts create mode 100644 packages/interfacectl-cli/src/utils/run-artifacts.ts create mode 100644 packages/interfacectl-cli/test/generation-session.test.mjs create mode 100644 packages/interfacectl-cli/test/run-artifacts.test.mjs diff --git a/API.md b/API.md index d09a9ad..0a6d55a 100644 --- a/API.md +++ b/API.md @@ -635,6 +635,98 @@ The generated JSON document includes: --- +### `init-generation-session` + +Creates a tracked local-agent session from an already compiled bundle. The command copies the bundle into a session-local directory, prepares the canonical agent payload once, and writes `session.json`. + +**Synopsis:** +```bash +interfacectl init-generation-session --bundle-root --surface --workspace-root [--tool ] [--session ] [--artifacts-root ] +``` + +**Description:** +- Requires a compiled bundle and does not call `compile` implicitly. +- Defaults session artifacts to `/artifacts/generation-sessions///`. +- Writes `bundle/`, `prepared-input.json`, and `session.json`. +- Canonical session schema lives at `packages/interfacectl-cli/schemas/generation-session.schema.json`. + +**Exit Codes:** +- `0`: Session created successfully +- `10`: Invalid input, unreadable bundle, duplicate session, or unreadable workspace root +- `1`: Unexpected internal error + +--- + +### `record-generation-attempt` + +Records one operator-reviewed attempt for a tracked session. + +**Synopsis:** +```bash +interfacectl record-generation-attempt --session-dir --assessment-file +``` + +**Description:** +- Loads `session.json` from the provided session directory. +- Validates the workspace against the frozen session bundle. +- Writes: + - `attempts/.validate.json` + - `attempts/.assessment.json` + - `attempts/.metadata.json` +- Emits a canonical `generation` run into `contracts/generated/contract-runs.json` and rebuilds `contract-lineage.json`. +- Canonical assessment schema lives at `packages/interfacectl-cli/schemas/generation-assessment.schema.json`. + +**Exit Codes:** +- `0`: Attempt recorded successfully +- `10`: Invalid input, missing session, invalid assessment payload, or unreadable files +- `1`: Unexpected internal error + +--- + +### `summarize-generation-session` + +Summarizes recorded attempts for one session. + +**Synopsis:** +```bash +interfacectl summarize-generation-session --session-dir +``` + +**Description:** +- Aggregates attempt count, first pass attempt, latest status, recurring finding codes, recurring repair codes, and the latest assessment. +- Writes `summary.json` and `summary.md`. +- Canonical summary schema lives at `packages/interfacectl-cli/schemas/generation-session-summary.schema.json`. + +**Exit Codes:** +- `0`: Latest attempt is `pass` +- `30`: Latest attempt is `warn` or `block` +- `10`: Missing session or attempts, or invalid artifacts +- `1`: Unexpected internal error + +--- + +### `emit-run-artifact` + +Writes one canonical run record into `contracts/generated/contract-runs.json` and rebuilds `contract-lineage.json`. + +**Synopsis:** +```bash +interfacectl emit-run-artifact --workspace-root --surface --source --status [--contract ] [--extraction-path ] [--report-path ] [--finding-codes ] [--workspace-id ] [--idempotency-key ] [--created-at ] [--run-id ] +``` + +**Description:** +- Creates `contract-runs.json` and `contract-lineage.json` if they do not yet exist. +- Uses canonical schema files: + - `packages/interfacectl-cli/schemas/contract-runs.schema.json` + - `packages/interfacectl-cli/schemas/contract-lineage.schema.json` +- Dedupes by `(workspaceId, runId)` and `(workspaceId, idempotencyKey)` when provided. + +**Exit Codes:** +- `0`: Run artifact emitted successfully +- `10`: Invalid input + +--- + ### `generate-contract` (Phase 0) Extracts a deterministic contract artifact from a Next.js app by analyzing app code. **Extraction only** — no enforcement, no network calls. Output is schema-valid; extracted-only fields live under `x_extracted`. diff --git a/README.md b/README.md index 0a8979f..19ad963 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,38 @@ interfacectl prepare-generation --bundle-root --surface [--out Use this before local agent generation. Use `validate-generation` after code is written. +### `init-generation-session` + +Freezes one compiled bundle revision into a tracked local generation session under `artifacts/generation-sessions///` inside the workspace root. + +```bash +interfacectl init-generation-session --bundle-root --surface --workspace-root [--tool ] [--session ] [--artifacts-root ] +``` + +### `record-generation-attempt` + +Validates one tracked session against the frozen bundle, records the validate payload and assessment, and emits a canonical `contract-runs.json` / `contract-lineage.json` update into the workspace. + +```bash +interfacectl record-generation-attempt --session-dir --assessment-file +``` + +### `summarize-generation-session` + +Aggregates recorded attempts for one tracked session, writes `summary.json` and `summary.md`, and exits non-zero unless the latest attempt is `pass`. + +```bash +interfacectl summarize-generation-session --session-dir +``` + +### `emit-run-artifact` + +Writes one canonical run-artifact entry into `contracts/generated/contract-runs.json` and rebuilds `contract-lineage.json`. + +```bash +interfacectl emit-run-artifact --workspace-root --surface --source --status [--finding-codes ] +``` + ### `generate-contract` (Phase 0 expert command) Extracts a **deterministic contract artifact** from a Next.js app by analyzing app code and config. This is **contract extraction only** — no first-run classification, no design-system draft, and no onboarding summary. Prefer `interfacectl init` for the user-facing entry point. @@ -214,6 +246,9 @@ interfacectl separates interface governance into clear phases: - **Prepare generation** Resolves one surface from the bundle into a single local-agent payload. +- **Generation sessions** + Freezes one bundle revision, records local-agent attempts, and emits canonical run artifacts. + - **Runtime consumption (framing only)** Consumption semantics are documented in Phase 4. No runtime enforcement or loaders exist in this repo. diff --git a/docs/ai-generator-adapter-api.md b/docs/ai-generator-adapter-api.md index 5e7d50f..457aba5 100644 --- a/docs/ai-generator-adapter-api.md +++ b/docs/ai-generator-adapter-api.md @@ -2,7 +2,7 @@ This page defines the canonical request/response semantics for generator adapters built around `interfacectl` validation and the compiled generation bundle. -For local workspace agents, the expected pre-generation handoff is `interfacectl prepare-generation`. This document covers the post-generation validation contract. +For local workspace agents, the expected pre-generation handoff is `interfacectl prepare-generation`. Tracked session orchestration now also lives in `interfacectl` through `init-generation-session`, `record-generation-attempt`, and `summarize-generation-session`. This document covers the post-generation validation contract. JSON Schemas for this contract live in: diff --git a/docs/ai-generator-adapter-quickstart.md b/docs/ai-generator-adapter-quickstart.md index 765c1b4..fb9c512 100644 --- a/docs/ai-generator-adapter-quickstart.md +++ b/docs/ai-generator-adapter-quickstart.md @@ -6,9 +6,11 @@ Use this flow when a local agent or hosted generator needs contract-aware guidan 1. Compile the contract into a generation bundle. 2. For local agents, resolve the bundle into one agent-ready payload with `prepare-generation`. -3. Generate or edit UI. -4. Run `validate-generation` against the same bundle. -5. Repeat until the result is `pass` or an accepted `warn`. +3. Freeze one tracked session with `init-generation-session` when you want iteration evidence. +4. Generate or edit UI. +5. Run `record-generation-attempt` for each attempt. +6. Run `summarize-generation-session` to aggregate progress. +7. Use `validate-generation` directly when you need an ad hoc post-generation check without a tracked session. ## Step 1: compile the bundle @@ -41,7 +43,7 @@ The output is one tool-neutral JSON payload with: ### `workspace` -Use `workspace` mode after a local agent edits the repo. +Use `workspace` mode after a local agent edits the repo, either directly or through `record-generation-attempt`. ```bash interfacectl validate-generation \ @@ -71,6 +73,24 @@ Exit semantics: - `30` for `block` - `10` for malformed adapter input or unreadable bundle +## Optional tracked-session loop + +```bash +interfacectl init-generation-session \ + --bundle-root ./artifacts/generation-bundles/surfaces-web \ + --surface surfaces-web \ + --workspace-root . + +interfacectl record-generation-attempt \ + --session-dir ./artifacts/generation-sessions/surfaces-web/ \ + --assessment-file ./artifacts/generation-sessions/surfaces-web//assessment.json + +interfacectl summarize-generation-session \ + --session-dir ./artifacts/generation-sessions/surfaces-web/ +``` + +This loop freezes the bundle revision, records each assessment, and emits canonical run artifacts for downstream consumers. + ## HTTP mode Hosted builders can still use the HTTP adapter: diff --git a/docs/ai-tool-playbooks.md b/docs/ai-tool-playbooks.md index d6e31ce..1b2a5b9 100644 --- a/docs/ai-tool-playbooks.md +++ b/docs/ai-tool-playbooks.md @@ -22,14 +22,14 @@ Recommended flow: 1. Run `interfacectl compile`. 2. Run `interfacectl prepare-generation` for the target surface. -3. Feed the prepared JSON into the agent context or repo-local wrapper. -4. Generate directly in the repo. -5. Run `interfacectl validate-generation --mode workspace`. -6. Treat `block` as a hard stop and feed findings back into the next attempt. +3. Optionally run `interfacectl init-generation-session` when you want tracked iteration evidence. +4. Feed the prepared JSON into the agent context or repo-local wrapper. +5. Generate directly in the repo. +6. Run `interfacectl validate-generation --mode workspace`, or `interfacectl record-generation-attempt` for tracked sessions. +7. Treat `block` as a hard stop and feed findings back into the next attempt. These tools should consume the prepared generation payload, not the raw contract or sibling bundle files directly. - -When you want tracked iteration evidence around a local-agent loop, pair `prepare-generation` and `validate-generation` with a repo-local session harness that freezes one bundle revision, records each assessment, and aggregates recurring repair codes. +When you want tracked iteration evidence around a local-agent loop, use the canonical session commands rather than a repo-local harness. ## Hosted builders diff --git a/docs/generator-consumption.md b/docs/generator-consumption.md index aab88df..a858365 100644 --- a/docs/generator-consumption.md +++ b/docs/generator-consumption.md @@ -17,10 +17,11 @@ For workspace agents: 1. Run `interfacectl compile --contract --out `. 2. Run `interfacectl prepare-generation --bundle-root --surface `. -3. Feed the resulting JSON into the agent. -4. Generate only inside the surface-owned boundary. -5. Run `interfacectl validate-generation --mode workspace` against the same bundle. -6. Feed structured findings back into the next attempt. +3. Optionally run `interfacectl init-generation-session --bundle-root --surface --workspace-root ` when you want tracked iteration evidence. +4. Feed the resulting prepared JSON into the agent. +5. Generate only inside the surface-owned boundary. +6. Either run `interfacectl validate-generation --mode workspace` directly, or run `interfacectl record-generation-attempt` for a tracked session. +7. Feed structured findings back into the next attempt. The prepared payload is the canonical handoff for local agents. Do not make each agent re-load and merge sibling bundle files independently. @@ -69,6 +70,16 @@ The prepared payload is intentionally tool-neutral. - It does not inline raw extraction evidence. - It does not try to cover hosted descriptor flows, which remain on the adapter path. +## Tracked sessions + +When you need auditable iteration history, use the canonical session commands rather than a repo-local harness: + +1. `interfacectl init-generation-session` +2. `interfacectl record-generation-attempt` +3. `interfacectl summarize-generation-session` + +These commands write session artifacts under `artifacts/generation-sessions/...` and emit canonical `contract-runs.json` / `contract-lineage.json` updates into the workspace. + ## Related docs - [AI Generator Adapter Quick Start](./ai-generator-adapter-quickstart.md) diff --git a/packages/interfacectl-cli/dist/commands/emit-run-artifact.d.ts b/packages/interfacectl-cli/dist/commands/emit-run-artifact.d.ts new file mode 100644 index 0000000..2576e14 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/emit-run-artifact.d.ts @@ -0,0 +1,16 @@ +export interface EmitRunArtifactCommandOptions { + workspaceRoot?: string; + surfaceId?: string; + source?: string; + status?: string; + contractPath?: string; + extractionPath?: string; + reportPath?: string; + findingCodes?: string; + workspaceId?: string; + idempotencyKey?: string; + createdAt?: string; + runId?: string; +} +export declare function runEmitRunArtifactCommand(options: EmitRunArtifactCommandOptions): Promise; +//# sourceMappingURL=emit-run-artifact.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/emit-run-artifact.d.ts.map b/packages/interfacectl-cli/dist/commands/emit-run-artifact.d.ts.map new file mode 100644 index 0000000..9e09f96 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/emit-run-artifact.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"emit-run-artifact.d.ts","sourceRoot":"","sources":["../../src/commands/emit-run-artifact.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,6BAA6B;IAC5C,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AASD,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,MAAM,CAAC,CA2CjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/emit-run-artifact.js b/packages/interfacectl-cli/dist/commands/emit-run-artifact.js new file mode 100644 index 0000000..cc7a5cc --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/emit-run-artifact.js @@ -0,0 +1,49 @@ +import path from "node:path"; +import { emitContractRunArtifact } from "../utils/run-artifacts.js"; +const VALID_SOURCES = new Set(["bootstrap", "generation", "ci", "runtime"]); +const VALID_STATUSES = new Set(["pass", "warn", "fail", "unknown"]); +function writeError(message, code) { + process.stderr.write(`${JSON.stringify({ status: "error", code, error: message }, null, 2)}\n`); +} +export async function runEmitRunArtifactCommand(options) { + try { + if (!options.workspaceRoot) { + throw new Error("--workspace-root is required."); + } + if (!options.surfaceId) { + throw new Error("--surface is required."); + } + if (!options.source || !VALID_SOURCES.has(options.source)) { + throw new Error("--source must be one of bootstrap|generation|ci|runtime."); + } + if (!options.status || !VALID_STATUSES.has(options.status)) { + throw new Error("--status must be one of pass|warn|fail|unknown."); + } + const findingCodes = typeof options.findingCodes === "string" + ? options.findingCodes + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + : []; + const result = emitContractRunArtifact({ + rootDir: path.resolve(options.workspaceRoot), + surfaceId: options.surfaceId, + source: options.source, + status: options.status, + contractPath: options.contractPath, + extractionPath: options.extractionPath, + reportPath: options.reportPath, + findingCodes, + workspaceId: options.workspaceId, + idempotencyKey: options.idempotencyKey, + createdAt: options.createdAt, + runId: options.runId, + }); + process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`); + return 0; + } + catch (error) { + writeError(error instanceof Error ? error.message : String(error), "run-artifact.input"); + return 10; + } +} diff --git a/packages/interfacectl-cli/dist/commands/generation-session.d.ts b/packages/interfacectl-cli/dist/commands/generation-session.d.ts new file mode 100644 index 0000000..a165094 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/generation-session.d.ts @@ -0,0 +1,19 @@ +export interface InitGenerationSessionCommandOptions { + bundleRoot?: string; + surfaceId?: string; + workspaceRoot?: string; + tool?: string; + sessionId?: string; + artifactsRoot?: string; +} +export interface RecordGenerationAttemptCommandOptions { + sessionDir?: string; + assessmentFile?: string; +} +export interface SummarizeGenerationSessionCommandOptions { + sessionDir?: string; +} +export declare function runInitGenerationSessionCommand(options: InitGenerationSessionCommandOptions): Promise; +export declare function runRecordGenerationAttemptCommand(options: RecordGenerationAttemptCommandOptions): Promise; +export declare function runSummarizeGenerationSessionCommand(options: SummarizeGenerationSessionCommandOptions): Promise; +//# sourceMappingURL=generation-session.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/generation-session.d.ts.map b/packages/interfacectl-cli/dist/commands/generation-session.d.ts.map new file mode 100644 index 0000000..8f6a953 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/generation-session.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"generation-session.d.ts","sourceRoot":"","sources":["../../src/commands/generation-session.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,mCAAmC;IAClD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,qCAAqC;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,wCAAwC;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAqTD,wBAAsB,+BAA+B,CACnD,OAAO,EAAE,mCAAmC,GAC3C,OAAO,CAAC,MAAM,CAAC,CAkEjB;AAED,wBAAsB,iCAAiC,CACrD,OAAO,EAAE,qCAAqC,GAC7C,OAAO,CAAC,MAAM,CAAC,CAiGjB;AAED,wBAAsB,oCAAoC,CACxD,OAAO,EAAE,wCAAwC,GAChD,OAAO,CAAC,MAAM,CAAC,CAmHjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/generation-session.js b/packages/interfacectl-cli/dist/commands/generation-session.js new file mode 100644 index 0000000..709cd27 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/generation-session.js @@ -0,0 +1,440 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { AdapterInputError, loadCompiledSurfaceBundle, readJsonFile, } from "../adapter/bundle.js"; +import { runGenerationAdapter } from "../adapter/core.js"; +import { buildPreparedGenerationPayload } from "./prepare-generation.js"; +import { emitContractRunArtifact, } from "../utils/run-artifacts.js"; +import { writeDeterministicJsonSync } from "../utils/deterministic-json.js"; +const VALID_TOOLS = new Set(["codex", "cursor"]); +const VALID_GRADES = new Set(["strong", "partial", "weak"]); +class SessionInputError extends Error { + code; + constructor(message, code = "generation-session.input") { + super(message); + this.name = "SessionInputError"; + this.code = code; + } +} +function writeError(error, code) { + process.stderr.write(`${JSON.stringify({ + status: "error", + code, + error: error.message, + }, null, 2)}\n`); +} +function ensureSessionTool(tool) { + const normalized = typeof tool === "string" ? tool.trim().toLowerCase() : "codex"; + if (!VALID_TOOLS.has(normalized)) { + throw new SessionInputError(`Invalid --tool value "${tool ?? ""}". Expected codex|cursor.`); + } + return normalized; +} +function buildDefaultSessionId() { + return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} +function resolveWorkspaceRelative(workspaceRoot, candidate) { + if (!candidate || candidate.trim().length === 0) { + return path.join(workspaceRoot, "artifacts", "generation-sessions"); + } + return path.isAbsolute(candidate) ? candidate : path.resolve(workspaceRoot, candidate); +} +function getSessionPaths(sessionDir) { + return { + sessionDir, + sessionPath: path.join(sessionDir, "session.json"), + bundleRoot: path.join(sessionDir, "bundle"), + preparedInputPath: path.join(sessionDir, "prepared-input.json"), + attemptsDir: path.join(sessionDir, "attempts"), + summaryJsonPath: path.join(sessionDir, "summary.json"), + summaryMarkdownPath: path.join(sessionDir, "summary.md"), + }; +} +function normalizeAssessment(payload, filePath) { + const grade = (key) => { + const value = payload[key]; + if (!VALID_GRADES.has(value)) { + throw new SessionInputError(`Assessment field "${key}" must be one of strong|partial|weak: ${filePath}.`); + } + return value; + }; + const notes = typeof payload.notes === "string" ? payload.notes.trim() : ""; + if (!notes) { + throw new SessionInputError(`Assessment field "notes" must be a non-empty string: ${filePath}.`); + } + let touchedFiles; + if (payload.touchedFiles !== undefined) { + if (!Array.isArray(payload.touchedFiles)) { + throw new SessionInputError(`Assessment field "touchedFiles" must be an array when provided: ${filePath}.`); + } + touchedFiles = [...new Set(payload.touchedFiles + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean))].sort((left, right) => left.localeCompare(right)); + } + return { + structure: grade("structure"), + visual: grade("visual"), + responsiveness: grade("responsiveness"), + notes, + ...(touchedFiles && touchedFiles.length > 0 ? { touchedFiles } : {}), + }; +} +function loadAssessment(assessmentPath) { + const resolvedPath = path.resolve(assessmentPath); + if (!fs.existsSync(resolvedPath)) { + throw new SessionInputError(`Assessment file not found at ${resolvedPath}.`); + } + return normalizeAssessment(readJsonFile(resolvedPath, "generation assessment"), resolvedPath); +} +function loadSession(sessionDirInput) { + const sessionDir = path.resolve(sessionDirInput); + const paths = getSessionPaths(sessionDir); + if (!fs.existsSync(paths.sessionPath)) { + throw new SessionInputError(`Generation session not found at ${paths.sessionPath}.`); + } + const session = readJsonFile(paths.sessionPath, "generation session"); + if (session.schemaVersion !== 1) { + throw new SessionInputError(`Unsupported generation session schemaVersion "${String(session.schemaVersion ?? "unknown")}".`); + } + if (!VALID_TOOLS.has(session.tool)) { + throw new SessionInputError(`Unsupported session tool "${session.tool}".`); + } + return { + session, + paths, + }; +} +function nextAttemptNumber(attemptsDir) { + if (!fs.existsSync(attemptsDir)) { + return 1; + } + const seen = fs.readdirSync(attemptsDir) + .map((entry) => /^(\d{3})\.metadata\.json$/.exec(entry)) + .filter((match) => Boolean(match)) + .map((match) => Number.parseInt(match[1], 10)); + if (seen.length === 0) { + return 1; + } + return Math.max(...seen) + 1; +} +function formatAttemptNumber(attemptNumber) { + return String(attemptNumber).padStart(3, "0"); +} +function mapAdapterStatusToRunStatus(status) { + switch (status) { + case "pass": + return "pass"; + case "warn": + return "warn"; + case "block": + return "fail"; + } +} +function parseFindingCodes(validatePayload) { + const findings = Array.isArray(validatePayload.findings) ? validatePayload.findings : []; + return [...new Set(findings + .map((finding) => { + if (!finding || typeof finding !== "object") + return ""; + const code = finding.code; + return typeof code === "string" ? code : ""; + }) + .filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} +function buildRecurringCounts(values) { + const counts = new Map(); + for (const value of values) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + return [...counts.entries()] + .filter(([, count]) => count > 1) + .map(([code, count]) => ({ code, count })) + .sort((left, right) => right.count - left.count || left.code.localeCompare(right.code)); +} +function renderSummaryMarkdown(summary) { + const lines = [ + "# Generation Session Summary", + "", + `Surface: ${summary.surfaceId}`, + `Session: ${summary.sessionId}`, + `Tool: ${summary.tool}`, + `Latest status: ${summary.latestStatus}`, + `Attempts: ${summary.attemptCount}`, + `First pass attempt: ${summary.firstPassAttempt ?? "not yet reached"}`, + "", + "## Recurring finding codes", + ]; + if (summary.recurringFindingCodes.length === 0) { + lines.push("None."); + } + else { + for (const item of summary.recurringFindingCodes) { + lines.push(`- ${item.code}: ${item.count}`); + } + } + lines.push("", "## Recurring repair codes"); + if (summary.recurringRepairCodes.length === 0) { + lines.push("None."); + } + else { + for (const item of summary.recurringRepairCodes) { + lines.push(`- ${item.code}: ${item.count} (${item.priority})`); + } + } + lines.push("", "## Latest assessment"); + lines.push(`- structure: ${summary.latestAssessment?.structure ?? "n/a"}`); + lines.push(`- visual: ${summary.latestAssessment?.visual ?? "n/a"}`); + lines.push(`- responsiveness: ${summary.latestAssessment?.responsiveness ?? "n/a"}`); + lines.push(`- notes: ${summary.latestAssessment?.notes ?? "n/a"}`); + if (summary.latestAssessment?.touchedFiles?.length) { + lines.push(`- touched files: ${summary.latestAssessment.touchedFiles.join(", ")}`); + } + return `${lines.join("\n")}\n`; +} +export async function runInitGenerationSessionCommand(options) { + try { + if (!options.bundleRoot) { + throw new SessionInputError("--bundle-root is required."); + } + if (!options.surfaceId) { + throw new SessionInputError("--surface is required."); + } + if (!options.workspaceRoot) { + throw new SessionInputError("--workspace-root is required."); + } + const tool = ensureSessionTool(options.tool); + const workspaceRoot = path.resolve(options.workspaceRoot); + if (!fs.existsSync(workspaceRoot) || !fs.statSync(workspaceRoot).isDirectory()) { + throw new SessionInputError(`Workspace root directory not found at ${workspaceRoot}.`); + } + const loadedBundle = loadCompiledSurfaceBundle(options.bundleRoot, options.surfaceId, process.cwd()); + const sessionId = options.sessionId?.trim() || buildDefaultSessionId(); + const artifactsRoot = resolveWorkspaceRelative(workspaceRoot, options.artifactsRoot); + const sessionDir = path.join(artifactsRoot, options.surfaceId, sessionId); + const paths = getSessionPaths(sessionDir); + if (fs.existsSync(paths.sessionDir)) { + throw new SessionInputError(`Generation session already exists at ${paths.sessionDir}.`); + } + fs.mkdirSync(paths.sessionDir, { recursive: true }); + fs.cpSync(loadedBundle.root, paths.bundleRoot, { recursive: true }); + const sessionBundle = loadCompiledSurfaceBundle(paths.bundleRoot, options.surfaceId, process.cwd()); + const preparedPayload = buildPreparedGenerationPayload(sessionBundle); + writeDeterministicJsonSync(paths.preparedInputPath, preparedPayload); + const session = { + schemaVersion: 1, + surfaceId: options.surfaceId, + sessionId, + tool, + workspaceRoot, + sourceBundleRoot: loadedBundle.root, + sessionDir: paths.sessionDir, + bundleRoot: paths.bundleRoot, + preparedInputPath: paths.preparedInputPath, + contractPath: sessionBundle.contract.path, + repairMapPath: sessionBundle.surface.repairMap.path, + startedAt: new Date().toISOString(), + successRule: { + finalStatus: "pass", + }, + }; + writeDeterministicJsonSync(paths.sessionPath, session); + process.stdout.write(`${JSON.stringify({ ok: true, session, paths }, null, 2)}\n`); + return 0; + } + catch (error) { + if (error instanceof SessionInputError || error instanceof AdapterInputError) { + writeError(error, error.code); + return 10; + } + writeError(error instanceof Error ? error : new Error(String(error)), "generation-session.internal"); + return 1; + } +} +export async function runRecordGenerationAttemptCommand(options) { + try { + if (!options.sessionDir) { + throw new SessionInputError("--session-dir is required."); + } + if (!options.assessmentFile) { + throw new SessionInputError("--assessment-file is required."); + } + const { session, paths } = loadSession(options.sessionDir); + const assessment = loadAssessment(options.assessmentFile); + const attemptNumber = nextAttemptNumber(paths.attemptsDir); + const attemptId = formatAttemptNumber(attemptNumber); + fs.mkdirSync(paths.attemptsDir, { recursive: true }); + const validatePath = path.join(paths.attemptsDir, `${attemptId}.validate.json`); + const assessmentPath = path.join(paths.attemptsDir, `${attemptId}.assessment.json`); + const metadataPath = path.join(paths.attemptsDir, `${attemptId}.metadata.json`); + const response = await runGenerationAdapter({ + requestId: crypto.randomUUID(), + tool: session.tool, + surfaceId: session.surfaceId, + mode: "workspace", + bundleRoot: session.bundleRoot, + workspaceRoot: session.workspaceRoot, + provenance: { + sessionId: session.sessionId, + timestamp: new Date().toISOString(), + }, + }, { + cwd: process.cwd(), + }); + writeDeterministicJsonSync(validatePath, response); + writeDeterministicJsonSync(assessmentPath, assessment); + const findingCodes = parseFindingCodes(response); + const contractRun = emitContractRunArtifact({ + rootDir: session.workspaceRoot, + contractPath: session.contractPath, + surfaceId: session.surfaceId, + source: "generation", + status: mapAdapterStatusToRunStatus(response.status), + reportPath: validatePath, + findingCodes, + workspaceId: session.sessionId, + idempotencyKey: `${session.surfaceId}:${session.sessionId}:${attemptId}`, + }); + const metadata = { + schemaVersion: 1, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + attemptNumber, + tool: session.tool, + createdAt: new Date().toISOString(), + validateStatus: response.status, + validateExitCode: response.status === "block" ? 30 : 0, + findingCodes, + assessmentPath, + validatePath, + touchedFiles: assessment.touchedFiles ?? [], + contractRun, + }; + writeDeterministicJsonSync(metadataPath, metadata); + process.stdout.write(`${JSON.stringify({ + ok: true, + session, + attempt: { + attemptNumber, + validatePath, + assessmentPath, + metadataPath, + validateStatus: response.status, + findingCodes, + }, + }, null, 2)}\n`); + return 0; + } + catch (error) { + if (error instanceof SessionInputError || error instanceof AdapterInputError) { + writeError(error, error.code); + return 10; + } + writeError(error instanceof Error ? error : new Error(String(error)), "generation-session.internal"); + return 1; + } +} +export async function runSummarizeGenerationSessionCommand(options) { + try { + if (!options.sessionDir) { + throw new SessionInputError("--session-dir is required."); + } + const { session, paths } = loadSession(options.sessionDir); + if (!fs.existsSync(paths.attemptsDir)) { + throw new SessionInputError(`No attempts recorded for session ${session.sessionId}.`); + } + const attemptNumbers = fs.readdirSync(paths.attemptsDir) + .map((entry) => /^(\d{3})\.metadata\.json$/.exec(entry)) + .filter((match) => Boolean(match)) + .map((match) => Number.parseInt(match[1], 10)) + .sort((left, right) => left - right); + if (attemptNumbers.length === 0) { + throw new SessionInputError(`No attempts recorded for session ${session.sessionId}.`); + } + const attempts = attemptNumbers.map((attemptNumber) => { + const attemptId = formatAttemptNumber(attemptNumber); + const validatePath = path.join(paths.attemptsDir, `${attemptId}.validate.json`); + const assessmentPath = path.join(paths.attemptsDir, `${attemptId}.assessment.json`); + const metadataPath = path.join(paths.attemptsDir, `${attemptId}.metadata.json`); + return { + attemptNumber, + validate: readJsonFile(validatePath, `attempt ${attemptId} validate payload`), + assessment: readJsonFile(assessmentPath, `attempt ${attemptId} assessment`), + metadata: readJsonFile(metadataPath, `attempt ${attemptId} metadata`), + validatePath, + assessmentPath, + metadataPath, + }; + }); + const firstPassAttempt = attempts.find((attempt) => attempt.validate.status === "pass")?.attemptNumber ?? null; + const latestAttempt = attempts[attempts.length - 1]; + const recurringFindingCodes = buildRecurringCounts(attempts.flatMap((attempt) => parseFindingCodes(attempt.validate))); + const repairMapDoc = readJsonFile(session.repairMapPath, "repair map"); + const repairs = Array.isArray(repairMapDoc.repairs) ? repairMapDoc.repairs : []; + const repairMapByCode = new Map(repairs + .filter((entry) => Boolean(entry) && typeof entry === "object") + .map((entry) => [typeof entry.code === "string" ? entry.code : "", entry]) + .filter(([code]) => Boolean(code))); + const recurringRepairCodes = recurringFindingCodes + .map((entry) => { + const repair = repairMapByCode.get(entry.code); + if (!repair) + return undefined; + const action = repair.action && typeof repair.action === "object" + ? repair.action + : {}; + return { + code: entry.code, + count: entry.count, + priority: typeof repair.priority === "string" ? repair.priority : "medium", + category: typeof repair.category === "string" ? repair.category : "unknown", + actionType: typeof action.type === "string" ? action.type : "unknown", + }; + }) + .filter((entry) => Boolean(entry)) + .sort((left, right) => right.count - left.count || left.code.localeCompare(right.code)); + const latestAssessment = normalizeAssessment(latestAttempt.assessment, latestAttempt.assessmentPath); + const latestStatus = latestAttempt.validate.status; + if (latestStatus !== "pass" && latestStatus !== "warn" && latestStatus !== "block") { + throw new SessionInputError(`Unsupported validate status "${String(latestStatus)}" in ${latestAttempt.validatePath}.`); + } + const summary = { + schemaVersion: 1, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + tool: session.tool, + attemptCount: attempts.length, + firstPassAttempt, + latestStatus, + recurringFindingCodes, + recurringRepairCodes, + latestAssessment, + successRule: session.successRule, + paths: { + sessionPath: paths.sessionPath, + bundleRoot: session.bundleRoot, + preparedInputPath: session.preparedInputPath, + }, + attempts: attempts.map((attempt) => ({ + attemptNumber: attempt.attemptNumber, + status: attempt.validate.status, + findingCodes: parseFindingCodes(attempt.validate), + validatePath: attempt.validatePath, + assessmentPath: attempt.assessmentPath, + metadataPath: attempt.metadataPath, + createdAt: typeof attempt.metadata.createdAt === "string" ? attempt.metadata.createdAt : undefined, + })), + }; + writeDeterministicJsonSync(paths.summaryJsonPath, summary); + fs.writeFileSync(paths.summaryMarkdownPath, renderSummaryMarkdown(summary), "utf8"); + process.stdout.write(`${JSON.stringify({ ok: true, summary, paths }, null, 2)}\n`); + return latestStatus === "pass" ? 0 : 30; + } + catch (error) { + if (error instanceof SessionInputError || error instanceof AdapterInputError) { + writeError(error, error.code); + return 10; + } + writeError(error instanceof Error ? error : new Error(String(error)), "generation-session.internal"); + return 1; + } +} diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts index 3d44f48..570949e 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts @@ -1,7 +1,118 @@ +import { type JsonRecord, type LoadedCompiledSurfaceBundle } from "../adapter/bundle.js"; export interface PrepareGenerationCommandOptions { bundleRoot?: string; surfaceId?: string; outPath?: string; } +type RepairPriority = "high" | "medium" | "low"; +interface SummaryRepairItem { + code: string; + priority: RepairPriority; + category: string; + actionType: string; +} +export declare function buildPreparedGenerationPayload(bundle: LoadedCompiledSurfaceBundle): { + evidenceRefs: any[]; + authoring?: JsonRecord | undefined; + surface: { + surfaceId: string; + displayName: string; + type: string; + }; + bundle: { + root: string; + version: string; + manifestPath: string; + sourcePaths: { + authoring?: string | undefined; + contract: string; + generation: string; + sections: string; + components: string; + constraints: string; + repairMap: string; + }; + }; + contract: { + id: string; + version: string; + normalizedPath: string; + }; + summary: { + text: string; + focusOrder: string[]; + requiredSectionIds: string[]; + prohibitedRoles: string[]; + checklist: { + id: string; + label: string; + detail: string; + }[]; + topRepairs: SummaryRepairItem[]; + }; + generation: { + boundary: JsonRecord; + structure: JsonRecord; + layout: JsonRecord; + visual: JsonRecord; + guidance: JsonRecord; + }; + sections: any[]; + components: any[]; + constraints: JsonRecord; + repairMap: any[]; +}; +export declare function loadPreparedGenerationPayload(bundleRoot: string, surfaceId: string, cwd?: string): { + evidenceRefs: any[]; + authoring?: JsonRecord | undefined; + surface: { + surfaceId: string; + displayName: string; + type: string; + }; + bundle: { + root: string; + version: string; + manifestPath: string; + sourcePaths: { + authoring?: string | undefined; + contract: string; + generation: string; + sections: string; + components: string; + constraints: string; + repairMap: string; + }; + }; + contract: { + id: string; + version: string; + normalizedPath: string; + }; + summary: { + text: string; + focusOrder: string[]; + requiredSectionIds: string[]; + prohibitedRoles: string[]; + checklist: { + id: string; + label: string; + detail: string; + }[]; + topRepairs: SummaryRepairItem[]; + }; + generation: { + boundary: JsonRecord; + structure: JsonRecord; + layout: JsonRecord; + visual: JsonRecord; + guidance: JsonRecord; + }; + sections: any[]; + components: any[]; + constraints: JsonRecord; + repairMap: any[]; +}; export declare function runPrepareGenerationCommand(options: PrepareGenerationCommandOptions): Promise; +export {}; //# sourceMappingURL=prepare-generation.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map index 3571821..80806aa 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"prepare-generation.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-generation.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,+BAA+B;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAkOD,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,MAAM,CAAC,CAgCjB"} \ No newline at end of file +{"version":3,"file":"prepare-generation.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-generation.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,UAAU,EACf,KAAK,2BAA2B,EACjC,MAAM,sBAAsB,CAAC;AAG9B,MAAM,WAAW,+BAA+B;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,KAAK,cAAc,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;AAEhD,UAAU,iBAAiB;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,cAAc,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AA8HD,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBAzDnD,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;EA+GnE;AAED,wBAAgB,6BAA6B,CAC3C,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,SAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBApHU,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;EAwHnE;AAgBD,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,MAAM,CAAC,CA+BjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.js b/packages/interfacectl-cli/dist/commands/prepare-generation.js index c625fdf..749d3b3 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-generation.js +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.js @@ -1,25 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { AdapterInputError, isRecord, loadCompiledSurfaceBundle, } from "../adapter/bundle.js"; -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 key of Object.keys(value).sort()) { - sorted[key] = sortKeysRecursive(value[key]); - } - return sorted; - } - return value; -} -function stringifyDeterministic(value) { - return `${JSON.stringify(sortKeysRecursive(value), null, 2)}\n`; -} +import { stringifyDeterministicJson } from "../utils/deterministic-json.js"; function asRecord(value) { return isRecord(value) ? value : {}; } @@ -132,7 +114,7 @@ function buildSummary(bundle) { topRepairs: repairs.slice(0, 5), }; } -function buildPreparedGenerationPayload(bundle) { +export function buildPreparedGenerationPayload(bundle) { const generation = asRecord(bundle.surface.generation.value); const generationRefs = asRecord(generation.refs); const identity = asRecord(generation.identity); @@ -186,6 +168,10 @@ function buildPreparedGenerationPayload(bundle) { evidenceRefs: Array.isArray(generationRefs.evidence) ? generationRefs.evidence : [], }; } +export function loadPreparedGenerationPayload(bundleRoot, surfaceId, cwd = process.cwd()) { + const bundle = loadCompiledSurfaceBundle(bundleRoot, surfaceId, cwd); + return buildPreparedGenerationPayload(bundle); +} function writeError(error, code) { process.stderr.write(`${JSON.stringify({ status: "error", @@ -201,9 +187,8 @@ export async function runPrepareGenerationCommand(options) { if (!options.surfaceId) { throw new AdapterInputError("--surface is required."); } - const bundle = loadCompiledSurfaceBundle(options.bundleRoot, options.surfaceId, process.cwd()); - const payload = buildPreparedGenerationPayload(bundle); - const serialized = stringifyDeterministic(payload); + const payload = loadPreparedGenerationPayload(options.bundleRoot, options.surfaceId, process.cwd()); + const serialized = stringifyDeterministicJson(payload); if (options.outPath) { const outPath = path.resolve(options.outPath); fs.mkdirSync(path.dirname(outPath), { recursive: true }); diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index cc26317..1632df6 100644 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -12,6 +12,8 @@ import { runDescribeCommand } from "./commands/describe.js"; import { runPrepareGenerationCommand } from "./commands/prepare-generation.js"; import { runValidateGenerationCommand } from "./commands/validate-generation.js"; import { runServeGenerationAdapterCommand } from "./commands/serve-generation-adapter.js"; +import { runEmitRunArtifactCommand } from "./commands/emit-run-artifact.js"; +import { runInitGenerationSessionCommand, runRecordGenerationAttemptCommand, runSummarizeGenerationSessionCommand, } from "./commands/generation-session.js"; import { runInitCommand } from "./commands/init.js"; import { runAnalyzeCommand } from "./commands/analyze.js"; import { runAuthCaptureCommand, runAuthClearCommand, runAuthListCommandWithOptions, runAuthTestCommand, } from "./commands/auth.js"; @@ -205,6 +207,45 @@ program outPath: options.out, }); }); +program + .command("init-generation-session") + .description("Freeze one compiled bundle revision into a tracked local generation session") + .requiredOption("--bundle-root ", "Path to the compiled generation bundle directory") + .requiredOption("--surface ", "Surface identifier") + .requiredOption("--workspace-root ", "Workspace root for emitted run artifacts") + .option("--tool ", "Generation tool identifier (codex|cursor)") + .option("--session ", "Optional session identifier") + .option("--artifacts-root ", "Optional session artifacts root (defaults under workspaceRoot/artifacts/generation-sessions)") + .action(async (options) => { + process.exitCode = await runInitGenerationSessionCommand({ + bundleRoot: options.bundleRoot, + surfaceId: options.surface, + workspaceRoot: options.workspaceRoot, + tool: options.tool, + sessionId: options.session, + artifactsRoot: options.artifactsRoot, + }); +}); +program + .command("record-generation-attempt") + .description("Validate and record one generation attempt for a tracked session") + .requiredOption("--session-dir ", "Path to the generation session directory") + .requiredOption("--assessment-file ", "Path to the assessment JSON file") + .action(async (options) => { + process.exitCode = await runRecordGenerationAttemptCommand({ + sessionDir: options.sessionDir, + assessmentFile: options.assessmentFile, + }); +}); +program + .command("summarize-generation-session") + .description("Aggregate recorded generation attempts for a tracked session") + .requiredOption("--session-dir ", "Path to the generation session directory") + .action(async (options) => { + process.exitCode = await runSummarizeGenerationSessionCommand({ + sessionDir: options.sessionDir, + }); +}); program .command("validate-generation") .description("Validate generated UI against a compiled generation bundle") @@ -249,6 +290,37 @@ program descriptorParityConfigPath: options.descriptorParityConfig, }); }); +program + .command("emit-run-artifact") + .description("Emit one canonical run artifact into contracts/generated") + .requiredOption("--workspace-root ", "Workspace root for contracts/generated") + .requiredOption("--surface ", "Surface identifier") + .requiredOption("--source ", "Run source") + .requiredOption("--status ", "Run status") + .option("--contract ", "Optional contract path override") + .option("--extraction-path ", "Optional extraction artifact path") + .option("--report-path ", "Optional report artifact path") + .option("--finding-codes ", "Optional comma-separated finding codes") + .option("--workspace-id ", "Optional workspace identifier") + .option("--idempotency-key ", "Optional idempotency key") + .option("--created-at ", "Optional created-at timestamp") + .option("--run-id ", "Optional run identifier") + .action(async (options) => { + process.exitCode = await runEmitRunArtifactCommand({ + workspaceRoot: options.workspaceRoot, + surfaceId: options.surface, + source: options.source, + status: options.status, + contractPath: options.contract, + extractionPath: options.extractionPath, + reportPath: options.reportPath, + findingCodes: options.findingCodes, + workspaceId: options.workspaceId, + idempotencyKey: options.idempotencyKey, + createdAt: options.createdAt, + runId: options.runId, + }); +}); program .command("generate-contract") .description("Extract a contract from a Next.js app (Phase 0: extraction only)") diff --git a/packages/interfacectl-cli/dist/utils/deterministic-json.d.ts b/packages/interfacectl-cli/dist/utils/deterministic-json.d.ts new file mode 100644 index 0000000..0c54d07 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/deterministic-json.d.ts @@ -0,0 +1,4 @@ +export declare function stringifyDeterministicJson(value: unknown): string; +export declare function writeDeterministicJsonSync(filePath: string, value: unknown): void; +export declare function writeDeterministicJson(filePath: string, value: unknown): Promise; +//# sourceMappingURL=deterministic-json.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/deterministic-json.d.ts.map b/packages/interfacectl-cli/dist/utils/deterministic-json.d.ts.map new file mode 100644 index 0000000..34190e7 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/deterministic-json.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"deterministic-json.d.ts","sourceRoot":"","sources":["../../src/utils/deterministic-json.ts"],"names":[],"mappings":"AAKA,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEjE;AAED,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAGjF;AAED,wBAAsB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAK5F"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/deterministic-json.js b/packages/interfacectl-cli/dist/utils/deterministic-json.js new file mode 100644 index 0000000..3702b94 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/deterministic-json.js @@ -0,0 +1,17 @@ +import fs from "node:fs"; +import { mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { stableStringify } from "@surfaces/interfacectl-extractor"; +export function stringifyDeterministicJson(value) { + return `${stableStringify(value)}\n`; +} +export function writeDeterministicJsonSync(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, stringifyDeterministicJson(value), "utf8"); +} +export async function writeDeterministicJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp`; + await writeFile(tempPath, stringifyDeterministicJson(value), "utf8"); + await rename(tempPath, filePath); +} diff --git a/packages/interfacectl-cli/dist/utils/onboarding.d.ts b/packages/interfacectl-cli/dist/utils/onboarding.d.ts index 1a349a9..eb984ff 100644 --- a/packages/interfacectl-cli/dist/utils/onboarding.d.ts +++ b/packages/interfacectl-cli/dist/utils/onboarding.d.ts @@ -1,5 +1,6 @@ -type ValidationOutcome = "pass" | "warn" | "fail" | "unknown"; -export type OnboardingRunSource = "bootstrap" | "generation" | "ci" | "runtime"; +import { type RunArtifactStatus, type RunArtifactSource } from "./run-artifacts.js"; +type ValidationOutcome = RunArtifactStatus; +export type OnboardingRunSource = RunArtifactSource; export interface BootstrapExtractionReport { surfaceId: string; appRoot: string; diff --git a/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map b/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map index d2b185e..db80906 100644 --- a/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map +++ b/packages/interfacectl-cli/dist/utils/onboarding.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"onboarding.d.ts","sourceRoot":"","sources":["../../src/utils/onboarding.ts"],"names":[],"mappings":"AAMA,KAAK,iBAAiB,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAC9D,MAAM,MAAM,mBAAmB,GAAG,WAAW,GAAG,YAAY,GAAG,IAAI,GAAG,SAAS,CAAC;AAiDhF,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9C,SAAS,EAAE;QACT,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,QAAQ,EAAE,OAAO,CAAC;QAClB,sBAAsB,EAAE,MAAM,EAAE,CAAC;QACjC,SAAS,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,UAAU,EAAE;QACV,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAAC;QACrC,WAAW,EAAE,YAAY,GAAG,YAAY,CAAC;QACzC,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,SAAS,EAAE,MAAM,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE;YACT,OAAO,EAAE,MAAM,CAAC;YAChB,SAAS,EAAE,MAAM,CAAC;YAClB,OAAO,EAAE,MAAM,CAAC;SACjB,CAAC;KACH,CAAC;CACH;AAkCD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAgB9D;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAQhE;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA+C1B;AAED,wBAAsB,uBAAuB,CAAC,KAAK,EAAE;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,MAAM,EAAE,yBAAyB,CAAC;CACnC,GAAG,OAAO,CAAC;IACV,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC,CASD;AAwBD,wBAAsB,yBAAyB,CAAC,KAAK,EAAE;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,mBAAmB,CAAC;IAC5B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAqD7B;AAED,wBAAsB,wBAAwB,CAAC,KAAK,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAK7B"} \ No newline at end of file +{"version":3,"file":"onboarding.d.ts","sourceRoot":"","sources":["../../src/utils/onboarding.ts"],"names":[],"mappings":"AAKA,OAAO,EAA2B,KAAK,iBAAiB,EAAE,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC7G,KAAK,iBAAiB,GAAG,iBAAiB,CAAC;AAC3C,MAAM,MAAM,mBAAmB,GAAG,iBAAiB,CAAC;AAEpD,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9C,SAAS,EAAE;QACT,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,QAAQ,EAAE,OAAO,CAAC;QAClB,sBAAsB,EAAE,MAAM,EAAE,CAAC;QACjC,SAAS,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,UAAU,EAAE;QACV,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAAC;QACrC,WAAW,EAAE,YAAY,GAAG,YAAY,CAAC;QACzC,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,SAAS,EAAE,MAAM,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE;YACT,OAAO,EAAE,MAAM,CAAC;YAChB,SAAS,EAAE,MAAM,CAAC;YAClB,OAAO,EAAE,MAAM,CAAC;SACjB,CAAC;KACH,CAAC;CACH;AAuBD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAgB9D;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAQhE;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;CACpB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA+C1B;AAED,wBAAsB,uBAAuB,CAAC,KAAK,EAAE;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,MAAM,EAAE,yBAAyB,CAAC;CACnC,GAAG,OAAO,CAAC;IACV,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC,CASD;AAED,wBAAsB,yBAAyB,CAAC,KAAK,EAAE;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,mBAAmB,CAAC;IAC5B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAW7B;AAED,wBAAsB,wBAAwB,CAAC,KAAK,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAK7B"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/onboarding.js b/packages/interfacectl-cli/dist/utils/onboarding.js index d4dc582..ac9bc36 100644 --- a/packages/interfacectl-cli/dist/utils/onboarding.js +++ b/packages/interfacectl-cli/dist/utils/onboarding.js @@ -1,14 +1,9 @@ -import { createHash, randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import { stableStringify } from "@surfaces/interfacectl-extractor"; -function statusSummary(status, findingCodes) { - return { - errorCount: status === "fail" ? Math.max(1, findingCodes.length) : 0, - warnCount: status === "warn" ? Math.max(1, findingCodes.length) : 0, - }; -} +import { emitContractRunArtifact } from "./run-artifacts.js"; async function writeJsonAtomic(filePath, payload) { await mkdir(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.tmp`; @@ -29,9 +24,6 @@ async function readJson(filePath, fallback) { function sha256FromContent(content) { return createHash("sha256").update(content).digest("hex"); } -function toRelative(root, candidate) { - return path.relative(root, candidate); -} export function suggestSurfaceIdFromUrl(rawUrl) { const url = new URL(rawUrl); const host = url.hostname.replace(/^www\./, ""); @@ -122,74 +114,17 @@ export async function writeBootstrapArtifacts(input) { await writeJsonAtomic(reportPath, input.report); return { contractPath, reportPath }; } -async function readCanonicalContract(rootDir) { - const canonicalPath = path.resolve(rootDir, "contracts", "surfaces.web.contract.json"); - if (!existsSync(canonicalPath)) { - return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; - } - try { - const raw = await readFile(canonicalPath, "utf-8"); - const parsed = JSON.parse(raw); - return { - id: parsed.contractId ?? "unknown", - version: parsed.version ?? "unknown", - sha256: sha256FromContent(raw), - }; - } - catch { - return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; - } -} export async function emitOnboardingRunArtifact(input) { - const generatedDir = path.resolve(input.rootDir, "contracts", "generated"); - const runsPath = path.join(generatedDir, "contract-runs.json"); - const lineagePath = path.join(generatedDir, "contract-lineage.json"); - const runsDoc = await readJson(runsPath, { - schemaVersion: 1, - runs: [], - }); - const lineageDoc = await readJson(lineagePath, { - schemaVersion: 1, - surfaces: {}, - }); - const runId = randomUUID(); - const createdAt = new Date().toISOString(); - const canonical = await readCanonicalContract(input.rootDir); - const runRecord = { - runId, - createdAt, + const result = emitContractRunArtifact({ + rootDir: input.rootDir, surfaceId: input.surfaceId, source: input.source, - contract: canonical, - artifacts: { - extractionPath: toRelative(input.rootDir, input.extractionPath), - reportPath: toRelative(input.rootDir, input.reportPath), - }, status: input.status, - findingCodes: [...input.findingCodes].sort(), - summary: statusSummary(input.status, input.findingCodes), - }; - const deduped = runsDoc.runs.filter((run) => run.runId !== runId); - deduped.push(runRecord); - deduped.sort((a, b) => { - const timeDiff = Date.parse(a.createdAt) - Date.parse(b.createdAt); - if (timeDiff !== 0) - return timeDiff; - return a.runId.localeCompare(b.runId); + findingCodes: input.findingCodes, + extractionPath: input.extractionPath, + reportPath: input.reportPath, }); - runsDoc.runs = deduped; - lineageDoc.surfaces[input.surfaceId] = { - lastRunId: runRecord.runId, - lastRunAt: runRecord.createdAt, - lastSource: runRecord.source, - lastStatus: runRecord.status, - contract: runRecord.contract, - artifacts: runRecord.artifacts, - findingCodes: runRecord.findingCodes, - }; - await writeJsonAtomic(runsPath, runsDoc); - await writeJsonAtomic(lineagePath, lineageDoc); - return { runId }; + return { runId: result.runId }; } export async function emitBootstrapRunArtifact(input) { return emitOnboardingRunArtifact({ diff --git a/packages/interfacectl-cli/dist/utils/run-artifacts.d.ts b/packages/interfacectl-cli/dist/utils/run-artifacts.d.ts new file mode 100644 index 0000000..c0e21d3 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/run-artifacts.d.ts @@ -0,0 +1,73 @@ +export type RunArtifactStatus = "pass" | "warn" | "fail" | "unknown"; +export type RunArtifactSource = "bootstrap" | "generation" | "ci" | "runtime"; +export interface ContractRun { + runId: string; + workspaceId: string; + idempotencyKey?: string; + ingestedAt?: string; + createdAt: string; + surfaceId: string; + source: RunArtifactSource; + contract: { + id: string; + version: string; + sha256: string; + }; + artifacts: { + extractionPath?: string; + reportPath?: string; + }; + status: RunArtifactStatus; + findingCodes: string[]; + summary: { + errorCount: number; + warnCount: number; + }; +} +export interface ContractRunsDocument { + schemaVersion: 2; + runs: ContractRun[]; +} +export interface ContractLineageRecord { + lastRunId: string; + lastRunAt: string; + lastSource: RunArtifactSource; + lastStatus: RunArtifactStatus; + contract: { + id: string; + version: string; + sha256: string; + }; + artifacts: { + extractionPath?: string; + reportPath?: string; + }; + findingCodes: string[]; +} +export interface ContractLineageDocument { + schemaVersion: 1; + surfaces: Record; +} +export interface EmitContractRunArtifactInput { + rootDir: string; + surfaceId: string; + source: RunArtifactSource; + status: RunArtifactStatus; + findingCodes: string[]; + extractionPath?: string; + reportPath?: string; + contractPath?: string; + workspaceId?: string; + idempotencyKey?: string; + createdAt?: string; + runId?: string; +} +export interface EmitContractRunArtifactResult { + deduped: boolean; + runId: string; + surfaceId: string; + runsPath: string; + lineagePath: string; +} +export declare function emitContractRunArtifact(input: EmitContractRunArtifactInput): EmitContractRunArtifactResult; +//# sourceMappingURL=run-artifacts.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/run-artifacts.d.ts.map b/packages/interfacectl-cli/dist/utils/run-artifacts.d.ts.map new file mode 100644 index 0000000..30a12be --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/run-artifacts.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"run-artifacts.d.ts","sourceRoot":"","sources":["../../src/utils/run-artifacts.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AACrE,MAAM,MAAM,iBAAiB,GAAG,WAAW,GAAG,YAAY,GAAG,IAAI,GAAG,SAAS,CAAC;AAE9E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,EAAE;QACR,EAAE,EAAE,MAAM,CAAC;QACX,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,SAAS,EAAE;QACT,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,MAAM,EAAE,iBAAiB,CAAC;IAC1B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,OAAO,EAAE;QACP,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;CACH;AAED,MAAM,WAAW,oBAAoB;IACnC,aAAa,EAAE,CAAC,CAAC;IACjB,IAAI,EAAE,WAAW,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,iBAAiB,CAAC;IAC9B,UAAU,EAAE,iBAAiB,CAAC;IAC9B,QAAQ,EAAE;QACR,EAAE,EAAE,MAAM,CAAC;QACX,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,SAAS,EAAE;QACT,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,uBAAuB;IACtC,aAAa,EAAE,CAAC,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,4BAA4B;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB;AAwJD,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,4BAA4B,GAClC,6BAA6B,CA0E/B"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/run-artifacts.js b/packages/interfacectl-cli/dist/utils/run-artifacts.js new file mode 100644 index 0000000..79a9644 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/run-artifacts.js @@ -0,0 +1,194 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { stringifyDeterministicJson, writeDeterministicJsonSync } from "./deterministic-json.js"; +const LOCK_TIMEOUT_MS = 5_000; +const LOCK_POLL_MS = 25; +const DEFAULT_WORKSPACE_ID = "ws-local-default"; +function sleep(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + fs.mkdirSync(lockPath); + return; + } + catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + sleep(LOCK_POLL_MS); + } + } + throw new Error(`Timed out acquiring run-artifact lock: ${lockPath}`); +} +function releaseLock(lockPath) { + try { + fs.rmdirSync(lockPath); + } + catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } +} +function readJson(filePath, fallback) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } + catch { + return fallback; + } +} +function sha256FromFile(filePath) { + const content = fs.readFileSync(filePath); + return createHash("sha256").update(content).digest("hex"); +} +function normalizeContractIdentity(rootDir, contractPath) { + const resolvedPath = contractPath + ? path.resolve(rootDir, contractPath) + : path.resolve(rootDir, "contracts", "surfaces.web.contract.json"); + if (!fs.existsSync(resolvedPath)) { + return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; + } + try { + const raw = fs.readFileSync(resolvedPath, "utf8"); + const parsed = JSON.parse(raw); + return { + id: parsed.contractId ?? "unknown", + version: parsed.version ?? "unknown", + sha256: sha256FromFile(resolvedPath), + }; + } + catch { + return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; + } +} +function toRelative(rootDir, candidate) { + if (!candidate) + return undefined; + if (path.isAbsolute(candidate)) { + return path.relative(rootDir, candidate); + } + return candidate; +} +function statusSummary(status, findingCodes) { + return { + errorCount: status === "fail" ? Math.max(1, findingCodes.length) : 0, + warnCount: status === "warn" ? Math.max(1, findingCodes.length) : 0, + }; +} +function sortRunsDeterministic(runs) { + return [...runs].sort((left, right) => { + const timeDiff = Date.parse(left.createdAt) - Date.parse(right.createdAt); + if (timeDiff !== 0) + return timeDiff; + return left.runId.localeCompare(right.runId); + }); +} +function latestRunsBySurface(runs) { + const latest = new Map(); + for (const run of runs) { + latest.set(run.surfaceId, run); + } + return latest; +} +function rebuildLineageFromRuns(runs) { + const surfaces = {}; + for (const [surfaceId, run] of latestRunsBySurface(runs).entries()) { + surfaces[surfaceId] = { + lastRunId: run.runId, + lastRunAt: run.createdAt, + lastSource: run.source, + lastStatus: run.status, + contract: run.contract, + artifacts: run.artifacts, + findingCodes: run.findingCodes, + }; + } + return { + schemaVersion: 1, + surfaces, + }; +} +function ensureArtifacts(rootDir) { + const generatedDir = path.join(rootDir, "contracts", "generated"); + fs.mkdirSync(generatedDir, { recursive: true }); + const runsPath = path.join(generatedDir, "contract-runs.json"); + const lineagePath = path.join(generatedDir, "contract-lineage.json"); + const lockPath = path.join(generatedDir, "contract-runs.lock"); + if (!fs.existsSync(runsPath)) { + writeDeterministicJsonSync(runsPath, { schemaVersion: 2, runs: [] }); + } + if (!fs.existsSync(lineagePath)) { + writeDeterministicJsonSync(lineagePath, { schemaVersion: 1, surfaces: {} }); + } + return { + generatedDir, + runsPath, + lineagePath, + lockPath, + }; +} +export function emitContractRunArtifact(input) { + const rootDir = path.resolve(input.rootDir); + const { runsPath, lineagePath, lockPath } = ensureArtifacts(rootDir); + const workspaceId = input.workspaceId?.trim() || DEFAULT_WORKSPACE_ID; + const idempotencyKey = input.idempotencyKey?.trim() || undefined; + const createdAt = input.createdAt ?? new Date().toISOString(); + const runId = input.runId ?? randomUUID(); + const runRecord = { + runId, + workspaceId, + ...(idempotencyKey ? { idempotencyKey } : {}), + ingestedAt: new Date().toISOString(), + createdAt, + surfaceId: input.surfaceId, + source: input.source, + contract: normalizeContractIdentity(rootDir, input.contractPath), + artifacts: { + extractionPath: toRelative(rootDir, input.extractionPath), + reportPath: toRelative(rootDir, input.reportPath), + }, + status: input.status, + findingCodes: [...new Set(input.findingCodes)].sort((left, right) => left.localeCompare(right)), + summary: statusSummary(input.status, input.findingCodes), + }; + acquireLock(lockPath); + try { + const runsDoc = readJson(runsPath, { + schemaVersion: 2, + runs: [], + }); + const currentRuns = Array.isArray(runsDoc.runs) ? runsDoc.runs : []; + const duplicateByRunId = currentRuns.some((run) => (run.workspaceId || DEFAULT_WORKSPACE_ID) === workspaceId && run.runId === runRecord.runId); + const duplicateByIdempotencyKey = Boolean(idempotencyKey) && + currentRuns.some((run) => (run.workspaceId || DEFAULT_WORKSPACE_ID) === workspaceId && + typeof run.idempotencyKey === "string" && + run.idempotencyKey === idempotencyKey); + const nextRuns = duplicateByRunId || duplicateByIdempotencyKey + ? currentRuns + : sortRunsDeterministic([...currentRuns, runRecord]); + const effectiveRun = duplicateByRunId || duplicateByIdempotencyKey + ? nextRuns.find((run) => run.runId === runRecord.runId || + (Boolean(idempotencyKey) && run.idempotencyKey === idempotencyKey)) ?? runRecord + : runRecord; + fs.writeFileSync(runsPath, stringifyDeterministicJson({ + schemaVersion: 2, + runs: nextRuns, + }), "utf8"); + fs.writeFileSync(lineagePath, stringifyDeterministicJson(rebuildLineageFromRuns(nextRuns)), "utf8"); + return { + deduped: Boolean(duplicateByRunId || duplicateByIdempotencyKey), + runId: effectiveRun.runId, + surfaceId: effectiveRun.surfaceId, + runsPath, + lineagePath, + }; + } + finally { + releaseLock(lockPath); + } +} diff --git a/packages/interfacectl-cli/schemas/contract-lineage.schema.json b/packages/interfacectl-cli/schemas/contract-lineage.schema.json new file mode 100644 index 0000000..b1820ce --- /dev/null +++ b/packages/interfacectl-cli/schemas/contract-lineage.schema.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/contract-lineage.schema.json", + "title": "ContractLineageDocument", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "surfaces"], + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "surfaces": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "lastRunId", + "lastRunAt", + "lastSource", + "lastStatus", + "contract", + "artifacts", + "findingCodes" + ], + "properties": { + "lastRunId": { + "type": "string", + "minLength": 1 + }, + "lastRunAt": { + "type": "string", + "format": "date-time" + }, + "lastSource": { + "type": "string", + "enum": ["bootstrap", "generation", "ci", "runtime"] + }, + "lastStatus": { + "type": "string", + "enum": ["pass", "warn", "fail", "unknown"] + }, + "contract": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "sha256"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + } + } + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "properties": { + "extractionPath": { + "type": "string", + "minLength": 1 + }, + "reportPath": { + "type": "string", + "minLength": 1 + } + } + }, + "findingCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } +} diff --git a/packages/interfacectl-cli/schemas/contract-runs.schema.json b/packages/interfacectl-cli/schemas/contract-runs.schema.json new file mode 100644 index 0000000..884806c --- /dev/null +++ b/packages/interfacectl-cli/schemas/contract-runs.schema.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/contract-runs.schema.json", + "title": "ContractRunsDocument", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "runs"], + "properties": { + "schemaVersion": { + "type": "number", + "const": 2 + }, + "runs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "runId", + "workspaceId", + "ingestedAt", + "createdAt", + "surfaceId", + "source", + "contract", + "artifacts", + "status", + "findingCodes", + "summary" + ], + "properties": { + "runId": { + "type": "string", + "minLength": 1 + }, + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1 + }, + "ingestedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "enum": ["bootstrap", "generation", "ci", "runtime"] + }, + "contract": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "sha256"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + } + } + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "properties": { + "extractionPath": { + "type": "string", + "minLength": 1 + }, + "reportPath": { + "type": "string", + "minLength": 1 + } + } + }, + "status": { + "type": "string", + "enum": ["pass", "warn", "fail", "unknown"] + }, + "findingCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["errorCount", "warnCount"], + "properties": { + "errorCount": { + "type": "number", + "minimum": 0 + }, + "warnCount": { + "type": "number", + "minimum": 0 + } + } + } + } + } + } + } +} diff --git a/packages/interfacectl-cli/schemas/generation-assessment.schema.json b/packages/interfacectl-cli/schemas/generation-assessment.schema.json new file mode 100644 index 0000000..2f5bfe4 --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-assessment.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/generation-assessment.schema.json", + "title": "GenerationAssessment", + "type": "object", + "additionalProperties": false, + "required": ["structure", "visual", "responsiveness", "notes"], + "properties": { + "structure": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "visual": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "responsiveness": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "notes": { + "type": "string", + "minLength": 1 + }, + "touchedFiles": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } +} diff --git a/packages/interfacectl-cli/schemas/generation-session-summary.schema.json b/packages/interfacectl-cli/schemas/generation-session-summary.schema.json new file mode 100644 index 0000000..40d4625 --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-session-summary.schema.json @@ -0,0 +1,215 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/generation-session-summary.schema.json", + "title": "GenerationSessionSummary", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "surfaceId", + "sessionId", + "tool", + "attemptCount", + "firstPassAttempt", + "latestStatus", + "recurringFindingCodes", + "recurringRepairCodes", + "latestAssessment", + "successRule", + "paths", + "attempts" + ], + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "tool": { + "type": "string", + "enum": ["codex", "cursor"] + }, + "attemptCount": { + "type": "number", + "minimum": 1 + }, + "firstPassAttempt": { + "type": ["number", "null"] + }, + "latestStatus": { + "type": "string", + "enum": ["pass", "warn", "block"] + }, + "recurringFindingCodes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "count"], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "count": { + "type": "number", + "minimum": 2 + } + } + } + }, + "recurringRepairCodes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "count", "priority", "category", "actionType"], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "count": { + "type": "number", + "minimum": 2 + }, + "priority": { + "type": "string", + "minLength": 1 + }, + "category": { + "type": "string", + "minLength": 1 + }, + "actionType": { + "type": "string", + "minLength": 1 + } + } + } + }, + "latestAssessment": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": ["structure", "visual", "responsiveness", "notes"], + "properties": { + "structure": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "visual": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "responsiveness": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "notes": { + "type": "string", + "minLength": 1 + }, + "touchedFiles": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + ] + }, + "successRule": { + "type": "object", + "additionalProperties": false, + "required": ["finalStatus"], + "properties": { + "finalStatus": { + "type": "string", + "const": "pass" + } + } + }, + "paths": { + "type": "object", + "additionalProperties": false, + "required": ["sessionPath", "bundleRoot", "preparedInputPath"], + "properties": { + "sessionPath": { + "type": "string", + "minLength": 1 + }, + "bundleRoot": { + "type": "string", + "minLength": 1 + }, + "preparedInputPath": { + "type": "string", + "minLength": 1 + } + } + }, + "attempts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "attemptNumber", + "status", + "findingCodes", + "validatePath", + "assessmentPath", + "metadataPath" + ], + "properties": { + "attemptNumber": { + "type": "number", + "minimum": 1 + }, + "status": { + "type": "string", + "enum": ["pass", "warn", "block"] + }, + "findingCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "validatePath": { + "type": "string", + "minLength": 1 + }, + "assessmentPath": { + "type": "string", + "minLength": 1 + }, + "metadataPath": { + "type": "string", + "minLength": 1 + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } +} diff --git a/packages/interfacectl-cli/schemas/generation-session.schema.json b/packages/interfacectl-cli/schemas/generation-session.schema.json new file mode 100644 index 0000000..3d8b3b1 --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-session.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/generation-session.schema.json", + "title": "GenerationSession", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "surfaceId", + "sessionId", + "tool", + "workspaceRoot", + "sourceBundleRoot", + "sessionDir", + "bundleRoot", + "preparedInputPath", + "contractPath", + "repairMapPath", + "startedAt", + "successRule" + ], + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "tool": { + "type": "string", + "enum": ["codex", "cursor"] + }, + "workspaceRoot": { + "type": "string", + "minLength": 1 + }, + "sourceBundleRoot": { + "type": "string", + "minLength": 1 + }, + "sessionDir": { + "type": "string", + "minLength": 1 + }, + "bundleRoot": { + "type": "string", + "minLength": 1 + }, + "preparedInputPath": { + "type": "string", + "minLength": 1 + }, + "contractPath": { + "type": "string", + "minLength": 1 + }, + "repairMapPath": { + "type": "string", + "minLength": 1 + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "successRule": { + "type": "object", + "additionalProperties": false, + "required": ["finalStatus"], + "properties": { + "finalStatus": { + "type": "string", + "const": "pass" + } + } + } + } +} diff --git a/packages/interfacectl-cli/src/commands/emit-run-artifact.ts b/packages/interfacectl-cli/src/commands/emit-run-artifact.ts new file mode 100644 index 0000000..58bee80 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/emit-run-artifact.ts @@ -0,0 +1,71 @@ +import path from "node:path"; +import { emitContractRunArtifact, type RunArtifactSource, type RunArtifactStatus } from "../utils/run-artifacts.js"; + +export interface EmitRunArtifactCommandOptions { + workspaceRoot?: string; + surfaceId?: string; + source?: string; + status?: string; + contractPath?: string; + extractionPath?: string; + reportPath?: string; + findingCodes?: string; + workspaceId?: string; + idempotencyKey?: string; + createdAt?: string; + runId?: string; +} + +const VALID_SOURCES = new Set(["bootstrap", "generation", "ci", "runtime"]); +const VALID_STATUSES = new Set(["pass", "warn", "fail", "unknown"]); + +function writeError(message: string, code: string) { + process.stderr.write(`${JSON.stringify({ status: "error", code, error: message }, null, 2)}\n`); +} + +export async function runEmitRunArtifactCommand( + options: EmitRunArtifactCommandOptions, +): Promise { + try { + if (!options.workspaceRoot) { + throw new Error("--workspace-root is required."); + } + if (!options.surfaceId) { + throw new Error("--surface is required."); + } + if (!options.source || !VALID_SOURCES.has(options.source as RunArtifactSource)) { + throw new Error("--source must be one of bootstrap|generation|ci|runtime."); + } + if (!options.status || !VALID_STATUSES.has(options.status as RunArtifactStatus)) { + throw new Error("--status must be one of pass|warn|fail|unknown."); + } + + const findingCodes = typeof options.findingCodes === "string" + ? options.findingCodes + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + : []; + + const result = emitContractRunArtifact({ + rootDir: path.resolve(options.workspaceRoot), + surfaceId: options.surfaceId, + source: options.source as RunArtifactSource, + status: options.status as RunArtifactStatus, + contractPath: options.contractPath, + extractionPath: options.extractionPath, + reportPath: options.reportPath, + findingCodes, + workspaceId: options.workspaceId, + idempotencyKey: options.idempotencyKey, + createdAt: options.createdAt, + runId: options.runId, + }); + + process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`); + return 0; + } catch (error) { + writeError(error instanceof Error ? error.message : String(error), "run-artifact.input"); + return 10; + } +} diff --git a/packages/interfacectl-cli/src/commands/generation-session.ts b/packages/interfacectl-cli/src/commands/generation-session.ts new file mode 100644 index 0000000..c264500 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/generation-session.ts @@ -0,0 +1,635 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { + AdapterInputError, + loadCompiledSurfaceBundle, + readJsonFile, + type JsonRecord, +} from "../adapter/bundle.js"; +import { runGenerationAdapter } from "../adapter/core.js"; +import { buildPreparedGenerationPayload } from "./prepare-generation.js"; +import { + emitContractRunArtifact, + type RunArtifactStatus, +} from "../utils/run-artifacts.js"; +import { stringifyDeterministicJson, writeDeterministicJsonSync } from "../utils/deterministic-json.js"; + +type SessionTool = "codex" | "cursor"; +type AssessmentGrade = "strong" | "partial" | "weak"; +type ValidateStatus = "pass" | "warn" | "block"; + +export interface InitGenerationSessionCommandOptions { + bundleRoot?: string; + surfaceId?: string; + workspaceRoot?: string; + tool?: string; + sessionId?: string; + artifactsRoot?: string; +} + +export interface RecordGenerationAttemptCommandOptions { + sessionDir?: string; + assessmentFile?: string; +} + +export interface SummarizeGenerationSessionCommandOptions { + sessionDir?: string; +} + +interface GenerationAssessment { + structure: AssessmentGrade; + visual: AssessmentGrade; + responsiveness: AssessmentGrade; + notes: string; + touchedFiles?: string[]; +} + +interface GenerationSession { + schemaVersion: 1; + surfaceId: string; + sessionId: string; + tool: SessionTool; + workspaceRoot: string; + sourceBundleRoot: string; + sessionDir: string; + bundleRoot: string; + preparedInputPath: string; + contractPath: string; + repairMapPath: string; + startedAt: string; + successRule: { + finalStatus: "pass"; + }; +} + +interface GenerationSessionAttemptMetadata { + schemaVersion: 1; + surfaceId: string; + sessionId: string; + attemptNumber: number; + tool: SessionTool; + createdAt: string; + validateStatus: ValidateStatus; + validateExitCode: number; + findingCodes: string[]; + assessmentPath: string; + validatePath: string; + touchedFiles: string[]; + contractRun: { + deduped: boolean; + runId: string; + surfaceId: string; + runsPath: string; + lineagePath: string; + } | null; +} + +interface GenerationSessionSummary { + schemaVersion: 1; + surfaceId: string; + sessionId: string; + tool: SessionTool; + attemptCount: number; + firstPassAttempt: number | null; + latestStatus: ValidateStatus; + recurringFindingCodes: Array<{ code: string; count: number }>; + recurringRepairCodes: Array<{ + code: string; + count: number; + priority: string; + category: string; + actionType: string; + }>; + latestAssessment: GenerationAssessment | null; + successRule: { + finalStatus: "pass"; + }; + paths: { + sessionPath: string; + bundleRoot: string; + preparedInputPath: string; + }; + attempts: Array<{ + attemptNumber: number; + status: ValidateStatus; + findingCodes: string[]; + validatePath: string; + assessmentPath: string; + metadataPath: string; + createdAt?: string; + }>; +} + +const VALID_TOOLS = new Set(["codex", "cursor"]); +const VALID_GRADES = new Set(["strong", "partial", "weak"]); + +class SessionInputError extends Error { + code: string; + + constructor(message: string, code = "generation-session.input") { + super(message); + this.name = "SessionInputError"; + this.code = code; + } +} + +function writeError(error: SessionInputError | AdapterInputError | Error, code: string) { + process.stderr.write( + `${JSON.stringify( + { + status: "error", + code, + error: error.message, + }, + null, + 2, + )}\n`, + ); +} + +function ensureSessionTool(tool?: string): SessionTool { + const normalized = typeof tool === "string" ? tool.trim().toLowerCase() : "codex"; + if (!VALID_TOOLS.has(normalized as SessionTool)) { + throw new SessionInputError(`Invalid --tool value "${tool ?? ""}". Expected codex|cursor.`); + } + return normalized as SessionTool; +} + +function buildDefaultSessionId(): string { + return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +function resolveWorkspaceRelative(workspaceRoot: string, candidate?: string): string { + if (!candidate || candidate.trim().length === 0) { + return path.join(workspaceRoot, "artifacts", "generation-sessions"); + } + return path.isAbsolute(candidate) ? candidate : path.resolve(workspaceRoot, candidate); +} + +function getSessionPaths(sessionDir: string) { + return { + sessionDir, + sessionPath: path.join(sessionDir, "session.json"), + bundleRoot: path.join(sessionDir, "bundle"), + preparedInputPath: path.join(sessionDir, "prepared-input.json"), + attemptsDir: path.join(sessionDir, "attempts"), + summaryJsonPath: path.join(sessionDir, "summary.json"), + summaryMarkdownPath: path.join(sessionDir, "summary.md"), + }; +} + +function normalizeAssessment(payload: JsonRecord, filePath: string): GenerationAssessment { + const grade = (key: keyof Pick) => { + const value = payload[key]; + if (!VALID_GRADES.has(value as AssessmentGrade)) { + throw new SessionInputError( + `Assessment field "${key}" must be one of strong|partial|weak: ${filePath}.`, + ); + } + return value as AssessmentGrade; + }; + + const notes = typeof payload.notes === "string" ? payload.notes.trim() : ""; + if (!notes) { + throw new SessionInputError(`Assessment field "notes" must be a non-empty string: ${filePath}.`); + } + + let touchedFiles: string[] | undefined; + if (payload.touchedFiles !== undefined) { + if (!Array.isArray(payload.touchedFiles)) { + throw new SessionInputError(`Assessment field "touchedFiles" must be an array when provided: ${filePath}.`); + } + touchedFiles = [...new Set( + payload.touchedFiles + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean), + )].sort((left, right) => left.localeCompare(right)); + } + + return { + structure: grade("structure"), + visual: grade("visual"), + responsiveness: grade("responsiveness"), + notes, + ...(touchedFiles && touchedFiles.length > 0 ? { touchedFiles } : {}), + }; +} + +function loadAssessment(assessmentPath: string): GenerationAssessment { + const resolvedPath = path.resolve(assessmentPath); + if (!fs.existsSync(resolvedPath)) { + throw new SessionInputError(`Assessment file not found at ${resolvedPath}.`); + } + return normalizeAssessment(readJsonFile(resolvedPath, "generation assessment"), resolvedPath); +} + +function loadSession(sessionDirInput: string): { session: GenerationSession; paths: ReturnType } { + const sessionDir = path.resolve(sessionDirInput); + const paths = getSessionPaths(sessionDir); + if (!fs.existsSync(paths.sessionPath)) { + throw new SessionInputError(`Generation session not found at ${paths.sessionPath}.`); + } + + const session = readJsonFile(paths.sessionPath, "generation session") as unknown as GenerationSession; + if (session.schemaVersion !== 1) { + throw new SessionInputError(`Unsupported generation session schemaVersion "${String((session as unknown as JsonRecord).schemaVersion ?? "unknown")}".`); + } + if (!VALID_TOOLS.has(session.tool)) { + throw new SessionInputError(`Unsupported session tool "${session.tool}".`); + } + + return { + session, + paths, + }; +} + +function nextAttemptNumber(attemptsDir: string): number { + if (!fs.existsSync(attemptsDir)) { + return 1; + } + + const seen = fs.readdirSync(attemptsDir) + .map((entry) => /^(\d{3})\.metadata\.json$/.exec(entry)) + .filter((match): match is RegExpExecArray => Boolean(match)) + .map((match) => Number.parseInt(match[1], 10)); + + if (seen.length === 0) { + return 1; + } + return Math.max(...seen) + 1; +} + +function formatAttemptNumber(attemptNumber: number): string { + return String(attemptNumber).padStart(3, "0"); +} + +function mapAdapterStatusToRunStatus(status: ValidateStatus): RunArtifactStatus { + switch (status) { + case "pass": + return "pass"; + case "warn": + return "warn"; + case "block": + return "fail"; + } +} + +function parseFindingCodes(validatePayload: JsonRecord): string[] { + const findings = Array.isArray(validatePayload.findings) ? validatePayload.findings : []; + return [...new Set( + findings + .map((finding) => { + if (!finding || typeof finding !== "object") return ""; + const code = (finding as Record).code; + return typeof code === "string" ? code : ""; + }) + .filter(Boolean), + )].sort((left, right) => left.localeCompare(right)); +} + +function buildRecurringCounts(values: string[]) { + const counts = new Map(); + for (const value of values) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + return [...counts.entries()] + .filter(([, count]) => count > 1) + .map(([code, count]) => ({ code, count })) + .sort((left, right) => right.count - left.count || left.code.localeCompare(right.code)); +} + +function renderSummaryMarkdown(summary: GenerationSessionSummary): string { + const lines = [ + "# Generation Session Summary", + "", + `Surface: ${summary.surfaceId}`, + `Session: ${summary.sessionId}`, + `Tool: ${summary.tool}`, + `Latest status: ${summary.latestStatus}`, + `Attempts: ${summary.attemptCount}`, + `First pass attempt: ${summary.firstPassAttempt ?? "not yet reached"}`, + "", + "## Recurring finding codes", + ]; + + if (summary.recurringFindingCodes.length === 0) { + lines.push("None."); + } else { + for (const item of summary.recurringFindingCodes) { + lines.push(`- ${item.code}: ${item.count}`); + } + } + + lines.push("", "## Recurring repair codes"); + if (summary.recurringRepairCodes.length === 0) { + lines.push("None."); + } else { + for (const item of summary.recurringRepairCodes) { + lines.push(`- ${item.code}: ${item.count} (${item.priority})`); + } + } + + lines.push("", "## Latest assessment"); + lines.push(`- structure: ${summary.latestAssessment?.structure ?? "n/a"}`); + lines.push(`- visual: ${summary.latestAssessment?.visual ?? "n/a"}`); + lines.push(`- responsiveness: ${summary.latestAssessment?.responsiveness ?? "n/a"}`); + lines.push(`- notes: ${summary.latestAssessment?.notes ?? "n/a"}`); + + if (summary.latestAssessment?.touchedFiles?.length) { + lines.push(`- touched files: ${summary.latestAssessment.touchedFiles.join(", ")}`); + } + + return `${lines.join("\n")}\n`; +} + +export async function runInitGenerationSessionCommand( + options: InitGenerationSessionCommandOptions, +): Promise { + try { + if (!options.bundleRoot) { + throw new SessionInputError("--bundle-root is required."); + } + if (!options.surfaceId) { + throw new SessionInputError("--surface is required."); + } + if (!options.workspaceRoot) { + throw new SessionInputError("--workspace-root is required."); + } + + const tool = ensureSessionTool(options.tool); + const workspaceRoot = path.resolve(options.workspaceRoot); + if (!fs.existsSync(workspaceRoot) || !fs.statSync(workspaceRoot).isDirectory()) { + throw new SessionInputError(`Workspace root directory not found at ${workspaceRoot}.`); + } + + const loadedBundle = loadCompiledSurfaceBundle(options.bundleRoot, options.surfaceId, process.cwd()); + const sessionId = options.sessionId?.trim() || buildDefaultSessionId(); + const artifactsRoot = resolveWorkspaceRelative(workspaceRoot, options.artifactsRoot); + const sessionDir = path.join(artifactsRoot, options.surfaceId, sessionId); + const paths = getSessionPaths(sessionDir); + + if (fs.existsSync(paths.sessionDir)) { + throw new SessionInputError(`Generation session already exists at ${paths.sessionDir}.`); + } + + fs.mkdirSync(paths.sessionDir, { recursive: true }); + fs.cpSync(loadedBundle.root, paths.bundleRoot, { recursive: true }); + + const sessionBundle = loadCompiledSurfaceBundle(paths.bundleRoot, options.surfaceId, process.cwd()); + const preparedPayload = buildPreparedGenerationPayload(sessionBundle); + writeDeterministicJsonSync(paths.preparedInputPath, preparedPayload); + + const session: GenerationSession = { + schemaVersion: 1, + surfaceId: options.surfaceId, + sessionId, + tool, + workspaceRoot, + sourceBundleRoot: loadedBundle.root, + sessionDir: paths.sessionDir, + bundleRoot: paths.bundleRoot, + preparedInputPath: paths.preparedInputPath, + contractPath: sessionBundle.contract.path, + repairMapPath: sessionBundle.surface.repairMap.path, + startedAt: new Date().toISOString(), + successRule: { + finalStatus: "pass", + }, + }; + writeDeterministicJsonSync(paths.sessionPath, session); + + process.stdout.write( + `${JSON.stringify({ ok: true, session, paths }, null, 2)}\n`, + ); + return 0; + } catch (error) { + if (error instanceof SessionInputError || error instanceof AdapterInputError) { + writeError(error, error.code); + return 10; + } + writeError(error instanceof Error ? error : new Error(String(error)), "generation-session.internal"); + return 1; + } +} + +export async function runRecordGenerationAttemptCommand( + options: RecordGenerationAttemptCommandOptions, +): Promise { + try { + if (!options.sessionDir) { + throw new SessionInputError("--session-dir is required."); + } + if (!options.assessmentFile) { + throw new SessionInputError("--assessment-file is required."); + } + + const { session, paths } = loadSession(options.sessionDir); + const assessment = loadAssessment(options.assessmentFile); + const attemptNumber = nextAttemptNumber(paths.attemptsDir); + const attemptId = formatAttemptNumber(attemptNumber); + fs.mkdirSync(paths.attemptsDir, { recursive: true }); + + const validatePath = path.join(paths.attemptsDir, `${attemptId}.validate.json`); + const assessmentPath = path.join(paths.attemptsDir, `${attemptId}.assessment.json`); + const metadataPath = path.join(paths.attemptsDir, `${attemptId}.metadata.json`); + + const response = await runGenerationAdapter( + { + requestId: crypto.randomUUID(), + tool: session.tool, + surfaceId: session.surfaceId, + mode: "workspace", + bundleRoot: session.bundleRoot, + workspaceRoot: session.workspaceRoot, + provenance: { + sessionId: session.sessionId, + timestamp: new Date().toISOString(), + }, + }, + { + cwd: process.cwd(), + }, + ); + + writeDeterministicJsonSync(validatePath, response); + writeDeterministicJsonSync(assessmentPath, assessment); + + const findingCodes = parseFindingCodes(response as unknown as JsonRecord); + const contractRun = emitContractRunArtifact({ + rootDir: session.workspaceRoot, + contractPath: session.contractPath, + surfaceId: session.surfaceId, + source: "generation", + status: mapAdapterStatusToRunStatus(response.status), + reportPath: validatePath, + findingCodes, + workspaceId: session.sessionId, + idempotencyKey: `${session.surfaceId}:${session.sessionId}:${attemptId}`, + }); + + const metadata: GenerationSessionAttemptMetadata = { + schemaVersion: 1, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + attemptNumber, + tool: session.tool, + createdAt: new Date().toISOString(), + validateStatus: response.status, + validateExitCode: response.status === "block" ? 30 : 0, + findingCodes, + assessmentPath, + validatePath, + touchedFiles: assessment.touchedFiles ?? [], + contractRun, + }; + writeDeterministicJsonSync(metadataPath, metadata); + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + session, + attempt: { + attemptNumber, + validatePath, + assessmentPath, + metadataPath, + validateStatus: response.status, + findingCodes, + }, + }, + null, + 2, + )}\n`, + ); + return 0; + } catch (error) { + if (error instanceof SessionInputError || error instanceof AdapterInputError) { + writeError(error, error.code); + return 10; + } + writeError(error instanceof Error ? error : new Error(String(error)), "generation-session.internal"); + return 1; + } +} + +export async function runSummarizeGenerationSessionCommand( + options: SummarizeGenerationSessionCommandOptions, +): Promise { + try { + if (!options.sessionDir) { + throw new SessionInputError("--session-dir is required."); + } + const { session, paths } = loadSession(options.sessionDir); + + if (!fs.existsSync(paths.attemptsDir)) { + throw new SessionInputError(`No attempts recorded for session ${session.sessionId}.`); + } + + const attemptNumbers = fs.readdirSync(paths.attemptsDir) + .map((entry) => /^(\d{3})\.metadata\.json$/.exec(entry)) + .filter((match): match is RegExpExecArray => Boolean(match)) + .map((match) => Number.parseInt(match[1], 10)) + .sort((left, right) => left - right); + + if (attemptNumbers.length === 0) { + throw new SessionInputError(`No attempts recorded for session ${session.sessionId}.`); + } + + const attempts = attemptNumbers.map((attemptNumber) => { + const attemptId = formatAttemptNumber(attemptNumber); + const validatePath = path.join(paths.attemptsDir, `${attemptId}.validate.json`); + const assessmentPath = path.join(paths.attemptsDir, `${attemptId}.assessment.json`); + const metadataPath = path.join(paths.attemptsDir, `${attemptId}.metadata.json`); + + return { + attemptNumber, + validate: readJsonFile(validatePath, `attempt ${attemptId} validate payload`), + assessment: readJsonFile(assessmentPath, `attempt ${attemptId} assessment`), + metadata: readJsonFile(metadataPath, `attempt ${attemptId} metadata`), + validatePath, + assessmentPath, + metadataPath, + }; + }); + + const firstPassAttempt = attempts.find((attempt) => attempt.validate.status === "pass")?.attemptNumber ?? null; + const latestAttempt = attempts[attempts.length - 1]; + const recurringFindingCodes = buildRecurringCounts( + attempts.flatMap((attempt) => parseFindingCodes(attempt.validate)), + ); + const repairMapDoc = readJsonFile(session.repairMapPath, "repair map"); + const repairs = Array.isArray(repairMapDoc.repairs) ? repairMapDoc.repairs : []; + const repairMapByCode = new Map( + repairs + .filter((entry): entry is JsonRecord => Boolean(entry) && typeof entry === "object") + .map((entry) => [typeof entry.code === "string" ? entry.code : "", entry] as const) + .filter(([code]) => Boolean(code)), + ); + const recurringRepairCodes = recurringFindingCodes + .map((entry) => { + const repair = repairMapByCode.get(entry.code); + if (!repair) return undefined; + const action = repair.action && typeof repair.action === "object" + ? (repair.action as Record) + : {}; + return { + code: entry.code, + count: entry.count, + priority: typeof repair.priority === "string" ? repair.priority : "medium", + category: typeof repair.category === "string" ? repair.category : "unknown", + actionType: typeof action.type === "string" ? action.type : "unknown", + }; + }) + .filter((entry): entry is NonNullable => Boolean(entry)) + .sort((left, right) => right.count - left.count || left.code.localeCompare(right.code)); + + const latestAssessment = normalizeAssessment(latestAttempt.assessment, latestAttempt.assessmentPath); + const latestStatus = latestAttempt.validate.status; + if (latestStatus !== "pass" && latestStatus !== "warn" && latestStatus !== "block") { + throw new SessionInputError(`Unsupported validate status "${String(latestStatus)}" in ${latestAttempt.validatePath}.`); + } + + const summary: GenerationSessionSummary = { + schemaVersion: 1, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + tool: session.tool, + attemptCount: attempts.length, + firstPassAttempt, + latestStatus, + recurringFindingCodes, + recurringRepairCodes, + latestAssessment, + successRule: session.successRule, + paths: { + sessionPath: paths.sessionPath, + bundleRoot: session.bundleRoot, + preparedInputPath: session.preparedInputPath, + }, + attempts: attempts.map((attempt) => ({ + attemptNumber: attempt.attemptNumber, + status: attempt.validate.status as ValidateStatus, + findingCodes: parseFindingCodes(attempt.validate), + validatePath: attempt.validatePath, + assessmentPath: attempt.assessmentPath, + metadataPath: attempt.metadataPath, + createdAt: typeof attempt.metadata.createdAt === "string" ? attempt.metadata.createdAt : undefined, + })), + }; + + writeDeterministicJsonSync(paths.summaryJsonPath, summary); + fs.writeFileSync(paths.summaryMarkdownPath, renderSummaryMarkdown(summary), "utf8"); + process.stdout.write(`${JSON.stringify({ ok: true, summary, paths }, null, 2)}\n`); + return latestStatus === "pass" ? 0 : 30; + } catch (error) { + if (error instanceof SessionInputError || error instanceof AdapterInputError) { + writeError(error, error.code); + return 10; + } + writeError(error instanceof Error ? error : new Error(String(error)), "generation-session.internal"); + return 1; + } +} diff --git a/packages/interfacectl-cli/src/commands/prepare-generation.ts b/packages/interfacectl-cli/src/commands/prepare-generation.ts index bb31e37..8a0940b 100644 --- a/packages/interfacectl-cli/src/commands/prepare-generation.ts +++ b/packages/interfacectl-cli/src/commands/prepare-generation.ts @@ -7,6 +7,7 @@ import { type JsonRecord, type LoadedCompiledSurfaceBundle, } from "../adapter/bundle.js"; +import { stringifyDeterministicJson } from "../utils/deterministic-json.js"; export interface PrepareGenerationCommandOptions { bundleRoot?: string; @@ -23,27 +24,6 @@ interface SummaryRepairItem { actionType: string; } -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 key of Object.keys(value as Record).sort()) { - sorted[key] = sortKeysRecursive((value as Record)[key]); - } - return sorted; - } - return value; -} - -function stringifyDeterministic(value: unknown): string { - return `${JSON.stringify(sortKeysRecursive(value), null, 2)}\n`; -} - function asRecord(value: unknown): JsonRecord { return isRecord(value) ? value : {}; } @@ -168,7 +148,7 @@ function buildSummary(bundle: LoadedCompiledSurfaceBundle) { }; } -function buildPreparedGenerationPayload(bundle: LoadedCompiledSurfaceBundle) { +export function buildPreparedGenerationPayload(bundle: LoadedCompiledSurfaceBundle) { const generation = asRecord(bundle.surface.generation.value); const generationRefs = asRecord(generation.refs); const identity = asRecord(generation.identity); @@ -224,6 +204,15 @@ function buildPreparedGenerationPayload(bundle: LoadedCompiledSurfaceBundle) { }; } +export function loadPreparedGenerationPayload( + bundleRoot: string, + surfaceId: string, + cwd = process.cwd(), +) { + const bundle = loadCompiledSurfaceBundle(bundleRoot, surfaceId, cwd); + return buildPreparedGenerationPayload(bundle); +} + function writeError(error: AdapterInputError | Error, code: string) { process.stderr.write( `${JSON.stringify( @@ -249,9 +238,8 @@ export async function runPrepareGenerationCommand( throw new AdapterInputError("--surface is required."); } - const bundle = loadCompiledSurfaceBundle(options.bundleRoot, options.surfaceId, process.cwd()); - const payload = buildPreparedGenerationPayload(bundle); - const serialized = stringifyDeterministic(payload); + const payload = loadPreparedGenerationPayload(options.bundleRoot, options.surfaceId, process.cwd()); + const serialized = stringifyDeterministicJson(payload); if (options.outPath) { const outPath = path.resolve(options.outPath); diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index 4447cf1..71e9753 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -13,6 +13,12 @@ import { runDescribeCommand } from "./commands/describe.js"; import { runPrepareGenerationCommand } from "./commands/prepare-generation.js"; import { runValidateGenerationCommand } from "./commands/validate-generation.js"; import { runServeGenerationAdapterCommand } from "./commands/serve-generation-adapter.js"; +import { runEmitRunArtifactCommand } from "./commands/emit-run-artifact.js"; +import { + runInitGenerationSessionCommand, + runRecordGenerationAttemptCommand, + runSummarizeGenerationSessionCommand, +} from "./commands/generation-session.js"; import { runInitCommand } from "./commands/init.js"; import { runAnalyzeCommand } from "./commands/analyze.js"; import { @@ -302,6 +308,48 @@ program }); }); +program + .command("init-generation-session") + .description("Freeze one compiled bundle revision into a tracked local generation session") + .requiredOption("--bundle-root ", "Path to the compiled generation bundle directory") + .requiredOption("--surface ", "Surface identifier") + .requiredOption("--workspace-root ", "Workspace root for emitted run artifacts") + .option("--tool ", "Generation tool identifier (codex|cursor)") + .option("--session ", "Optional session identifier") + .option("--artifacts-root ", "Optional session artifacts root (defaults under workspaceRoot/artifacts/generation-sessions)") + .action(async (options) => { + process.exitCode = await runInitGenerationSessionCommand({ + bundleRoot: options.bundleRoot, + surfaceId: options.surface, + workspaceRoot: options.workspaceRoot, + tool: options.tool, + sessionId: options.session, + artifactsRoot: options.artifactsRoot, + }); + }); + +program + .command("record-generation-attempt") + .description("Validate and record one generation attempt for a tracked session") + .requiredOption("--session-dir ", "Path to the generation session directory") + .requiredOption("--assessment-file ", "Path to the assessment JSON file") + .action(async (options) => { + process.exitCode = await runRecordGenerationAttemptCommand({ + sessionDir: options.sessionDir, + assessmentFile: options.assessmentFile, + }); + }); + +program + .command("summarize-generation-session") + .description("Aggregate recorded generation attempts for a tracked session") + .requiredOption("--session-dir ", "Path to the generation session directory") + .action(async (options) => { + process.exitCode = await runSummarizeGenerationSessionCommand({ + sessionDir: options.sessionDir, + }); + }); + program .command("validate-generation") .description("Validate generated UI against a compiled generation bundle") @@ -348,6 +396,38 @@ program }); }); +program + .command("emit-run-artifact") + .description("Emit one canonical run artifact into contracts/generated") + .requiredOption("--workspace-root ", "Workspace root for contracts/generated") + .requiredOption("--surface ", "Surface identifier") + .requiredOption("--source ", "Run source") + .requiredOption("--status ", "Run status") + .option("--contract ", "Optional contract path override") + .option("--extraction-path ", "Optional extraction artifact path") + .option("--report-path ", "Optional report artifact path") + .option("--finding-codes ", "Optional comma-separated finding codes") + .option("--workspace-id ", "Optional workspace identifier") + .option("--idempotency-key ", "Optional idempotency key") + .option("--created-at ", "Optional created-at timestamp") + .option("--run-id ", "Optional run identifier") + .action(async (options) => { + process.exitCode = await runEmitRunArtifactCommand({ + workspaceRoot: options.workspaceRoot, + surfaceId: options.surface, + source: options.source, + status: options.status, + contractPath: options.contract, + extractionPath: options.extractionPath, + reportPath: options.reportPath, + findingCodes: options.findingCodes, + workspaceId: options.workspaceId, + idempotencyKey: options.idempotencyKey, + createdAt: options.createdAt, + runId: options.runId, + }); + }); + program .command("generate-contract") .description("Extract a contract from a Next.js app (Phase 0: extraction only)") diff --git a/packages/interfacectl-cli/src/utils/deterministic-json.ts b/packages/interfacectl-cli/src/utils/deterministic-json.ts new file mode 100644 index 0000000..1641c79 --- /dev/null +++ b/packages/interfacectl-cli/src/utils/deterministic-json.ts @@ -0,0 +1,20 @@ +import fs from "node:fs"; +import { mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { stableStringify } from "@surfaces/interfacectl-extractor"; + +export function stringifyDeterministicJson(value: unknown): string { + return `${stableStringify(value)}\n`; +} + +export function writeDeterministicJsonSync(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, stringifyDeterministicJson(value), "utf8"); +} + +export async function writeDeterministicJson(filePath: string, value: unknown): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp`; + await writeFile(tempPath, stringifyDeterministicJson(value), "utf8"); + await rename(tempPath, filePath); +} diff --git a/packages/interfacectl-cli/src/utils/onboarding.ts b/packages/interfacectl-cli/src/utils/onboarding.ts index 6c40310..767c8cf 100644 --- a/packages/interfacectl-cli/src/utils/onboarding.ts +++ b/packages/interfacectl-cli/src/utils/onboarding.ts @@ -3,56 +3,9 @@ import { existsSync } from "node:fs"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import { stableStringify } from "@surfaces/interfacectl-extractor"; - -type ValidationOutcome = "pass" | "warn" | "fail" | "unknown"; -export type OnboardingRunSource = "bootstrap" | "generation" | "ci" | "runtime"; - -interface ContractRunsDocument { - schemaVersion: number; - runs: ContractRun[]; -} - -interface ContractRun { - runId: string; - createdAt: string; - surfaceId: string; - source: OnboardingRunSource; - contract: { - id: string; - version: string; - sha256: string; - }; - artifacts: { - extractionPath?: string; - reportPath?: string; - }; - status: ValidationOutcome; - findingCodes: string[]; - summary: { - errorCount: number; - warnCount: number; - }; -} - -interface ContractLineageDocument { - schemaVersion: number; - surfaces: Record; -} +import { emitContractRunArtifact, type RunArtifactStatus, type RunArtifactSource } from "./run-artifacts.js"; +type ValidationOutcome = RunArtifactStatus; +export type OnboardingRunSource = RunArtifactSource; export interface BootstrapExtractionReport { surfaceId: string; @@ -80,13 +33,6 @@ export interface BootstrapExtractionReport { }; } -function statusSummary(status: ValidationOutcome, findingCodes: string[]) { - return { - errorCount: status === "fail" ? Math.max(1, findingCodes.length) : 0, - warnCount: status === "warn" ? Math.max(1, findingCodes.length) : 0, - }; -} - async function writeJsonAtomic(filePath: string, payload: unknown): Promise { await mkdir(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.tmp`; @@ -108,10 +54,6 @@ function sha256FromContent(content: string): string { return createHash("sha256").update(content).digest("hex"); } -function toRelative(root: string, candidate: string): string { - return path.relative(root, candidate); -} - export function suggestSurfaceIdFromUrl(rawUrl: string): string { const url = new URL(rawUrl); const host = url.hostname.replace(/^www\./, ""); @@ -222,28 +164,6 @@ export async function writeBootstrapArtifacts(input: { return { contractPath, reportPath }; } -async function readCanonicalContract(rootDir: string): Promise<{ - id: string; - version: string; - sha256: string; -}> { - const canonicalPath = path.resolve(rootDir, "contracts", "surfaces.web.contract.json"); - if (!existsSync(canonicalPath)) { - return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; - } - try { - const raw = await readFile(canonicalPath, "utf-8"); - const parsed = JSON.parse(raw) as { contractId?: string; version?: string }; - return { - id: parsed.contractId ?? "unknown", - version: parsed.version ?? "unknown", - sha256: sha256FromContent(raw), - }; - } catch { - return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; - } -} - export async function emitOnboardingRunArtifact(input: { rootDir: string; surfaceId: string; @@ -253,58 +173,16 @@ export async function emitOnboardingRunArtifact(input: { extractionPath: string; reportPath: string; }): Promise<{ runId: string }> { - const generatedDir = path.resolve(input.rootDir, "contracts", "generated"); - const runsPath = path.join(generatedDir, "contract-runs.json"); - const lineagePath = path.join(generatedDir, "contract-lineage.json"); - - const runsDoc = await readJson(runsPath, { - schemaVersion: 1, - runs: [], - }); - const lineageDoc = await readJson(lineagePath, { - schemaVersion: 1, - surfaces: {}, - }); - - const runId = randomUUID(); - const createdAt = new Date().toISOString(); - const canonical = await readCanonicalContract(input.rootDir); - const runRecord: ContractRun = { - runId, - createdAt, + const result = emitContractRunArtifact({ + rootDir: input.rootDir, surfaceId: input.surfaceId, source: input.source, - contract: canonical, - artifacts: { - extractionPath: toRelative(input.rootDir, input.extractionPath), - reportPath: toRelative(input.rootDir, input.reportPath), - }, status: input.status, - findingCodes: [...input.findingCodes].sort(), - summary: statusSummary(input.status, input.findingCodes), - }; - - const deduped = runsDoc.runs.filter((run) => run.runId !== runId); - deduped.push(runRecord); - deduped.sort((a, b) => { - const timeDiff = Date.parse(a.createdAt) - Date.parse(b.createdAt); - if (timeDiff !== 0) return timeDiff; - return a.runId.localeCompare(b.runId); + findingCodes: input.findingCodes, + extractionPath: input.extractionPath, + reportPath: input.reportPath, }); - runsDoc.runs = deduped; - lineageDoc.surfaces[input.surfaceId] = { - lastRunId: runRecord.runId, - lastRunAt: runRecord.createdAt, - lastSource: runRecord.source, - lastStatus: runRecord.status, - contract: runRecord.contract, - artifacts: runRecord.artifacts, - findingCodes: runRecord.findingCodes, - }; - - await writeJsonAtomic(runsPath, runsDoc); - await writeJsonAtomic(lineagePath, lineageDoc); - return { runId }; + return { runId: result.runId }; } export async function emitBootstrapRunArtifact(input: { diff --git a/packages/interfacectl-cli/src/utils/run-artifacts.ts b/packages/interfacectl-cli/src/utils/run-artifacts.ts new file mode 100644 index 0000000..94c18e6 --- /dev/null +++ b/packages/interfacectl-cli/src/utils/run-artifacts.ts @@ -0,0 +1,310 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { stringifyDeterministicJson, writeDeterministicJsonSync } from "./deterministic-json.js"; + +export type RunArtifactStatus = "pass" | "warn" | "fail" | "unknown"; +export type RunArtifactSource = "bootstrap" | "generation" | "ci" | "runtime"; + +export interface ContractRun { + runId: string; + workspaceId: string; + idempotencyKey?: string; + ingestedAt?: string; + createdAt: string; + surfaceId: string; + source: RunArtifactSource; + contract: { + id: string; + version: string; + sha256: string; + }; + artifacts: { + extractionPath?: string; + reportPath?: string; + }; + status: RunArtifactStatus; + findingCodes: string[]; + summary: { + errorCount: number; + warnCount: number; + }; +} + +export interface ContractRunsDocument { + schemaVersion: 2; + runs: ContractRun[]; +} + +export interface ContractLineageRecord { + lastRunId: string; + lastRunAt: string; + lastSource: RunArtifactSource; + lastStatus: RunArtifactStatus; + contract: { + id: string; + version: string; + sha256: string; + }; + artifacts: { + extractionPath?: string; + reportPath?: string; + }; + findingCodes: string[]; +} + +export interface ContractLineageDocument { + schemaVersion: 1; + surfaces: Record; +} + +export interface EmitContractRunArtifactInput { + rootDir: string; + surfaceId: string; + source: RunArtifactSource; + status: RunArtifactStatus; + findingCodes: string[]; + extractionPath?: string; + reportPath?: string; + contractPath?: string; + workspaceId?: string; + idempotencyKey?: string; + createdAt?: string; + runId?: string; +} + +export interface EmitContractRunArtifactResult { + deduped: boolean; + runId: string; + surfaceId: string; + runsPath: string; + lineagePath: string; +} + +const LOCK_TIMEOUT_MS = 5_000; +const LOCK_POLL_MS = 25; +const DEFAULT_WORKSPACE_ID = "ws-local-default"; + +function sleep(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function acquireLock(lockPath: string, timeoutMs = LOCK_TIMEOUT_MS): void { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + fs.mkdirSync(lockPath); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") { + throw error; + } + sleep(LOCK_POLL_MS); + } + } + throw new Error(`Timed out acquiring run-artifact lock: ${lockPath}`); +} + +function releaseLock(lockPath: string): void { + try { + fs.rmdirSync(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + } +} + +function readJson(filePath: string, fallback: T): T { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; + } catch { + return fallback; + } +} + +function sha256FromFile(filePath: string): string { + const content = fs.readFileSync(filePath); + return createHash("sha256").update(content).digest("hex"); +} + +function normalizeContractIdentity(rootDir: string, contractPath?: string): { + id: string; + version: string; + sha256: string; +} { + const resolvedPath = contractPath + ? path.resolve(rootDir, contractPath) + : path.resolve(rootDir, "contracts", "surfaces.web.contract.json"); + + if (!fs.existsSync(resolvedPath)) { + return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; + } + + try { + const raw = fs.readFileSync(resolvedPath, "utf8"); + const parsed = JSON.parse(raw) as { contractId?: string; version?: string }; + return { + id: parsed.contractId ?? "unknown", + version: parsed.version ?? "unknown", + sha256: sha256FromFile(resolvedPath), + }; + } catch { + return { id: "unknown", version: "unknown", sha256: "0".repeat(64) }; + } +} + +function toRelative(rootDir: string, candidate?: string): string | undefined { + if (!candidate) return undefined; + if (path.isAbsolute(candidate)) { + return path.relative(rootDir, candidate); + } + return candidate; +} + +function statusSummary(status: RunArtifactStatus, findingCodes: string[]) { + return { + errorCount: status === "fail" ? Math.max(1, findingCodes.length) : 0, + warnCount: status === "warn" ? Math.max(1, findingCodes.length) : 0, + }; +} + +function sortRunsDeterministic(runs: ContractRun[]): ContractRun[] { + return [...runs].sort((left, right) => { + const timeDiff = Date.parse(left.createdAt) - Date.parse(right.createdAt); + if (timeDiff !== 0) return timeDiff; + return left.runId.localeCompare(right.runId); + }); +} + +function latestRunsBySurface(runs: ContractRun[]): Map { + const latest = new Map(); + for (const run of runs) { + latest.set(run.surfaceId, run); + } + return latest; +} + +function rebuildLineageFromRuns(runs: ContractRun[]): ContractLineageDocument { + const surfaces: Record = {}; + for (const [surfaceId, run] of latestRunsBySurface(runs).entries()) { + surfaces[surfaceId] = { + lastRunId: run.runId, + lastRunAt: run.createdAt, + lastSource: run.source, + lastStatus: run.status, + contract: run.contract, + artifacts: run.artifacts, + findingCodes: run.findingCodes, + }; + } + return { + schemaVersion: 1, + surfaces, + }; +} + +function ensureArtifacts(rootDir: string): { + generatedDir: string; + runsPath: string; + lineagePath: string; + lockPath: string; +} { + const generatedDir = path.join(rootDir, "contracts", "generated"); + fs.mkdirSync(generatedDir, { recursive: true }); + const runsPath = path.join(generatedDir, "contract-runs.json"); + const lineagePath = path.join(generatedDir, "contract-lineage.json"); + const lockPath = path.join(generatedDir, "contract-runs.lock"); + + if (!fs.existsSync(runsPath)) { + writeDeterministicJsonSync(runsPath, { schemaVersion: 2, runs: [] }); + } + if (!fs.existsSync(lineagePath)) { + writeDeterministicJsonSync(lineagePath, { schemaVersion: 1, surfaces: {} }); + } + + return { + generatedDir, + runsPath, + lineagePath, + lockPath, + }; +} + +export function emitContractRunArtifact( + input: EmitContractRunArtifactInput, +): EmitContractRunArtifactResult { + const rootDir = path.resolve(input.rootDir); + const { runsPath, lineagePath, lockPath } = ensureArtifacts(rootDir); + const workspaceId = input.workspaceId?.trim() || DEFAULT_WORKSPACE_ID; + const idempotencyKey = input.idempotencyKey?.trim() || undefined; + const createdAt = input.createdAt ?? new Date().toISOString(); + const runId = input.runId ?? randomUUID(); + + const runRecord: ContractRun = { + runId, + workspaceId, + ...(idempotencyKey ? { idempotencyKey } : {}), + ingestedAt: new Date().toISOString(), + createdAt, + surfaceId: input.surfaceId, + source: input.source, + contract: normalizeContractIdentity(rootDir, input.contractPath), + artifacts: { + extractionPath: toRelative(rootDir, input.extractionPath), + reportPath: toRelative(rootDir, input.reportPath), + }, + status: input.status, + findingCodes: [...new Set(input.findingCodes)].sort((left, right) => left.localeCompare(right)), + summary: statusSummary(input.status, input.findingCodes), + }; + + acquireLock(lockPath); + try { + const runsDoc = readJson(runsPath, { + schemaVersion: 2, + runs: [], + }); + const currentRuns = Array.isArray(runsDoc.runs) ? runsDoc.runs : []; + + const duplicateByRunId = currentRuns.some( + (run) => (run.workspaceId || DEFAULT_WORKSPACE_ID) === workspaceId && run.runId === runRecord.runId, + ); + const duplicateByIdempotencyKey = + Boolean(idempotencyKey) && + currentRuns.some( + (run) => + (run.workspaceId || DEFAULT_WORKSPACE_ID) === workspaceId && + typeof run.idempotencyKey === "string" && + run.idempotencyKey === idempotencyKey, + ); + + const nextRuns = duplicateByRunId || duplicateByIdempotencyKey + ? currentRuns + : sortRunsDeterministic([...currentRuns, runRecord]); + + const effectiveRun = duplicateByRunId || duplicateByIdempotencyKey + ? nextRuns.find( + (run) => + run.runId === runRecord.runId || + (Boolean(idempotencyKey) && run.idempotencyKey === idempotencyKey), + ) ?? runRecord + : runRecord; + + fs.writeFileSync(runsPath, stringifyDeterministicJson({ + schemaVersion: 2, + runs: nextRuns, + }), "utf8"); + fs.writeFileSync(lineagePath, stringifyDeterministicJson(rebuildLineageFromRuns(nextRuns)), "utf8"); + + return { + deduped: Boolean(duplicateByRunId || duplicateByIdempotencyKey), + runId: effectiveRun.runId, + surfaceId: effectiveRun.surfaceId, + runsPath, + lineagePath, + }; + } finally { + releaseLock(lockPath); + } +} diff --git a/packages/interfacectl-cli/test/generation-session.test.mjs b/packages/interfacectl-cli/test/generation-session.test.mjs new file mode 100644 index 0000000..021ae3a --- /dev/null +++ b/packages/interfacectl-cli/test/generation-session.test.mjs @@ -0,0 +1,488 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { validateDiffOutput } from "@surfaces/interfacectl-validator"; +import generationAssessmentSchema from "../schemas/generation-assessment.schema.json" with { type: "json" }; +import generationSessionSchema from "../schemas/generation-session.schema.json" with { type: "json" }; +import generationSessionSummarySchema from "../schemas/generation-session-summary.schema.json" with { type: "json" }; +import contractRunsSchema from "../schemas/contract-runs.schema.json" with { type: "json" }; +import contractLineageSchema from "../schemas/contract-lineage.schema.json" with { type: "json" }; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.resolve(__dirname, "..", "dist", "index.js"); + +async function runCli(args, cwd = __dirname) { + const child = spawn("node", [cliPath, ...args], { + cwd, + env: process.env, + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const [exitCode] = await once(child, "exit"); + return { + exitCode: Number(exitCode), + stdout, + stderr, + }; +} + +async function writeJson(filePath, value) { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +async function writeText(filePath, value) { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, value, "utf8"); +} + +function validateWithSchema(payload, schema, label) { + const result = validateDiffOutput(payload, schema); + assert.equal(result.ok, true, `${label} should satisfy schema: ${JSON.stringify(result.errors)}`); +} + +function buildContract() { + return { + contractId: "generation-session-demo", + version: "1.0.0", + surfaces: [ + { + id: "demo-surface", + displayName: "Demo Surface", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["sans-serif"], + layout: { + maxContentWidth: 960, + requiredContainers: ["page-container"], + }, + }, + ], + sections: [ + { + id: "main.hero", + intent: "hero", + description: "Demo hero section.", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: ["#ffffff", "#111111"], + }, + }; +} + +async function writeDemoWorkspace(workspaceRoot, { valid }) { + await writeJson( + path.join(workspaceRoot, "contracts", "surfaces.web.contract.json"), + buildContract(), + ); + await writeJson( + path.join(workspaceRoot, "contracts", "generated", "demo-surface.contract.json"), + buildContract(), + ); + + await writeText( + path.join(workspaceRoot, "apps", "demo-surface", "app", "globals.css"), + `:root { + --contract-max-width: 960px; + --contract-motion-duration: 120ms; + --contract-motion-timing: linear; +} + +body { + font-family: sans-serif; + color: #111111; + background: #ffffff; +} + +[data-contract="page-container"] { + max-width: 960px; +} + +[data-contract-section="main.hero"] { + transition: opacity 120ms linear; +} +`, + ); + + const sectionAttribute = valid ? ' data-contract-section="main.hero"' : ""; + const containerAttribute = valid ? ' data-contract-container="page-container"' : ""; + await writeText( + path.join(workspaceRoot, "apps", "demo-surface", "app", "page.tsx"), + `export default function Page() { + return ( +
+ +

Demo Surface

+ +
+ ); +} +`, + ); +} + +test("generation session commands freeze bundle input, record attempts, and emit canonical run artifacts", async () => { + const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-session-")); + const workspaceRoot = path.join(tempRoot, "workspace"); + const bundleRoot = path.join(tempRoot, "bundle"); + const sessionDir = path.join(workspaceRoot, "artifacts", "generation-sessions", "demo-surface", "demo-session"); + + try { + await writeDemoWorkspace(workspaceRoot, { valid: false }); + + const compileResult = await runCli( + [ + "compile", + "--contract", + path.join(workspaceRoot, "contracts", "surfaces.web.contract.json"), + "--out", + bundleRoot, + ], + tempRoot, + ); + assert.equal(compileResult.exitCode, 0, compileResult.stderr); + + const initResult = await runCli( + [ + "init-generation-session", + "--bundle-root", + bundleRoot, + "--surface", + "demo-surface", + "--workspace-root", + workspaceRoot, + "--tool", + "codex", + "--session", + "demo-session", + ], + tempRoot, + ); + assert.equal(initResult.exitCode, 0, initResult.stderr); + + const session = JSON.parse(await fsp.readFile(path.join(sessionDir, "session.json"), "utf8")); + validateWithSchema(session, generationSessionSchema, "generation session"); + assert.ok(fs.existsSync(path.join(sessionDir, "bundle", "manifest.json"))); + assert.ok(fs.existsSync(path.join(sessionDir, "prepared-input.json"))); + + const assessmentOnePath = path.join(tempRoot, "assessment-1.json"); + await writeJson(assessmentOnePath, { + structure: "weak", + visual: "partial", + responsiveness: "weak", + notes: "Initial draft misses the contract markers.", + touchedFiles: ["apps/demo-surface/app/page.tsx"], + }); + + const attemptOneResult = await runCli( + [ + "record-generation-attempt", + "--session-dir", + sessionDir, + "--assessment-file", + assessmentOnePath, + ], + tempRoot, + ); + assert.equal(attemptOneResult.exitCode, 0, attemptOneResult.stderr); + + const attemptOneValidate = JSON.parse( + await fsp.readFile(path.join(sessionDir, "attempts", "001.validate.json"), "utf8"), + ); + const attemptOneAssessment = JSON.parse( + await fsp.readFile(path.join(sessionDir, "attempts", "001.assessment.json"), "utf8"), + ); + validateWithSchema(attemptOneAssessment, generationAssessmentSchema, "generation assessment"); + assert.equal(attemptOneValidate.status, "block"); + + const summaryWarn = await runCli( + ["summarize-generation-session", "--session-dir", sessionDir], + tempRoot, + ); + assert.equal(summaryWarn.exitCode, 30, summaryWarn.stderr); + + const runsAfterFirstAttempt = JSON.parse( + await fsp.readFile(path.join(workspaceRoot, "contracts", "generated", "contract-runs.json"), "utf8"), + ); + validateWithSchema(runsAfterFirstAttempt, contractRunsSchema, "contract runs"); + assert.equal(runsAfterFirstAttempt.runs.length, 1); + assert.equal(runsAfterFirstAttempt.runs[0].source, "generation"); + assert.equal(runsAfterFirstAttempt.runs[0].workspaceId, "demo-session"); + assert.match(runsAfterFirstAttempt.runs[0].ingestedAt, /T/); + + await writeDemoWorkspace(workspaceRoot, { valid: true }); + const assessmentTwoPath = path.join(tempRoot, "assessment-2.json"); + await writeJson(assessmentTwoPath, { + structure: "strong", + visual: "strong", + responsiveness: "strong", + notes: "Added the missing section and container markers.", + touchedFiles: ["apps/demo-surface/app/page.tsx", "apps/demo-surface/app/globals.css"], + }); + + const attemptTwoResult = await runCli( + [ + "record-generation-attempt", + "--session-dir", + sessionDir, + "--assessment-file", + assessmentTwoPath, + ], + tempRoot, + ); + assert.equal(attemptTwoResult.exitCode, 0, attemptTwoResult.stderr); + + const summaryPass = await runCli( + ["summarize-generation-session", "--session-dir", sessionDir], + tempRoot, + ); + assert.equal(summaryPass.exitCode, 0, summaryPass.stderr); + + const summary = JSON.parse( + await fsp.readFile(path.join(sessionDir, "summary.json"), "utf8"), + ); + validateWithSchema(summary, generationSessionSummarySchema, "generation session summary"); + assert.equal(summary.attemptCount, 2); + assert.equal(summary.firstPassAttempt, 2); + assert.equal(summary.latestStatus, "pass"); + assert.equal(summary.latestAssessment.notes, "Added the missing section and container markers."); + assert.deepEqual(summary.recurringFindingCodes, []); + assert.deepEqual(summary.recurringRepairCodes, []); + + const lineage = JSON.parse( + await fsp.readFile(path.join(workspaceRoot, "contracts", "generated", "contract-lineage.json"), "utf8"), + ); + validateWithSchema(lineage, contractLineageSchema, "contract lineage"); + assert.equal(lineage.surfaces["demo-surface"].lastStatus, "pass"); + assert.equal(lineage.surfaces["demo-surface"].lastSource, "generation"); + } finally { + await fsp.rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("generation session commands reject invalid bundle roots, duplicate sessions, invalid assessments, and missing sessions", async () => { + const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-session-errors-")); + const workspaceRoot = path.join(tempRoot, "workspace"); + const bundleRoot = path.join(tempRoot, "bundle"); + const sessionDir = path.join(workspaceRoot, "artifacts", "generation-sessions", "demo-surface", "demo-session"); + + try { + await writeDemoWorkspace(workspaceRoot, { valid: true }); + const compileResult = await runCli( + [ + "compile", + "--contract", + path.join(workspaceRoot, "contracts", "surfaces.web.contract.json"), + "--out", + bundleRoot, + ], + tempRoot, + ); + assert.equal(compileResult.exitCode, 0, compileResult.stderr); + + const missingBundle = await runCli( + [ + "init-generation-session", + "--bundle-root", + path.join(tempRoot, "missing-bundle"), + "--surface", + "demo-surface", + "--workspace-root", + workspaceRoot, + ], + tempRoot, + ); + assert.equal(missingBundle.exitCode, 10); + assert.match(missingBundle.stderr, /Bundle root directory not found/); + + const missingWorkspace = await runCli( + [ + "init-generation-session", + "--bundle-root", + bundleRoot, + "--surface", + "demo-surface", + "--workspace-root", + path.join(tempRoot, "missing-workspace"), + ], + tempRoot, + ); + assert.equal(missingWorkspace.exitCode, 10); + assert.match(missingWorkspace.stderr, /Workspace root directory not found/); + + const firstInit = await runCli( + [ + "init-generation-session", + "--bundle-root", + bundleRoot, + "--surface", + "demo-surface", + "--workspace-root", + workspaceRoot, + "--session", + "demo-session", + ], + tempRoot, + ); + assert.equal(firstInit.exitCode, 0, firstInit.stderr); + + const duplicateInit = await runCli( + [ + "init-generation-session", + "--bundle-root", + bundleRoot, + "--surface", + "demo-surface", + "--workspace-root", + workspaceRoot, + "--session", + "demo-session", + ], + tempRoot, + ); + assert.equal(duplicateInit.exitCode, 10); + assert.match(duplicateInit.stderr, /already exists/); + + const invalidAssessmentPath = path.join(tempRoot, "assessment-invalid.json"); + await writeJson(invalidAssessmentPath, { + structure: "bad", + visual: "partial", + responsiveness: "weak", + notes: "Invalid assessment payload.", + }); + + const invalidAssessment = await runCli( + [ + "record-generation-attempt", + "--session-dir", + sessionDir, + "--assessment-file", + invalidAssessmentPath, + ], + tempRoot, + ); + assert.equal(invalidAssessment.exitCode, 10); + assert.match(invalidAssessment.stderr, /strong\|partial\|weak/); + + const missingSession = await runCli( + [ + "summarize-generation-session", + "--session-dir", + path.join(tempRoot, "missing-session"), + ], + tempRoot, + ); + assert.equal(missingSession.exitCode, 10); + assert.match(missingSession.stderr, /Generation session not found/); + } finally { + await fsp.rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("generation session summary aggregates recurring finding and repair codes", async () => { + const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-session-summary-")); + const sessionDir = path.join(tempRoot, "artifacts", "generation-sessions", "demo-surface", "summary-session"); + const bundleSurfaceDir = path.join(sessionDir, "bundle", "surfaces", "demo-surface"); + + try { + await writeJson(path.join(sessionDir, "session.json"), { + schemaVersion: 1, + surfaceId: "demo-surface", + sessionId: "summary-session", + tool: "codex", + workspaceRoot: tempRoot, + sourceBundleRoot: path.join(tempRoot, "source-bundle"), + sessionDir, + bundleRoot: path.join(sessionDir, "bundle"), + preparedInputPath: path.join(sessionDir, "prepared-input.json"), + contractPath: path.join(sessionDir, "bundle", "contract", "normalized.json"), + repairMapPath: path.join(bundleSurfaceDir, "repair-map.json"), + startedAt: "2026-03-12T00:00:00.000Z", + successRule: { finalStatus: "pass" }, + }); + await writeJson(path.join(bundleSurfaceDir, "repair-map.json"), { + repairs: [ + { + code: "section.required.missing", + priority: "high", + category: "structure", + action: { + type: "add-section", + }, + }, + ], + }); + await writeJson(path.join(sessionDir, "attempts", "001.validate.json"), { + status: "warn", + findings: [{ code: "section.required.missing" }], + }); + await writeJson(path.join(sessionDir, "attempts", "001.assessment.json"), { + structure: "partial", + visual: "partial", + responsiveness: "partial", + notes: "Still missing the hero section.", + }); + await writeJson(path.join(sessionDir, "attempts", "001.metadata.json"), { + createdAt: "2026-03-12T00:00:00.000Z", + }); + await writeJson(path.join(sessionDir, "attempts", "002.validate.json"), { + status: "warn", + findings: [{ code: "section.required.missing" }], + }); + await writeJson(path.join(sessionDir, "attempts", "002.assessment.json"), { + structure: "partial", + visual: "strong", + responsiveness: "strong", + notes: "Section is still pending.", + }); + await writeJson(path.join(sessionDir, "attempts", "002.metadata.json"), { + createdAt: "2026-03-12T00:01:00.000Z", + }); + + const result = await runCli( + ["summarize-generation-session", "--session-dir", sessionDir], + tempRoot, + ); + assert.equal(result.exitCode, 30, result.stderr); + + const summary = JSON.parse( + await fsp.readFile(path.join(sessionDir, "summary.json"), "utf8"), + ); + validateWithSchema(summary, generationSessionSummarySchema, "generation session summary"); + assert.deepEqual(summary.recurringFindingCodes, [{ code: "section.required.missing", count: 2 }]); + assert.deepEqual(summary.recurringRepairCodes, [ + { + code: "section.required.missing", + count: 2, + priority: "high", + category: "structure", + actionType: "add-section", + }, + ]); + } finally { + await fsp.rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/interfacectl-cli/test/init-auth.test.mjs b/packages/interfacectl-cli/test/init-auth.test.mjs index 3732dde..72f83b1 100644 --- a/packages/interfacectl-cli/test/init-auth.test.mjs +++ b/packages/interfacectl-cli/test/init-auth.test.mjs @@ -8,6 +8,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; +import { validateDiffOutput } from "@surfaces/interfacectl-validator"; +import contractRunsSchema from "../schemas/contract-runs.schema.json" with { type: "json" }; +import contractLineageSchema from "../schemas/contract-lineage.schema.json" with { type: "json" }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -276,8 +279,14 @@ test("init: non-interactive remote-url writes first-run artifacts and run metada assert.equal(extraction.onboarding.authMode, "none"); assert.equal(analysis.sourceHealth.status, "ok"); assert.equal(extraction.sourceHealth.confidence, "full"); - assert.equal(runs.schemaVersion, 1); + const runsValidation = validateDiffOutput(runs, contractRunsSchema); + assert.equal(runsValidation.ok, true, JSON.stringify(runsValidation.errors)); + const lineageValidation = validateDiffOutput(lineage, contractLineageSchema); + assert.equal(lineageValidation.ok, true, JSON.stringify(lineageValidation.errors)); + assert.equal(runs.schemaVersion, 2); assert.equal(runs.runs[0].source, "generation"); + assert.equal(runs.runs[0].workspaceId, "ws-local-default"); + assert.match(runs.runs[0].ingestedAt, /T/); assert.equal(lineage.surfaces["customer-products"].lastSource, "generation"); } finally { server.closeAllConnections?.(); diff --git a/packages/interfacectl-cli/test/run-artifacts.test.mjs b/packages/interfacectl-cli/test/run-artifacts.test.mjs new file mode 100644 index 0000000..db08f55 --- /dev/null +++ b/packages/interfacectl-cli/test/run-artifacts.test.mjs @@ -0,0 +1,128 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import fsp from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { validateDiffOutput } from "@surfaces/interfacectl-validator"; +import contractRunsSchema from "../schemas/contract-runs.schema.json" with { type: "json" }; +import contractLineageSchema from "../schemas/contract-lineage.schema.json" with { type: "json" }; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.resolve(__dirname, "..", "dist", "index.js"); + +async function runCli(args, cwd = __dirname) { + const child = spawn("node", [cliPath, ...args], { + cwd, + env: process.env, + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const [exitCode] = await once(child, "exit"); + return { + exitCode: Number(exitCode), + stdout, + stderr, + }; +} + +function validateWithSchema(payload, schema, label) { + const result = validateDiffOutput(payload, schema); + assert.equal(result.ok, true, `${label} should satisfy schema: ${JSON.stringify(result.errors)}`); +} + +async function writeJson(filePath, value) { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +test("emit-run-artifact writes canonical v2 runs, lineage, and dedupes idempotency keys", async () => { + const workspaceRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-run-artifacts-")); + const contractPath = path.join(workspaceRoot, "contracts", "surfaces.web.contract.json"); + const reportPath = path.join(workspaceRoot, "artifacts", "report.json"); + + try { + await writeJson(contractPath, { + contractId: "surfaces.web", + version: "1.0.0", + surfaces: [{ id: "demo-surface", displayName: "Demo Surface", type: "web" }], + }); + await writeJson(reportPath, { findings: [] }); + + const first = await runCli( + [ + "emit-run-artifact", + "--workspace-root", + workspaceRoot, + "--surface", + "demo-surface", + "--source", + "ci", + "--status", + "warn", + "--report-path", + reportPath, + "--finding-codes", + "icon.source-disallowed", + "--workspace-id", + "ws-test", + "--idempotency-key", + "demo:1", + ], + workspaceRoot, + ); + assert.equal(first.exitCode, 0, first.stderr); + + const second = await runCli( + [ + "emit-run-artifact", + "--workspace-root", + workspaceRoot, + "--surface", + "demo-surface", + "--source", + "ci", + "--status", + "warn", + "--report-path", + reportPath, + "--finding-codes", + "icon.source-disallowed", + "--workspace-id", + "ws-test", + "--idempotency-key", + "demo:1", + ], + workspaceRoot, + ); + assert.equal(second.exitCode, 0, second.stderr); + assert.match(second.stdout, /"deduped": true/); + + const runs = JSON.parse( + await fsp.readFile(path.join(workspaceRoot, "contracts", "generated", "contract-runs.json"), "utf8"), + ); + const lineage = JSON.parse( + await fsp.readFile(path.join(workspaceRoot, "contracts", "generated", "contract-lineage.json"), "utf8"), + ); + + validateWithSchema(runs, contractRunsSchema, "contract runs"); + validateWithSchema(lineage, contractLineageSchema, "contract lineage"); + assert.equal(runs.runs.length, 1); + assert.equal(runs.runs[0].workspaceId, "ws-test"); + assert.equal(runs.runs[0].idempotencyKey, "demo:1"); + assert.equal(lineage.surfaces["demo-surface"].lastRunId, runs.runs[0].runId); + } finally { + await fsp.rm(workspaceRoot, { recursive: true, force: true }); + } +}); From 39b21f092304dabed5feb72ebae378a8d74f0fbf Mon Sep 17 00:00:00 2001 From: Mike Long Date: Thu, 12 Mar 2026 22:46:05 -0700 Subject: [PATCH 4/5] chore: remove repo-level vercel preview --- AGENTS.md | 1 - public/index.html | 194 ---------------------------------------------- vercel.json | 5 -- 3 files changed, 200 deletions(-) delete mode 100644 public/index.html delete mode 100644 vercel.json diff --git a/AGENTS.md b/AGENTS.md index 65cb65b..94209ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,5 @@ Before proposing code changes, ensure the plan explicitly states: Repo-specific durability rules: - this repo is the canonical source of truth for CLI behavior, validator logic, extractor behavior, schema, and release provenance -- the static preview files in `public/index.html` and `vercel.json` are repo-local preview infrastructure, not the primary product website - the public onboarding and richer product marketing site still live in `../surfaces-webapps/apps/interfacectl-web` - if a cross-repo task also requires consumer wiring, land and test the source change here first, then update `surfaces-webapps` diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 9bdbebd..0000000 --- a/public/index.html +++ /dev/null @@ -1,194 +0,0 @@ - - - - - - interfacectl - - - -
-
-
Shared Contract Tooling
-

interfacectl

-

- This Vercel deployment is a lightweight landing page for the shared - `interfacectl` monorepo. The repo itself ships CLI and validator - packages, so previews stay repo-scoped and defer product onboarding to - the primary interfacectl website. -

-
- - - -
- Preview deployments are intentionally static for this repository and are - not the primary product site. -
-
- - diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 954a80d..0000000 --- a/vercel.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "buildCommand": "pnpm run build", - "outputDirectory": "public" -} From c988b9efabe60ef9cdc19065162de35e09815483 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Thu, 12 Mar 2026 22:48:23 -0700 Subject: [PATCH 5/5] chore: refresh checks after vercel unlink