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/API.md b/API.md index 776b75f..3814bb8 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,262 @@ 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 | + +--- + +### `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 ] [--guidance-mode ] [--brief-file ] [--session ] [--artifacts-root ] +``` + +**Description:** +- Requires a compiled bundle and does not call `compile` implicitly. +- Defaults session artifacts to `/artifacts/generation-sessions///`. +- Writes `bundle/`, optional `prepared-input.json`, and `session.json`. +- `--guidance-mode prepared` is the guided contract-aware arm. `--guidance-mode unguided` intentionally withholds the prepared payload from the generator while still freezing the same bundle for later validation. +- `--brief-file` freezes the implementation brief into the session so later benchmark comparisons can prove both sessions used the same task. +- 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. +- Requires a rubric assessment with `structure`, `components`, `boundary`, `visual`, `responsiveness`, `notes`, and optional `touchedFiles`. +- 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 + +--- + +### `review-generation-attempt` + +Records an explicit human review for one `warn` attempt. + +**Synopsis:** +```bash +interfacectl review-generation-attempt --session-dir --attempt --review-file +``` + +**Description:** +- Loads the validate payload for the selected attempt and requires that the attempt status is `warn`. +- Requires a review file with `status`, `findingCodes`, and `rationale`. +- When `status=accepted`, the reviewed finding-code set must cover every remaining finding on the attempt. +- Writes `attempts/.review.json`. +- Canonical review schema lives at `packages/interfacectl-cli/schemas/generation-attempt-review.schema.json`. + +**Exit Codes:** +- `0`: Review recorded successfully +- `10`: Invalid input, missing session/attempt, invalid review coverage, 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, first acceptable attempt, latest status, latest outcome, recurring finding codes, recurring repair codes, latest assessment, and the latest review when present. +- Writes `summary.json` and `summary.md`. +- Canonical summary schema lives at `packages/interfacectl-cli/schemas/generation-session-summary.schema.json`. + +**Exit Codes:** +- `0`: Latest outcome is `pass` or `accepted-warn` +- `30`: Latest outcome is `warn` or `block` +- `10`: Missing session or attempts, or invalid artifacts +- `1`: Unexpected internal error + +--- + +### `compare-generation-sessions` + +Compares one unguided baseline session against one prepared guided session for the same implementation brief. + +**Synopsis:** +```bash +interfacectl compare-generation-sessions --baseline-session-dir --guided-session-dir [--out-dir ] +``` + +**Description:** +- Requires both sessions to target the same surface, use the same tool, and freeze the same brief file. +- Requires `guidanceMode=unguided` for the baseline session and `guidanceMode=prepared` for the guided session. +- Computes first-attempt finding deltas, attempts-to-acceptable-outcome delta, rubric deltas, and goal checks. +- Writes `comparison.json` and `comparison.md`. +- Canonical schema lives at `packages/interfacectl-cli/schemas/generation-session-comparison.schema.json`. + +**Exit Codes:** +- `0`: Comparison artifacts written successfully +- `10`: Invalid session pairing, mismatched brief, or unreadable artifacts +- `1`: Unexpected internal error + +--- + +### `suggest-contract-deltas` + +Generates evidence-backed contract-refinement suggestions from a guided session without mutating the contract. + +**Synopsis:** +```bash +interfacectl suggest-contract-deltas --session-dir [--out ] +``` + +**Description:** +- Requires a guided `prepared` session. +- Uses the session repair map, repeated finding patterns, and latest validate evidence to propose contract paths for review. +- Emits deterministic suggestion JSON and markdown. +- Suggestions start as `status=proposed`. +- Canonical schema lives at `packages/interfacectl-cli/schemas/contract-delta-suggestions.schema.json`. + +**Exit Codes:** +- `0`: Suggestion artifacts written successfully +- `10`: Invalid session state or unreadable artifacts +- `1`: Unexpected internal error + +--- + +### `review-contract-delta-suggestions` + +Applies human accept/reject decisions to suggestion artifacts without mutating the canonical contract. + +**Synopsis:** +```bash +interfacectl review-contract-delta-suggestions --suggestions --review-file [--out ] +``` + +**Description:** +- Loads an existing suggestion artifact. +- Applies review decisions keyed by `suggestionId`. +- Preserves undecided suggestions as `proposed`. +- Rewrites markdown alongside the updated JSON output. + +**Exit Codes:** +- `0`: Suggestion review recorded successfully +- `10`: Invalid decisions or unreadable files +- `1`: Unexpected internal error + +--- + +### `summarize-generation-benchmark` + +Aggregates one or more comparison artifacts and reviewed suggestion sets into one benchmark report. + +**Synopsis:** +```bash +interfacectl summarize-generation-benchmark --comparisons [--suggestions ] [--out-dir ] +``` + +**Description:** +- Summarizes whether guided sessions reduced first-attempt blocking findings, reached acceptable outcomes no later, and improved rubric dimensions. +- Aggregates accepted/rejected/proposed suggestion counts across surfaces. +- Writes `benchmark-report.json` and `benchmark-report.md`. +- Canonical schema lives at `packages/interfacectl-cli/schemas/generation-benchmark-report.schema.json`. + +**Exit Codes:** +- `0`: Benchmark report written successfully +- `10`: Missing comparison inputs or unreadable 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 fb90ae2..6874337 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,88 @@ 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. + +### `init-generation-session` + +Freezes one compiled bundle revision into a tracked local generation session under `artifacts/generation-sessions///` inside the workspace root. Use `--guidance-mode` to distinguish guided vs unguided sessions, and use `--brief-file` when later benchmark comparisons need to prove both sessions implemented the same task brief. + +```bash +interfacectl init-generation-session --bundle-root --surface --workspace-root [--tool ] [--guidance-mode ] [--brief-file ] [--session ] [--artifacts-root ] +``` + +### `record-generation-attempt` + +Validates one tracked session against the frozen bundle, records the validate payload plus the expanded rubric (`structure`, `components`, `boundary`, `visual`, `responsiveness`), and emits a canonical `contract-runs.json` / `contract-lineage.json` update into the workspace. + +```bash +interfacectl record-generation-attempt --session-dir --assessment-file +``` + +### `review-generation-attempt` + +Marks the remaining findings on one `warn` attempt as explicitly reviewed so a tracked benchmark session can end in `accepted-warn` without mutating the canonical contract. + +```bash +interfacectl review-generation-attempt --session-dir --attempt --review-file +``` + +### `summarize-generation-session` + +Aggregates recorded attempts for one tracked session, writes `summary.json` and `summary.md`, and exits non-zero unless the latest outcome is `pass` or `accepted-warn`. + +```bash +interfacectl summarize-generation-session --session-dir +``` + +### `compare-generation-sessions` + +Compares one unguided baseline session against one guided prepared session for the same brief and writes deterministic comparison artifacts. + +```bash +interfacectl compare-generation-sessions --baseline-session-dir --guided-session-dir [--out-dir ] +``` + +### `suggest-contract-deltas` + +Generates evidence-backed, non-authoritative contract refinement suggestions from a guided session. + +```bash +interfacectl suggest-contract-deltas --session-dir [--out ] +``` + +### `review-contract-delta-suggestions` + +Applies human accept/reject decisions to contract-delta suggestions without mutating the canonical contract. + +```bash +interfacectl review-contract-delta-suggestions --suggestions --review-file [--out ] +``` + +### `summarize-generation-benchmark` + +Aggregates one or more comparisons and reviewed suggestion sets into a benchmark report. + +```bash +interfacectl summarize-generation-benchmark --comparisons [--suggestions ] [--out-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. @@ -201,6 +283,12 @@ 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. + +- **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 ac677ae..457aba5 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`. 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: + +- `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..710a50c 100644 --- a/docs/ai-generator-adapter-quickstart.md +++ b/docs/ai-generator-adapter-quickstart.md @@ -1,68 +1,133 @@ # 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. Freeze one tracked session with `init-generation-session` when you want iteration evidence or a guided-vs-unguided benchmark. +4. Generate or edit UI. +5. Run `record-generation-attempt` for each attempt. +6. Optionally run `review-generation-attempt` when a `warn` result is explicitly acceptable. +7. Run `summarize-generation-session` to aggregate progress. +8. Use `compare-generation-sessions`, `suggest-contract-deltas`, and `summarize-generation-benchmark` when you are proving guided-vs-unguided outcomes. +9. Use `validate-generation` directly when you need an ad hoc post-generation check without a tracked session. -- `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, either directly or through `record-generation-attempt`. + +```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 +``` + +Exit semantics: + +- `0` for `pass` and `warn` +- `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 . \ + --guidance-mode prepared \ + --brief-file ./artifacts/generation-briefs/surfaces-web.md + +interfacectl record-generation-attempt \ + --session-dir ./artifacts/generation-sessions/surfaces-web/ \ + --assessment-file ./artifacts/generation-sessions/surfaces-web//assessment.json + +interfacectl review-generation-attempt \ + --session-dir ./artifacts/generation-sessions/surfaces-web/ \ + --attempt 1 \ + --review-file ./artifacts/generation-sessions/surfaces-web//review.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. + +For an A/B proof loop, run one session with `--guidance-mode unguided` and the same `--brief-file`, run another with `--guidance-mode prepared`, then compare them: -- 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. +```bash +interfacectl compare-generation-sessions \ + --baseline-session-dir ./artifacts/generation-sessions/surfaces-web/baseline-unguided \ + --guided-session-dir ./artifacts/generation-sessions/surfaces-web/guided-prepared -## Adapter outputs +interfacectl suggest-contract-deltas \ + --session-dir ./artifacts/generation-sessions/surfaces-web/guided-prepared +``` -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..1b2a5b9 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,16 @@ 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. 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 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. +When you want tracked iteration evidence around a local-agent loop, use the canonical session commands rather than a repo-local harness. ## Hosted builders @@ -34,13 +37,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,28 +51,30 @@ 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 - [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/docs/generator-consumption.md b/docs/generator-consumption.md index f9af6c5..6abb7ef 100644 --- a/docs/generator-consumption.md +++ b/docs/generator-consumption.md @@ -1,68 +1,46 @@ # 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. Optionally run `interfacectl init-generation-session --bundle-root --surface --workspace-root --guidance-mode prepared --brief-file ` when you want tracked iteration evidence or a benchmark-ready guided session. +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. If a `warn` result is acceptable, run `interfacectl review-generation-attempt` to record that decision explicitly. +8. 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 +49,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 +58,36 @@ 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. + +## Limits of the canonical payload + +The prepared payload is intentionally tool-neutral. + +- 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. + +## 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 review-generation-attempt` when a warning is explicitly acceptable +4. `interfacectl summarize-generation-session` -Embedding patterns vary by consumer, but the goal is consistent traceability from generated output back to the contract that shaped it. +For the guided-vs-unguided proof loop, compare two sessions that froze the same `--brief-file`: -## Limits of the current contract +1. `interfacectl compare-generation-sessions` +2. `interfacectl suggest-contract-deltas` +3. `interfacectl review-contract-delta-suggestions` +4. `interfacectl summarize-generation-benchmark` -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. +These commands write session and benchmark artifacts under `artifacts/generation-sessions/...` and `artifacts/generation-benchmarks/...`, and they emit canonical `contract-runs.json` / `contract-lineage.json` updates into the workspace. ## 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/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..a4c682d --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/generation-session.d.ts @@ -0,0 +1,57 @@ +export interface InitGenerationSessionCommandOptions { + bundleRoot?: string; + surfaceId?: string; + workspaceRoot?: string; + tool?: string; + sessionId?: string; + artifactsRoot?: string; + guidanceMode?: string; + briefFile?: string; +} +export interface RecordGenerationAttemptCommandOptions { + sessionDir?: string; + assessmentFile?: string; +} +export interface CaptureGenerationPreviewCommandOptions { + sessionDir?: string; + attemptNumber?: string | number; + url?: string; + waitFor?: string; +} +export interface ReviewGenerationAttemptCommandOptions { + sessionDir?: string; + attemptNumber?: string | number; + reviewFile?: string; +} +export interface SummarizeGenerationSessionCommandOptions { + sessionDir?: string; +} +export interface CompareGenerationSessionsCommandOptions { + baselineSessionDir?: string; + guidedSessionDir?: string; + outDir?: string; +} +export interface SuggestContractDeltasCommandOptions { + sessionDir?: string; + outPath?: string; +} +export interface ReviewContractDeltaSuggestionsCommandOptions { + suggestionsPath?: string; + reviewFile?: string; + outPath?: string; +} +export interface SummarizeGenerationBenchmarkCommandOptions { + comparisonPaths?: string; + suggestionPaths?: string; + outDir?: string; +} +export declare function runInitGenerationSessionCommand(options: InitGenerationSessionCommandOptions): Promise; +export declare function runRecordGenerationAttemptCommand(options: RecordGenerationAttemptCommandOptions): Promise; +export declare function runCaptureGenerationPreviewCommand(options: CaptureGenerationPreviewCommandOptions): Promise; +export declare function runReviewGenerationAttemptCommand(options: ReviewGenerationAttemptCommandOptions): Promise; +export declare function runSummarizeGenerationSessionCommand(options: SummarizeGenerationSessionCommandOptions): Promise; +export declare function runCompareGenerationSessionsCommand(options: CompareGenerationSessionsCommandOptions): Promise; +export declare function runSuggestContractDeltasCommand(options: SuggestContractDeltasCommandOptions): Promise; +export declare function runReviewContractDeltaSuggestionsCommand(options: ReviewContractDeltaSuggestionsCommandOptions): Promise; +export declare function runSummarizeGenerationBenchmarkCommand(options: SummarizeGenerationBenchmarkCommandOptions): 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..a469bd3 --- /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":"AA8BA,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;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,qCAAqC;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,sCAAsC;IACrD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,qCAAqC;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,wCAAwC;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,uCAAuC;IACtD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,mCAAmC;IAClD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,4CAA4C;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,0CAA0C;IACzD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA6/CD,wBAAsB,+BAA+B,CACnD,OAAO,EAAE,mCAAmC,GAC3C,OAAO,CAAC,MAAM,CAAC,CAyEjB;AAED,wBAAsB,iCAAiC,CACrD,OAAO,EAAE,qCAAqC,GAC7C,OAAO,CAAC,MAAM,CAAC,CA8FjB;AAED,wBAAsB,kCAAkC,CACtD,OAAO,EAAE,sCAAsC,GAC9C,OAAO,CAAC,MAAM,CAAC,CAmGjB;AAED,wBAAsB,iCAAiC,CACrD,OAAO,EAAE,qCAAqC,GAC7C,OAAO,CAAC,MAAM,CAAC,CAuEjB;AAED,wBAAsB,oCAAoC,CACxD,OAAO,EAAE,wCAAwC,GAChD,OAAO,CAAC,MAAM,CAAC,CAmBjB;AAED,wBAAsB,mCAAmC,CACvD,OAAO,EAAE,uCAAuC,GAC/C,OAAO,CAAC,MAAM,CAAC,CA4CjB;AAED,wBAAsB,+BAA+B,CACnD,OAAO,EAAE,mCAAmC,GAC3C,OAAO,CAAC,MAAM,CAAC,CAuCjB;AAED,wBAAsB,wCAAwC,CAC5D,OAAO,EAAE,4CAA4C,GACpD,OAAO,CAAC,MAAM,CAAC,CA8EjB;AAED,wBAAsB,sCAAsC,CAC1D,OAAO,EAAE,0CAA0C,GAClD,OAAO,CAAC,MAAM,CAAC,CA4FjB"} \ 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..b93b191 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/generation-session.js @@ -0,0 +1,1563 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { chromium } from "playwright"; +import { AdapterInputError, isRecord, 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"]); +const VALID_GUIDANCE_MODES = new Set(["prepared", "unguided"]); +const VALID_REVIEW_STATUSES = new Set(["accepted", "rejected"]); +const VALID_SUGGESTION_STATUSES = new Set(["proposed", "accepted", "rejected"]); +const VALID_SUCCESS_RULES = new Set(["pass", "pass-or-reviewed-warn"]); +const ASSESSMENT_DIMENSIONS = [ + "structure", + "components", + "boundary", + "visual", + "responsiveness", +]; +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 asRecord(value) { + return isRecord(value) ? value : {}; +} +function asString(value) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} +function asStringArray(value) { + if (!Array.isArray(value)) + return []; + return [...new Set(value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} +function sameStringSet(left, right) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} +function countBySeverity(validatePayload) { + const findings = Array.isArray(validatePayload.findings) ? validatePayload.findings : []; + let errors = 0; + let warnings = 0; + for (const finding of findings) { + if (!finding || typeof finding !== "object") + continue; + const severity = finding.severity; + if (severity === "error") { + errors += 1; + } + else if (severity === "warning") { + warnings += 1; + } + } + return { errors, warnings, total: errors + warnings }; +} +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 ensureGuidanceMode(guidanceMode) { + const normalized = typeof guidanceMode === "string" ? guidanceMode.trim().toLowerCase() : "prepared"; + if (!VALID_GUIDANCE_MODES.has(normalized)) { + throw new SessionInputError(`Invalid --guidance-mode value "${guidanceMode ?? ""}". Expected prepared|unguided.`); + } + 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"), + suggestionsJsonPath: path.join(sessionDir, "contract-delta-suggestions.json"), + suggestionsMarkdownPath: path.join(sessionDir, "contract-delta-suggestions.md"), + }; +} +function getAttemptPaths(attemptsDir, attemptNumber) { + const attemptId = formatAttemptNumber(attemptNumber); + return { + validatePath: path.join(attemptsDir, `${attemptId}.validate.json`), + assessmentPath: path.join(attemptsDir, `${attemptId}.assessment.json`), + metadataPath: path.join(attemptsDir, `${attemptId}.metadata.json`), + reviewPath: path.join(attemptsDir, `${attemptId}.review.json`), + previewMetadataPath: path.join(attemptsDir, `${attemptId}.preview.json`), + previewImagePath: path.join(attemptsDir, `${attemptId}.preview.png`), + }; +} +function normalizeAssessment(payload, filePath, options = {}) { + const structureFallback = payload.structure; + const grade = (key) => { + let value = payload[key]; + if (value === undefined && options.allowLegacyMissing && (key === "components" || key === "boundary")) { + value = structureFallback; + } + 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"), + components: grade("components"), + boundary: grade("boundary"), + 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 loadStoredAttemptReview(reviewPath) { + if (!fs.existsSync(reviewPath)) { + return null; + } + const payload = readJsonFile(reviewPath, "generation attempt review"); + const status = payload.status; + if (!VALID_REVIEW_STATUSES.has(status)) { + throw new SessionInputError(`Unsupported review status in ${reviewPath}.`); + } + const rationale = typeof payload.rationale === "string" ? payload.rationale.trim() : ""; + if (!rationale) { + throw new SessionInputError(`Review rationale must be a non-empty string: ${reviewPath}.`); + } + return { + schemaVersion: 1, + surfaceId: asString(payload.surfaceId) ?? "", + sessionId: asString(payload.sessionId) ?? "", + attemptNumber: Number(payload.attemptNumber), + status: status, + findingCodes: asStringArray(payload.findingCodes), + rationale, + reviewedAt: asString(payload.reviewedAt) ?? "", + }; +} +function loadStoredAttemptPreview(previewMetadataPath, previewImagePath) { + if (!fs.existsSync(previewMetadataPath)) { + return null; + } + if (!fs.existsSync(previewImagePath)) { + throw new SessionInputError(`Preview image not found at ${previewImagePath}.`); + } + const payload = readJsonFile(previewMetadataPath, "generation attempt preview"); + const pageTitle = asString(payload.pageTitle); + const waitFor = asString(payload.waitFor); + const viewport = asRecord(payload.viewport); + const width = Number(viewport.width); + const height = Number(viewport.height); + if (!Number.isFinite(width) || width < 1 || !Number.isFinite(height) || height < 1) { + throw new SessionInputError(`Preview viewport is invalid in ${previewMetadataPath}.`); + } + const preview = { + schemaVersion: 1, + surfaceId: asString(payload.surfaceId) ?? "", + sessionId: asString(payload.sessionId) ?? "", + attemptNumber: Number(payload.attemptNumber), + url: asString(payload.url) ?? "", + finalUrl: asString(payload.finalUrl) ?? "", + imagePath: asString(payload.imagePath) ?? previewImagePath, + capturedAt: asString(payload.capturedAt) ?? "", + viewport: { + width, + height, + }, + ...(pageTitle ? { pageTitle } : {}), + ...(waitFor ? { waitFor } : {}), + }; + if (preview.attemptNumber < 1 || + !preview.surfaceId || + !preview.sessionId || + !preview.url || + !preview.finalUrl || + !preview.imagePath || + !preview.capturedAt) { + throw new SessionInputError(`Generation attempt preview is missing required fields: ${previewMetadataPath}.`); + } + return preview; +} +function toPreviewReference(preview, previewMetadataPath) { + if (!preview || !previewMetadataPath) { + return undefined; + } + return { + imagePath: preview.imagePath, + metadataPath: previewMetadataPath, + url: preview.url, + finalUrl: preview.finalUrl, + capturedAt: preview.capturedAt, + ...(preview.waitFor ? { waitFor: preview.waitFor } : {}), + }; +} +function normalizeReviewInput(payload, filePath, findingCodes) { + const status = payload.status; + if (!VALID_REVIEW_STATUSES.has(status)) { + throw new SessionInputError(`Review field "status" must be accepted|rejected: ${filePath}.`); + } + const rationale = typeof payload.rationale === "string" ? payload.rationale.trim() : ""; + if (!rationale) { + throw new SessionInputError(`Review field "rationale" must be a non-empty string: ${filePath}.`); + } + const reviewedCodes = asStringArray(payload.findingCodes); + if (reviewedCodes.length === 0) { + throw new SessionInputError(`Review field "findingCodes" must list the reviewed findings: ${filePath}.`); + } + for (const code of reviewedCodes) { + if (!findingCodes.includes(code)) { + throw new SessionInputError(`Review field "findingCodes" includes unknown finding code "${code}": ${filePath}.`); + } + } + if (status === "accepted" && !sameStringSet(reviewedCodes, findingCodes)) { + throw new SessionInputError(`Accepted warn reviews must cover every remaining finding code: ${filePath}.`); + } + return { + status: status, + findingCodes: reviewedCodes, + rationale, + }; +} +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 payload = readJsonFile(paths.sessionPath, "generation session"); + const schemaVersion = Number(payload.schemaVersion ?? 1); + if (schemaVersion !== 1 && schemaVersion !== 2) { + throw new SessionInputError(`Unsupported generation session schemaVersion "${String(payload.schemaVersion ?? "unknown")}".`); + } + const tool = ensureSessionTool(asString(payload.tool)); + const guidanceMode = ensureGuidanceMode(asString(payload.guidanceMode) ?? "prepared"); + const finalStatus = asString(asRecord(payload.successRule).finalStatus) ?? "pass"; + if (!VALID_SUCCESS_RULES.has(finalStatus)) { + throw new SessionInputError(`Unsupported session successRule.finalStatus "${finalStatus}".`); + } + const briefRecord = asRecord(payload.brief); + const briefPath = asString(briefRecord.path); + const briefSha256 = asString(briefRecord.sha256); + const session = { + schemaVersion: 2, + surfaceId: asString(payload.surfaceId) ?? "", + sessionId: asString(payload.sessionId) ?? "", + tool, + guidanceMode, + workspaceRoot: asString(payload.workspaceRoot) ?? "", + sourceBundleRoot: asString(payload.sourceBundleRoot) ?? "", + sessionDir: asString(payload.sessionDir) ?? sessionDir, + bundleRoot: asString(payload.bundleRoot) ?? "", + preparedInputPath: typeof payload.preparedInputPath === "string" ? payload.preparedInputPath : null, + contractPath: asString(payload.contractPath) ?? "", + repairMapPath: asString(payload.repairMapPath) ?? "", + startedAt: asString(payload.startedAt) ?? "", + ...(briefPath && briefSha256 ? { brief: { path: briefPath, sha256: briefSha256 } } : {}), + successRule: { + finalStatus: finalStatus, + }, + }; + if (!session.surfaceId || !session.sessionId || !session.workspaceRoot || !session.bundleRoot || !session.contractPath || !session.repairMapPath || !session.startedAt) { + throw new SessionInputError(`Generation session is missing required fields: ${paths.sessionPath}.`); + } + return { + session, + paths, + }; +} +function toBrowserLaunchError(error) { + const message = error instanceof Error ? error.message : String(error); + if (/Executable doesn't exist|browserType\.launch/i.test(message)) { + return new Error(`Playwright Chromium is not installed. Run "pnpm exec playwright install chromium" in /Users/mike/SurfacesPlatform/interfacectl.`); + } + return error instanceof Error ? error : new Error(message); +} +async function waitForPageSettle(page) { + await page.waitForLoadState("domcontentloaded"); + await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => undefined); + await page.waitForTimeout(300); +} +async function waitForPreviewCondition(page, waitFor) { + const timeout = 5_000; + try { + await page.locator(waitFor).first().waitFor({ state: "visible", timeout }); + return; + } + catch (selectorError) { + try { + await page.getByText(waitFor, { exact: false }).first().waitFor({ state: "visible", timeout }); + return; + } + catch (textError) { + const selectorMessage = selectorError instanceof Error ? selectorError.message : String(selectorError); + const textMessage = textError instanceof Error ? textError.message : String(textError); + throw new SessionInputError(`Preview wait condition "${waitFor}" was not satisfied. Selector error: ${selectorMessage}. Text error: ${textMessage}.`); + } + } +} +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}`, + `Guidance mode: ${summary.guidanceMode}`, + `Latest status: ${summary.latestStatus}`, + `Latest outcome: ${summary.latestOutcome}`, + `Attempts: ${summary.attemptCount}`, + `First pass attempt: ${summary.firstPassAttempt ?? "not yet reached"}`, + `First acceptable attempt: ${summary.firstAcceptableAttempt ?? "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(`- components: ${summary.latestAssessment?.components ?? "n/a"}`); + lines.push(`- boundary: ${summary.latestAssessment?.boundary ?? "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(", ")}`); + } + if (summary.latestReview) { + lines.push(`- latest review: ${summary.latestReview.status} (${summary.latestReview.findingCodes.join(", ")})`); + lines.push(`- review rationale: ${summary.latestReview.rationale}`); + } + return `${lines.join("\n")}\n`; +} +function getSuccessOutcome(status, review, findingCodes, successRule) { + if (status === "pass") { + return "pass"; + } + if (status === "warn" && + successRule === "pass-or-reviewed-warn" && + review && + review.status === "accepted" && + sameStringSet(review.findingCodes, findingCodes)) { + return "accepted-warn"; + } + return status; +} +function loadAttemptRecords(paths) { + if (!fs.existsSync(paths.attemptsDir)) { + return []; + } + 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); + return attemptNumbers.map((attemptNumber) => { + const attemptPaths = getAttemptPaths(paths.attemptsDir, attemptNumber); + const review = loadStoredAttemptReview(attemptPaths.reviewPath); + const preview = loadStoredAttemptPreview(attemptPaths.previewMetadataPath, attemptPaths.previewImagePath); + return { + attemptNumber, + validate: readJsonFile(attemptPaths.validatePath, `attempt ${attemptNumber} validate payload`), + assessment: readJsonFile(attemptPaths.assessmentPath, `attempt ${attemptNumber} assessment`), + metadata: readJsonFile(attemptPaths.metadataPath, `attempt ${attemptNumber} metadata`), + review, + preview, + validatePath: attemptPaths.validatePath, + assessmentPath: attemptPaths.assessmentPath, + metadataPath: attemptPaths.metadataPath, + ...(review ? { reviewPath: attemptPaths.reviewPath } : {}), + ...(preview ? { previewMetadataPath: attemptPaths.previewMetadataPath } : {}), + }; + }); +} +function buildGenerationSessionSummary(sessionDirInput) { + const { session, paths } = loadSession(sessionDirInput); + const attempts = loadAttemptRecords(paths); + if (attempts.length === 0) { + throw new SessionInputError(`No attempts recorded for session ${session.sessionId}.`); + } + const firstPassAttempt = attempts.find((attempt) => attempt.validate.status === "pass")?.attemptNumber ?? null; + const firstAcceptableAttempt = attempts.find((attempt) => { + const status = attempt.validate.status; + if (status !== "pass" && status !== "warn" && status !== "block") { + throw new SessionInputError(`Unsupported validate status "${String(status)}" in ${attempt.validatePath}.`); + } + const outcome = getSuccessOutcome(status, attempt.review, parseFindingCodes(attempt.validate), session.successRule.finalStatus); + return outcome === "pass" || outcome === "accepted-warn"; + })?.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, { + allowLegacyMissing: true, + }); + const latestStatus = latestAttempt.validate.status; + if (latestStatus !== "pass" && latestStatus !== "warn" && latestStatus !== "block") { + throw new SessionInputError(`Unsupported validate status "${String(latestStatus)}" in ${latestAttempt.validatePath}.`); + } + const latestOutcome = getSuccessOutcome(latestStatus, latestAttempt.review, parseFindingCodes(latestAttempt.validate), session.successRule.finalStatus); + const summary = { + schemaVersion: 3, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + tool: session.tool, + guidanceMode: session.guidanceMode, + attemptCount: attempts.length, + firstPassAttempt, + firstAcceptableAttempt, + latestStatus, + latestOutcome, + recurringFindingCodes, + recurringRepairCodes, + latestAssessment, + latestReview: latestAttempt.review, + ...(session.brief ? { brief: session.brief } : {}), + successRule: session.successRule, + paths: { + sessionPath: paths.sessionPath, + bundleRoot: session.bundleRoot, + preparedInputPath: session.preparedInputPath, + }, + attempts: attempts.map((attempt) => { + const status = attempt.validate.status; + if (status !== "pass" && status !== "warn" && status !== "block") { + throw new SessionInputError(`Unsupported validate status "${String(status)}" in ${attempt.validatePath}.`); + } + const findingCodes = parseFindingCodes(attempt.validate); + return { + attemptNumber: attempt.attemptNumber, + status, + outcome: getSuccessOutcome(status, attempt.review, findingCodes, session.successRule.finalStatus), + findingCodes, + validatePath: attempt.validatePath, + assessmentPath: attempt.assessmentPath, + metadataPath: attempt.metadataPath, + ...(attempt.reviewPath ? { reviewPath: attempt.reviewPath } : {}), + ...(attempt.review ? { reviewStatus: attempt.review.status } : {}), + createdAt: typeof attempt.metadata.createdAt === "string" ? attempt.metadata.createdAt : undefined, + ...(toPreviewReference(attempt.preview, attempt.previewMetadataPath) + ? { preview: toPreviewReference(attempt.preview, attempt.previewMetadataPath) } + : {}), + }; + }), + }; + return { + session, + paths, + attempts, + summary, + }; +} +function toComparisonAttemptSnapshot(attempt, successRule) { + const status = attempt.validate.status; + if (status !== "pass" && status !== "warn" && status !== "block") { + throw new SessionInputError(`Unsupported validate status "${String(status)}" in ${attempt.validatePath}.`); + } + const findingCodes = parseFindingCodes(attempt.validate); + const counts = countBySeverity(attempt.validate); + return { + attemptNumber: attempt.attemptNumber, + status, + outcome: getSuccessOutcome(status, attempt.review, findingCodes, successRule), + findingCount: counts.total, + blockingFindingCount: counts.errors, + warningFindingCount: counts.warnings, + findingCodes, + assessment: normalizeAssessment(attempt.assessment, attempt.assessmentPath, { allowLegacyMissing: true }), + ...(toPreviewReference(attempt.preview, attempt.previewMetadataPath) + ? { preview: toPreviewReference(attempt.preview, attempt.previewMetadataPath) } + : {}), + }; +} +function gradeToScore(grade) { + switch (grade) { + case "weak": + return 0; + case "partial": + return 1; + case "strong": + return 2; + } +} +function getRepairPriorityRank(priority) { + switch (priority) { + case "high": + return 0; + case "medium": + return 1; + case "low": + return 2; + default: + return 3; + } +} +function renderComparisonMarkdown(comparison) { + const lines = [ + "# Generation Session Comparison", + "", + `Surface: ${comparison.surfaceId}`, + `Tool: ${comparison.tool}`, + `Baseline session: ${comparison.baseline.sessionId}`, + `Guided session: ${comparison.guided.sessionId}`, + `Meets goal: ${comparison.checks.meetsGoal ? "yes" : "no"}`, + "", + "## First attempt", + `- baseline outcome: ${comparison.baseline.firstAttempt.outcome}`, + `- guided outcome: ${comparison.guided.firstAttempt.outcome}`, + `- blocking finding delta: ${comparison.delta.firstAttemptBlockingFindingCountDelta}`, + `- warning finding delta: ${comparison.delta.firstAttemptWarningFindingCountDelta}`, + "", + "## Convergence", + `- baseline first acceptable attempt: ${comparison.baseline.firstAcceptableAttempt ?? "not reached"}`, + `- guided first acceptable attempt: ${comparison.guided.firstAcceptableAttempt ?? "not reached"}`, + `- attempts-to-acceptable delta: ${comparison.delta.attemptsToAcceptableOutcome.delta ?? "n/a"}`, + "", + "## Rubric delta", + ]; + for (const dimension of ASSESSMENT_DIMENSIONS) { + const rubric = comparison.delta.rubric[dimension]; + lines.push(`- ${dimension}: ${rubric.baseline} -> ${rubric.guided} (${rubric.delta})`); + } + if (comparison.checks.guidedRubricBetterDimensions.length > 0) { + lines.push("", `Guided improved dimensions: ${comparison.checks.guidedRubricBetterDimensions.join(", ")}`); + } + return `${lines.join("\n")}\n`; +} +function renderSuggestionsMarkdown(artifact) { + const lines = [ + "# Contract Delta Suggestions", + "", + `Surface: ${artifact.surfaceId}`, + `Session: ${artifact.sessionId}`, + `Tool: ${artifact.tool}`, + `Guidance mode: ${artifact.guidanceMode}`, + "", + ]; + if (artifact.suggestions.length === 0) { + lines.push("No suggestions."); + return `${lines.join("\n")}\n`; + } + for (const suggestion of artifact.suggestions) { + lines.push(`## ${suggestion.suggestionId}`); + lines.push(`- finding: ${suggestion.findingCode}`); + lines.push(`- status: ${suggestion.status}`); + lines.push(`- repeated failures: ${suggestion.repeatedFailureCount}`); + lines.push(`- confidence: ${suggestion.confidence}`); + lines.push(`- contract path: ${suggestion.proposedChange.path}`); + lines.push(`- action: ${suggestion.proposedChange.actionType}`); + lines.push(`- summary: ${suggestion.proposedChange.summary}`); + if (suggestion.decision) { + lines.push(`- rationale: ${suggestion.decision.rationale}`); + } + lines.push(""); + } + return `${lines.join("\n")}\n`; +} +function renderBenchmarkReportMarkdown(report) { + const lines = [ + "# Generation Benchmark Report", + "", + `Generated at: ${report.generatedAt}`, + `Surfaces: ${report.overall.surfaceCount}`, + `Surfaces meeting goal: ${report.overall.surfacesMeetingGoal}`, + `Guided fewer first-attempt blocking findings: ${report.overall.guidedFewerFirstAttemptBlockingFindings}`, + `Guided reached acceptable no later: ${report.overall.guidedReachedAcceptableNoLater}`, + "", + "## Comparisons", + ]; + for (const comparison of report.comparisons) { + lines.push(`- ${comparison.surfaceId}: meetsGoal=${comparison.meetsGoal}, improved dimensions=${comparison.guidedRubricBetterDimensions.join(", ") || "none"}`); + } + lines.push("", "## Suggestion decisions"); + for (const suggestion of report.suggestions) { + lines.push(`- ${suggestion.surfaceId}: proposed=${suggestion.proposedCount}, accepted=${suggestion.acceptedCount}, rejected=${suggestion.rejectedCount}`); + } + return `${lines.join("\n")}\n`; +} +function freezeBriefFile(sessionDir, briefFile) { + const sourcePath = path.resolve(briefFile); + if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) { + throw new SessionInputError(`Brief file not found at ${sourcePath}.`); + } + const extension = path.extname(sourcePath) || ".txt"; + const destinationPath = path.join(sessionDir, `task-brief${extension}`); + const content = fs.readFileSync(sourcePath); + fs.writeFileSync(destinationPath, content); + return { + path: destinationPath, + sha256: crypto.createHash("sha256").update(content).digest("hex"), + }; +} +function defaultComparisonDir(baseline, guided) { + return path.join(baseline.workspaceRoot, "artifacts", "generation-benchmarks", baseline.surfaceId, `${baseline.sessionId}--vs--${guided.sessionId}`); +} +function defaultBenchmarkReportDir(comparisonPaths) { + const firstPath = comparisonPaths[0]; + return path.join(path.dirname(path.dirname(firstPath)), "report"); +} +function parseCsvPaths(value) { + if (!value) + return []; + return [...new Set(value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => path.resolve(entry)))]; +} +function buildComparisonArtifact(baselineSessionDir, guidedSessionDir) { + const baselineBuilt = buildGenerationSessionSummary(baselineSessionDir); + const guidedBuilt = buildGenerationSessionSummary(guidedSessionDir); + if (baselineBuilt.session.surfaceId !== guidedBuilt.session.surfaceId) { + throw new SessionInputError("Baseline and guided sessions must target the same surface."); + } + if (baselineBuilt.session.tool !== guidedBuilt.session.tool) { + throw new SessionInputError("Baseline and guided sessions must use the same tool."); + } + if (baselineBuilt.session.guidanceMode !== "unguided") { + throw new SessionInputError("Baseline session must use guidanceMode=unguided."); + } + if (guidedBuilt.session.guidanceMode !== "prepared") { + throw new SessionInputError("Guided session must use guidanceMode=prepared."); + } + if (!baselineBuilt.session.brief || !guidedBuilt.session.brief) { + throw new SessionInputError("Both sessions must freeze the same implementation brief before comparison."); + } + if (baselineBuilt.session.brief.sha256 !== guidedBuilt.session.brief.sha256) { + throw new SessionInputError("Baseline and guided sessions must use the same implementation brief."); + } + const baselineFirstAttempt = toComparisonAttemptSnapshot(baselineBuilt.attempts[0], baselineBuilt.session.successRule.finalStatus); + const guidedFirstAttempt = toComparisonAttemptSnapshot(guidedBuilt.attempts[0], guidedBuilt.session.successRule.finalStatus); + const baselineLatestAttempt = toComparisonAttemptSnapshot(baselineBuilt.attempts[baselineBuilt.attempts.length - 1], baselineBuilt.session.successRule.finalStatus); + const guidedLatestAttempt = toComparisonAttemptSnapshot(guidedBuilt.attempts[guidedBuilt.attempts.length - 1], guidedBuilt.session.successRule.finalStatus); + const rubric = Object.fromEntries(ASSESSMENT_DIMENSIONS.map((dimension) => { + const baseline = baselineLatestAttempt.assessment[dimension]; + const guided = guidedLatestAttempt.assessment[dimension]; + return [ + dimension, + { + baseline, + guided, + delta: gradeToScore(guided) - gradeToScore(baseline), + }, + ]; + })); + const guidedRubricBetterDimensions = ASSESSMENT_DIMENSIONS.filter((dimension) => rubric[dimension].delta > 0); + const guidedRubricAtLeastAsGood = ASSESSMENT_DIMENSIONS.every((dimension) => rubric[dimension].delta >= 0); + const guidedReachedAcceptableNoLater = baselineBuilt.summary.firstAcceptableAttempt === null + ? guidedBuilt.summary.firstAcceptableAttempt !== null + : guidedBuilt.summary.firstAcceptableAttempt !== null && + guidedBuilt.summary.firstAcceptableAttempt <= baselineBuilt.summary.firstAcceptableAttempt; + const guidedFewerFirstAttemptBlockingFindings = guidedFirstAttempt.blockingFindingCount < baselineFirstAttempt.blockingFindingCount; + return { + schemaVersion: 2, + surfaceId: baselineBuilt.session.surfaceId, + tool: baselineBuilt.session.tool, + brief: { + sha256: baselineBuilt.session.brief.sha256, + baselinePath: baselineBuilt.session.brief.path, + guidedPath: guidedBuilt.session.brief.path, + }, + baseline: { + sessionId: baselineBuilt.session.sessionId, + sessionDir: baselineBuilt.session.sessionDir, + guidanceMode: baselineBuilt.session.guidanceMode, + attemptCount: baselineBuilt.summary.attemptCount, + firstAcceptableAttempt: baselineBuilt.summary.firstAcceptableAttempt, + latestOutcome: baselineBuilt.summary.latestOutcome, + firstAttempt: baselineFirstAttempt, + latestAttempt: baselineLatestAttempt, + recurringFindingCodes: baselineBuilt.summary.recurringFindingCodes, + recurringRepairCodes: baselineBuilt.summary.recurringRepairCodes, + }, + guided: { + sessionId: guidedBuilt.session.sessionId, + sessionDir: guidedBuilt.session.sessionDir, + guidanceMode: guidedBuilt.session.guidanceMode, + attemptCount: guidedBuilt.summary.attemptCount, + firstAcceptableAttempt: guidedBuilt.summary.firstAcceptableAttempt, + latestOutcome: guidedBuilt.summary.latestOutcome, + firstAttempt: guidedFirstAttempt, + latestAttempt: guidedLatestAttempt, + recurringFindingCodes: guidedBuilt.summary.recurringFindingCodes, + recurringRepairCodes: guidedBuilt.summary.recurringRepairCodes, + }, + delta: { + firstAttemptVerdict: { + baseline: baselineFirstAttempt.outcome, + guided: guidedFirstAttempt.outcome, + }, + firstAttemptFindingCountDelta: guidedFirstAttempt.findingCount - baselineFirstAttempt.findingCount, + firstAttemptBlockingFindingCountDelta: guidedFirstAttempt.blockingFindingCount - baselineFirstAttempt.blockingFindingCount, + firstAttemptWarningFindingCountDelta: guidedFirstAttempt.warningFindingCount - baselineFirstAttempt.warningFindingCount, + latestFindingCountDelta: guidedLatestAttempt.findingCount - baselineLatestAttempt.findingCount, + attemptsToAcceptableOutcome: { + baseline: baselineBuilt.summary.firstAcceptableAttempt, + guided: guidedBuilt.summary.firstAcceptableAttempt, + delta: baselineBuilt.summary.firstAcceptableAttempt !== null && + guidedBuilt.summary.firstAcceptableAttempt !== null + ? guidedBuilt.summary.firstAcceptableAttempt - baselineBuilt.summary.firstAcceptableAttempt + : null, + }, + rubric, + }, + checks: { + guidedFewerFirstAttemptBlockingFindings, + guidedReachedAcceptableNoLater, + guidedRubricAtLeastAsGood, + guidedRubricBetterDimensions, + meetsGoal: guidedFewerFirstAttemptBlockingFindings && + guidedReachedAcceptableNoLater && + guidedRubricAtLeastAsGood && + guidedRubricBetterDimensions.length >= 2, + }, + paths: { + baselineSessionDir: baselineBuilt.session.sessionDir, + guidedSessionDir: guidedBuilt.session.sessionDir, + }, + }; +} +function inferContractPath(surfaceId, findingCode, repair) { + const actionType = asString(asRecord(repair?.action).type) ?? ""; + switch (actionType) { + case "ensure-required-sections": + case "restrict-section-set": + case "restore-top-level-sections": + case "restore-section-order": + return `surfaces[id=${surfaceId}].requiredSections`; + case "restrict-fonts": + case "restore-marketing-typography-profile": + return `surfaces[id=${surfaceId}].allowedFonts`; + case "restrict-colors": + return "color.allowedValues"; + case "restrict-icon-sources": + return "icons.allowedSources"; + case "restrict-motion-durations": + return "constraints.motion.allowedDurationsMs"; + case "restrict-motion-timing": + return "constraints.motion.allowedTimingFunctions"; + case "reduce-content-width": + case "ensure-required-containers": + case "align-page-frame": + case "restore-page-background-mode": + case "restore-landing-pattern-signals": + return `surfaces[id=${surfaceId}].layout`; + case "restore-required-flows": + case "restore-required-flow-steps": + case "restore-required-transitions": + return `surfaces[id=${surfaceId}].flows`; + case "remove-prohibited-primitives": + return `surfaces[id=${surfaceId}].shell`; + default: + if (findingCode.startsWith("section.")) + return `surfaces[id=${surfaceId}].requiredSections`; + if (findingCode.startsWith("layout.") || findingCode.startsWith("landing.")) + return `surfaces[id=${surfaceId}].layout`; + if (findingCode.startsWith("font.")) + return `surfaces[id=${surfaceId}].allowedFonts`; + if (findingCode.startsWith("color.")) + return "color.allowedValues"; + if (findingCode.startsWith("icon.")) + return "icons.allowedSources"; + if (findingCode.startsWith("motion.")) + return "constraints.motion"; + if (findingCode.startsWith("flow.")) + return `surfaces[id=${surfaceId}].flows`; + return `surfaces[id=${surfaceId}]`; + } +} +function getSuggestionConfidence(repeatedFailureCount, repairPriority, evidenceRefs, contractPath) { + let confidence = 0.45; + if (repeatedFailureCount > 1) + confidence += 0.2; + if (repairPriority === "high") + confidence += 0.15; + if (repairPriority === "medium") + confidence += 0.05; + if (evidenceRefs.length > 0) + confidence += 0.1; + if (contractPath === "surfaces[id=unknown]") + confidence -= 0.15; + return Math.max(0.1, Math.min(0.95, Math.round(confidence * 100) / 100)); +} +function getSuggestionSortKey(left, right) { + return (right.repeatedFailureCount - left.repeatedFailureCount || + getRepairPriorityRank(left.repair.priority) - getRepairPriorityRank(right.repair.priority) || + left.findingCode.localeCompare(right.findingCode)); +} +function buildSuggestionArtifact(sessionDir) { + const built = buildGenerationSessionSummary(sessionDir); + if (built.session.guidanceMode !== "prepared") { + throw new SessionInputError("Contract delta suggestions require a guided prepared session."); + } + const repairMapDoc = readJsonFile(built.session.repairMapPath, "repair map"); + const repairs = Array.isArray(repairMapDoc.repairs) ? repairMapDoc.repairs : []; + const repairMapByCode = new Map(repairs + .filter((entry) => isRecord(entry)) + .map((entry) => [asString(entry.code) ?? "", entry]) + .filter(([code]) => Boolean(code))); + const recurringCounts = new Map(built.summary.recurringFindingCodes.map((entry) => [entry.code, entry.count])); + const latestAttempt = built.attempts[built.attempts.length - 1]; + const latestFindings = Array.isArray(latestAttempt.validate.findings) ? latestAttempt.validate.findings : []; + const latestFindingByCode = new Map(); + for (const finding of latestFindings) { + if (!finding || typeof finding !== "object") + continue; + const entry = finding; + const code = asString(entry.code); + if (!code) + continue; + latestFindingByCode.set(code, entry); + } + const allCodes = [...new Set(built.attempts.flatMap((attempt) => parseFindingCodes(attempt.validate)))].sort((left, right) => left.localeCompare(right)); + const suggestions = allCodes.map((code) => { + const repair = repairMapByCode.get(code); + const latestFinding = latestFindingByCode.get(code); + const action = asRecord(repair?.action); + const repairPriority = asString(repair?.priority) ?? "medium"; + const repairCategory = asString(repair?.category) ?? "unknown"; + const actionType = asString(action.type) ?? "review-contract"; + const evidence = latestFinding?.evidence; + const evidenceRefs = isRecord(evidence) && Object.keys(evidence).length > 0 ? [evidence] : []; + const contractPath = inferContractPath(built.session.surfaceId, code, repair); + const valueHints = [ + ...(Array.isArray(action.sectionIds) ? action.sectionIds.filter((entry) => typeof entry === "string") : []), + ...(Array.isArray(action.sectionOrder) ? action.sectionOrder.filter((entry) => typeof entry === "string") : []), + ...(Array.isArray(action.allowedValues) ? action.allowedValues.filter((entry) => typeof entry === "string") : []), + ...(Array.isArray(action.allowedSources) ? action.allowedSources.filter((entry) => typeof entry === "string") : []), + ...(Array.isArray(action.requiredContainers) ? action.requiredContainers.filter((entry) => typeof entry === "string") : []), + ].slice(0, 6); + const repeatedFailureCount = recurringCounts.get(code) ?? 1; + return { + suggestionId: `suggestion:${code}`, + findingCode: code, + findingMessage: asString(latestFinding?.message) ?? `Review contract coverage for ${code}.`, + repeatedFailureCount, + confidence: getSuggestionConfidence(repeatedFailureCount, repairPriority, evidenceRefs, contractPath), + status: "proposed", + repair: { + priority: repairPriority, + category: repairCategory, + actionType, + }, + evidenceRefs, + proposedChange: { + path: contractPath, + actionType, + summary: asString(latestFinding?.message) ?? + `Review whether ${contractPath} needs refinement to better constrain ${code}.`, + valueHints, + }, + }; + }).sort(getSuggestionSortKey); + return { + schemaVersion: 1, + surfaceId: built.session.surfaceId, + sessionId: built.session.sessionId, + tool: built.session.tool, + guidanceMode: built.session.guidanceMode, + generatedAt: asString(latestAttempt.metadata.createdAt) ?? + asString(latestAttempt.validate.provenance && asRecord(latestAttempt.validate.provenance).evaluatedAt) ?? + built.session.startedAt, + contract: { + path: built.session.contractPath, + }, + session: { + sessionPath: built.paths.sessionPath, + summaryPath: built.paths.summaryJsonPath, + repairMapPath: built.session.repairMapPath, + }, + suggestions, + }; +} +function normalizeSuggestionReviewFile(filePath) { + const payload = readJsonFile(filePath, "contract delta suggestion review"); + const decisions = Array.isArray(payload.decisions) ? payload.decisions : []; + if (decisions.length === 0) { + throw new SessionInputError(`Review file must include a non-empty "decisions" array: ${filePath}.`); + } + return decisions.map((entry, index) => { + if (!entry || typeof entry !== "object") { + throw new SessionInputError(`Review decision at index ${index} is invalid: ${filePath}.`); + } + const decision = entry; + const suggestionId = asString(decision.suggestionId); + const status = asString(decision.status); + const rationale = typeof decision.rationale === "string" ? decision.rationale.trim() : ""; + if (!suggestionId) { + throw new SessionInputError(`Review decision at index ${index} is missing suggestionId: ${filePath}.`); + } + if (status !== "accepted" && status !== "rejected") { + throw new SessionInputError(`Review decision for ${suggestionId} must use accepted|rejected: ${filePath}.`); + } + if (!rationale) { + throw new SessionInputError(`Review decision for ${suggestionId} must include rationale: ${filePath}.`); + } + return { + suggestionId, + status, + rationale, + }; + }); +} +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 guidanceMode = ensureGuidanceMode(options.guidanceMode); + 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()); + let preparedInputPath = null; + if (guidanceMode === "prepared") { + const preparedPayload = buildPreparedGenerationPayload(sessionBundle); + writeDeterministicJsonSync(paths.preparedInputPath, preparedPayload); + preparedInputPath = paths.preparedInputPath; + } + const session = { + schemaVersion: 2, + surfaceId: options.surfaceId, + sessionId, + tool, + guidanceMode, + workspaceRoot, + sourceBundleRoot: loadedBundle.root, + sessionDir: paths.sessionDir, + bundleRoot: paths.bundleRoot, + preparedInputPath, + contractPath: sessionBundle.contract.path, + repairMapPath: sessionBundle.surface.repairMap.path, + startedAt: new Date().toISOString(), + ...(options.briefFile ? { brief: freezeBriefFile(paths.sessionDir, options.briefFile) } : {}), + successRule: { + finalStatus: "pass-or-reviewed-warn", + }, + }; + 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 attemptPaths = getAttemptPaths(paths.attemptsDir, attemptNumber); + fs.mkdirSync(paths.attemptsDir, { recursive: true }); + 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(attemptPaths.validatePath, response); + writeDeterministicJsonSync(attemptPaths.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: attemptPaths.validatePath, + findingCodes, + workspaceId: session.sessionId, + idempotencyKey: `${session.surfaceId}:${session.sessionId}:${formatAttemptNumber(attemptNumber)}`, + }); + const metadata = { + schemaVersion: 2, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + attemptNumber, + tool: session.tool, + guidanceMode: session.guidanceMode, + createdAt: new Date().toISOString(), + validateStatus: response.status, + validateExitCode: response.status === "block" ? 30 : 0, + findingCodes, + assessmentPath: attemptPaths.assessmentPath, + validatePath: attemptPaths.validatePath, + touchedFiles: assessment.touchedFiles ?? [], + contractRun, + }; + writeDeterministicJsonSync(attemptPaths.metadataPath, metadata); + process.stdout.write(`${JSON.stringify({ + ok: true, + session, + attempt: { + attemptNumber, + validatePath: attemptPaths.validatePath, + assessmentPath: attemptPaths.assessmentPath, + metadataPath: attemptPaths.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 runCaptureGenerationPreviewCommand(options) { + try { + if (!options.sessionDir) { + throw new SessionInputError("--session-dir is required."); + } + if (!options.url) { + throw new SessionInputError("--url is required."); + } + const attemptNumber = typeof options.attemptNumber === "number" + ? options.attemptNumber + : Number.parseInt(String(options.attemptNumber ?? ""), 10); + if (!Number.isInteger(attemptNumber) || attemptNumber < 1) { + throw new SessionInputError("--attempt must be a positive integer."); + } + const { session, paths } = loadSession(options.sessionDir); + const attemptPaths = getAttemptPaths(paths.attemptsDir, attemptNumber); + if (!fs.existsSync(attemptPaths.metadataPath) || !fs.existsSync(attemptPaths.validatePath)) { + throw new SessionInputError(`Attempt ${attemptNumber} not found in ${paths.attemptsDir}.`); + } + let parsedUrl; + try { + parsedUrl = new URL(options.url); + } + catch { + throw new SessionInputError(`Preview URL must be an absolute URL: ${options.url}.`); + } + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new SessionInputError(`Preview URL must use http or https: ${options.url}.`); + } + const viewport = { width: 1440, height: 1024 }; + const browser = await chromium.launch({ + headless: process.env.INTERFACECTL_PLAYWRIGHT_HEADLESS !== "0" && + process.env.INTERFACECTL_PLAYWRIGHT_HEADLESS !== "false", + }).catch((error) => { + throw toBrowserLaunchError(error); + }); + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + try { + await page.goto(parsedUrl.toString(), { waitUntil: "load", timeout: 15_000 }); + await waitForPageSettle(page); + const waitFor = asString(options.waitFor); + if (waitFor) { + await waitForPreviewCondition(page, waitFor); + await waitForPageSettle(page); + } + await page.screenshot({ path: attemptPaths.previewImagePath, fullPage: true, type: "png" }); + const preview = { + schemaVersion: 1, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + attemptNumber, + url: parsedUrl.toString(), + finalUrl: page.url(), + imagePath: attemptPaths.previewImagePath, + capturedAt: new Date().toISOString(), + viewport, + ...(asString(await page.title()) ? { pageTitle: asString(await page.title()) } : {}), + ...(waitFor ? { waitFor } : {}), + }; + writeDeterministicJsonSync(attemptPaths.previewMetadataPath, preview); + process.stdout.write(`${JSON.stringify({ + ok: true, + preview, + paths: { + metadataPath: attemptPaths.previewMetadataPath, + imagePath: attemptPaths.previewImagePath, + }, + }, null, 2)}\n`); + return 0; + } + catch (error) { + if (error instanceof SessionInputError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new SessionInputError(`Failed to capture preview for attempt ${attemptNumber}: ${message}.`); + } + finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } + } + 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 runReviewGenerationAttemptCommand(options) { + try { + if (!options.sessionDir) { + throw new SessionInputError("--session-dir is required."); + } + if (!options.reviewFile) { + throw new SessionInputError("--review-file is required."); + } + const attemptNumber = typeof options.attemptNumber === "number" + ? options.attemptNumber + : Number.parseInt(String(options.attemptNumber ?? ""), 10); + if (!Number.isInteger(attemptNumber) || attemptNumber < 1) { + throw new SessionInputError("--attempt must be a positive integer."); + } + const { session, paths } = loadSession(options.sessionDir); + const attemptPaths = getAttemptPaths(paths.attemptsDir, attemptNumber); + if (!fs.existsSync(attemptPaths.validatePath)) { + throw new SessionInputError(`Attempt ${attemptNumber} validate payload not found at ${attemptPaths.validatePath}.`); + } + const validatePayload = readJsonFile(attemptPaths.validatePath, `attempt ${attemptNumber} validate payload`); + if (validatePayload.status !== "warn") { + throw new SessionInputError(`Attempt ${attemptNumber} is ${String(validatePayload.status)}; only warn attempts can be reviewed.`); + } + const findingCodes = parseFindingCodes(validatePayload); + if (findingCodes.length === 0) { + throw new SessionInputError(`Attempt ${attemptNumber} does not have remaining finding codes to review.`); + } + const reviewInputPath = path.resolve(options.reviewFile); + if (!fs.existsSync(reviewInputPath)) { + throw new SessionInputError(`Review file not found at ${reviewInputPath}.`); + } + const reviewInput = normalizeReviewInput(readJsonFile(reviewInputPath, "generation attempt review input"), reviewInputPath, findingCodes); + const review = { + schemaVersion: 1, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + attemptNumber, + status: reviewInput.status, + findingCodes: reviewInput.findingCodes, + rationale: reviewInput.rationale, + reviewedAt: new Date().toISOString(), + }; + writeDeterministicJsonSync(attemptPaths.reviewPath, review); + process.stdout.write(`${JSON.stringify({ + ok: true, + review, + reviewPath: attemptPaths.reviewPath, + }, 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 { paths, summary } = buildGenerationSessionSummary(options.sessionDir); + writeDeterministicJsonSync(paths.summaryJsonPath, summary); + fs.writeFileSync(paths.summaryMarkdownPath, renderSummaryMarkdown(summary), "utf8"); + process.stdout.write(`${JSON.stringify({ ok: true, summary, paths }, null, 2)}\n`); + return summary.latestOutcome === "pass" || summary.latestOutcome === "accepted-warn" ? 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; + } +} +export async function runCompareGenerationSessionsCommand(options) { + try { + if (!options.baselineSessionDir) { + throw new SessionInputError("--baseline-session-dir is required."); + } + if (!options.guidedSessionDir) { + throw new SessionInputError("--guided-session-dir is required."); + } + const comparison = buildComparisonArtifact(options.baselineSessionDir, options.guidedSessionDir); + const baseline = loadSession(options.baselineSessionDir).session; + const guided = loadSession(options.guidedSessionDir).session; + const outDir = options.outDir + ? path.resolve(options.outDir) + : defaultComparisonDir(baseline, guided); + const jsonPath = path.join(outDir, "comparison.json"); + const markdownPath = path.join(outDir, "comparison.md"); + writeDeterministicJsonSync(jsonPath, comparison); + fs.mkdirSync(path.dirname(markdownPath), { recursive: true }); + fs.writeFileSync(markdownPath, renderComparisonMarkdown(comparison), "utf8"); + process.stdout.write(`${JSON.stringify({ + ok: true, + comparison, + paths: { + jsonPath, + markdownPath, + }, + }, 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 runSuggestContractDeltasCommand(options) { + try { + if (!options.sessionDir) { + throw new SessionInputError("--session-dir is required."); + } + const artifact = buildSuggestionArtifact(options.sessionDir); + const sessionPaths = getSessionPaths(path.resolve(options.sessionDir)); + const outPath = options.outPath + ? path.resolve(options.outPath) + : sessionPaths.suggestionsJsonPath; + const markdownPath = outPath.endsWith(".json") + ? `${outPath.slice(0, -5)}.md` + : `${outPath}.md`; + writeDeterministicJsonSync(outPath, artifact); + fs.mkdirSync(path.dirname(markdownPath), { recursive: true }); + fs.writeFileSync(markdownPath, renderSuggestionsMarkdown(artifact), "utf8"); + process.stdout.write(`${JSON.stringify({ + ok: true, + artifact, + paths: { + jsonPath: outPath, + markdownPath, + }, + }, 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 runReviewContractDeltaSuggestionsCommand(options) { + try { + if (!options.suggestionsPath) { + throw new SessionInputError("--suggestions is required."); + } + if (!options.reviewFile) { + throw new SessionInputError("--review-file is required."); + } + const suggestionsPath = path.resolve(options.suggestionsPath); + if (!fs.existsSync(suggestionsPath)) { + throw new SessionInputError(`Suggestions file not found at ${suggestionsPath}.`); + } + const artifact = readJsonFile(suggestionsPath, "contract delta suggestions artifact"); + const reviewFile = path.resolve(options.reviewFile); + if (!fs.existsSync(reviewFile)) { + throw new SessionInputError(`Review file not found at ${reviewFile}.`); + } + const decisions = normalizeSuggestionReviewFile(reviewFile); + const decisionMap = new Map(decisions.map((entry) => [entry.suggestionId, entry])); + const knownIds = new Set(artifact.suggestions.map((suggestion) => suggestion.suggestionId)); + for (const suggestionId of decisionMap.keys()) { + if (!knownIds.has(suggestionId)) { + throw new SessionInputError(`Unknown suggestionId "${suggestionId}" in ${reviewFile}.`); + } + } + const updatedArtifact = { + ...artifact, + suggestions: artifact.suggestions.map((suggestion) => { + const decision = decisionMap.get(suggestion.suggestionId); + if (!decision) { + return suggestion; + } + return { + ...suggestion, + status: decision.status, + decision: { + rationale: decision.rationale, + decidedAt: new Date().toISOString(), + }, + }; + }), + }; + const outPath = options.outPath ? path.resolve(options.outPath) : suggestionsPath; + const markdownPath = outPath.endsWith(".json") + ? `${outPath.slice(0, -5)}.md` + : `${outPath}.md`; + writeDeterministicJsonSync(outPath, updatedArtifact); + fs.mkdirSync(path.dirname(markdownPath), { recursive: true }); + fs.writeFileSync(markdownPath, renderSuggestionsMarkdown(updatedArtifact), "utf8"); + process.stdout.write(`${JSON.stringify({ + ok: true, + artifact: updatedArtifact, + paths: { + jsonPath: outPath, + markdownPath, + }, + }, 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 runSummarizeGenerationBenchmarkCommand(options) { + try { + const comparisonPaths = parseCsvPaths(options.comparisonPaths); + if (comparisonPaths.length === 0) { + throw new SessionInputError("--comparisons must include at least one comparison artifact path."); + } + const suggestionPaths = parseCsvPaths(options.suggestionPaths); + const comparisons = comparisonPaths.map((comparisonPath) => ({ + path: comparisonPath, + value: readJsonFile(comparisonPath, "generation session comparison"), + })); + const suggestions = suggestionPaths.map((suggestionPath) => ({ + path: suggestionPath, + value: readJsonFile(suggestionPath, "contract delta suggestions artifact"), + })); + const report = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + comparisons: comparisons.map(({ path: comparisonPath, value }) => ({ + surfaceId: value.surfaceId, + tool: value.tool, + comparisonPath, + meetsGoal: value.checks.meetsGoal, + guidedFewerFirstAttemptBlockingFindings: value.checks.guidedFewerFirstAttemptBlockingFindings, + guidedReachedAcceptableNoLater: value.checks.guidedReachedAcceptableNoLater, + guidedRubricBetterDimensions: value.checks.guidedRubricBetterDimensions, + })), + suggestions: suggestions.map(({ path: suggestionsPath, value }) => ({ + surfaceId: value.surfaceId, + sessionId: value.sessionId, + suggestionsPath, + proposedCount: value.suggestions.filter((entry) => entry.status === "proposed").length, + acceptedCount: value.suggestions.filter((entry) => entry.status === "accepted").length, + rejectedCount: value.suggestions.filter((entry) => entry.status === "rejected").length, + })), + overall: { + surfaceCount: comparisons.length, + surfacesMeetingGoal: comparisons.filter(({ value }) => value.checks.meetsGoal).length, + guidedFewerFirstAttemptBlockingFindings: comparisons.filter(({ value }) => value.checks.guidedFewerFirstAttemptBlockingFindings).length, + guidedReachedAcceptableNoLater: comparisons.filter(({ value }) => value.checks.guidedReachedAcceptableNoLater).length, + acceptedSuggestionCount: suggestions.reduce((total, entry) => total + entry.value.suggestions.filter((suggestion) => suggestion.status === "accepted").length, 0), + rejectedSuggestionCount: suggestions.reduce((total, entry) => total + entry.value.suggestions.filter((suggestion) => suggestion.status === "rejected").length, 0), + proposedSuggestionCount: suggestions.reduce((total, entry) => total + entry.value.suggestions.filter((suggestion) => suggestion.status === "proposed").length, 0), + }, + }; + const outDir = options.outDir + ? path.resolve(options.outDir) + : defaultBenchmarkReportDir(comparisonPaths); + const jsonPath = path.join(outDir, "benchmark-report.json"); + const markdownPath = path.join(outDir, "benchmark-report.md"); + writeDeterministicJsonSync(jsonPath, report); + fs.mkdirSync(path.dirname(markdownPath), { recursive: true }); + fs.writeFileSync(markdownPath, renderBenchmarkReportMarkdown(report), "utf8"); + process.stdout.write(`${JSON.stringify({ + ok: true, + report, + paths: { + jsonPath, + markdownPath, + }, + }, 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; + } +} 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..570949e --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts @@ -0,0 +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 new file mode 100644 index 0000000..80806aa --- /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":"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 new file mode 100644 index 0000000..749d3b3 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.js @@ -0,0 +1,210 @@ +import fs from "node:fs"; +import path from "node:path"; +import { AdapterInputError, isRecord, loadCompiledSurfaceBundle, } from "../adapter/bundle.js"; +import { stringifyDeterministicJson } from "../utils/deterministic-json.js"; +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), + }; +} +export 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 : [], + }; +} +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", + 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 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 }); + 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..a4d003d 100644 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -9,6 +9,11 @@ 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 { runEmitRunArtifactCommand } from "./commands/emit-run-artifact.js"; +import { runCaptureGenerationPreviewCommand, runCompareGenerationSessionsCommand, runInitGenerationSessionCommand, runRecordGenerationAttemptCommand, runReviewContractDeltaSuggestionsCommand, runReviewGenerationAttemptCommand, runSuggestContractDeltasCommand, runSummarizeGenerationSessionCommand, runSummarizeGenerationBenchmarkCommand, } 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"; @@ -189,6 +194,215 @@ 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("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("--guidance-mode ", "Session guidance mode (prepared|unguided)") + .option("--brief-file ", "Optional implementation brief file to freeze into the session") + .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, + guidanceMode: options.guidanceMode, + briefFile: options.briefFile, + 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("capture-generation-preview") + .description("Capture a visual preview for one recorded generation attempt") + .requiredOption("--session-dir ", "Path to the generation session directory") + .requiredOption("--attempt ", "Attempt number to capture") + .requiredOption("--url ", "Absolute preview URL to capture") + .option("--wait-for ", "Optional text or selector to wait for before capturing") + .action(async (options) => { + process.exitCode = await runCaptureGenerationPreviewCommand({ + sessionDir: options.sessionDir, + attemptNumber: options.attempt, + url: options.url, + waitFor: options.waitFor, + }); +}); +program + .command("review-generation-attempt") + .description("Review the remaining findings for one warn attempt in a tracked generation session") + .requiredOption("--session-dir ", "Path to the generation session directory") + .requiredOption("--attempt ", "Attempt number to review") + .requiredOption("--review-file ", "Path to the review JSON file") + .action(async (options) => { + process.exitCode = await runReviewGenerationAttemptCommand({ + sessionDir: options.sessionDir, + attemptNumber: options.attempt, + reviewFile: options.reviewFile, + }); +}); +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("compare-generation-sessions") + .description("Compare one unguided session against one prepared guided session") + .requiredOption("--baseline-session-dir ", "Path to the unguided baseline session directory") + .requiredOption("--guided-session-dir ", "Path to the prepared guided session directory") + .option("--out-dir ", "Output directory for comparison artifacts") + .action(async (options) => { + process.exitCode = await runCompareGenerationSessionsCommand({ + baselineSessionDir: options.baselineSessionDir, + guidedSessionDir: options.guidedSessionDir, + outDir: options.outDir, + }); +}); +program + .command("suggest-contract-deltas") + .description("Generate evidence-backed contract refinement suggestions from one guided session") + .requiredOption("--session-dir ", "Path to the guided generation session directory") + .option("--out ", "Write suggestion JSON to the provided file") + .action(async (options) => { + process.exitCode = await runSuggestContractDeltasCommand({ + sessionDir: options.sessionDir, + outPath: options.out, + }); +}); +program + .command("review-contract-delta-suggestions") + .description("Apply human accept/reject decisions to contract delta suggestions without mutating the contract") + .requiredOption("--suggestions ", "Path to the contract delta suggestions JSON file") + .requiredOption("--review-file ", "Path to the review decisions JSON file") + .option("--out ", "Write the updated suggestions JSON to the provided file") + .action(async (options) => { + process.exitCode = await runReviewContractDeltaSuggestionsCommand({ + suggestionsPath: options.suggestions, + reviewFile: options.reviewFile, + outPath: options.out, + }); +}); +program + .command("summarize-generation-benchmark") + .description("Aggregate one or more comparison and suggestion artifacts into a benchmark report") + .requiredOption("--comparisons ", "Comma-separated generation session comparison JSON paths") + .option("--suggestions ", "Comma-separated contract delta suggestion JSON paths") + .option("--out-dir ", "Output directory for the benchmark report") + .action(async (options) => { + process.exitCode = await runSummarizeGenerationBenchmarkCommand({ + comparisonPaths: options.comparisons, + suggestionPaths: options.suggestions, + outDir: options.outDir, + }); +}); +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("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-delta-suggestions.schema.json b/packages/interfacectl-cli/schemas/contract-delta-suggestions.schema.json new file mode 100644 index 0000000..23fcb51 --- /dev/null +++ b/packages/interfacectl-cli/schemas/contract-delta-suggestions.schema.json @@ -0,0 +1,162 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/contract-delta-suggestions.schema.json", + "title": "ContractDeltaSuggestionsArtifact", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "surfaceId", "sessionId", "tool", "guidanceMode", "generatedAt", "contract", "session", "suggestions"], + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "tool": { + "type": "string", + "enum": ["codex", "cursor"] + }, + "guidanceMode": { + "type": "string", + "enum": ["prepared", "unguided"] + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "contract": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + } + } + }, + "session": { + "type": "object", + "additionalProperties": false, + "required": ["sessionPath", "summaryPath", "repairMapPath"], + "properties": { + "sessionPath": { + "type": "string", + "minLength": 1 + }, + "summaryPath": { + "type": "string", + "minLength": 1 + }, + "repairMapPath": { + "type": "string", + "minLength": 1 + } + } + }, + "suggestions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["suggestionId", "findingCode", "findingMessage", "repeatedFailureCount", "confidence", "status", "repair", "evidenceRefs", "proposedChange"], + "properties": { + "suggestionId": { + "type": "string", + "minLength": 1 + }, + "findingCode": { + "type": "string", + "minLength": 1 + }, + "findingMessage": { + "type": "string", + "minLength": 1 + }, + "repeatedFailureCount": { + "type": "number", + "minimum": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "status": { + "type": "string", + "enum": ["proposed", "accepted", "rejected"] + }, + "repair": { + "type": "object", + "additionalProperties": false, + "required": ["priority", "category", "actionType"], + "properties": { + "priority": { + "type": "string", + "minLength": 1 + }, + "category": { + "type": "string", + "minLength": 1 + }, + "actionType": { + "type": "string", + "minLength": 1 + } + } + }, + "evidenceRefs": { + "type": "array" + }, + "proposedChange": { + "type": "object", + "additionalProperties": false, + "required": ["path", "actionType", "summary", "valueHints"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "actionType": { + "type": "string", + "minLength": 1 + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "valueHints": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "decision": { + "type": "object", + "additionalProperties": false, + "required": ["rationale", "decidedAt"], + "properties": { + "rationale": { + "type": "string", + "minLength": 1 + }, + "decidedAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } +} 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-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/schemas/generation-assessment.schema.json b/packages/interfacectl-cli/schemas/generation-assessment.schema.json new file mode 100644 index 0000000..508d1e0 --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-assessment.schema.json @@ -0,0 +1,41 @@ +{ + "$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", "components", "boundary", "visual", "responsiveness", "notes"], + "properties": { + "structure": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "components": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "boundary": { + "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-attempt-preview.schema.json b/packages/interfacectl-cli/schemas/generation-attempt-preview.schema.json new file mode 100644 index 0000000..aa640a9 --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-attempt-preview.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/generation-attempt-preview.schema.json", + "title": "GenerationAttemptPreview", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "surfaceId", + "sessionId", + "attemptNumber", + "url", + "finalUrl", + "imagePath", + "capturedAt", + "viewport" + ], + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "attemptNumber": { + "type": "number", + "minimum": 1 + }, + "url": { + "type": "string", + "minLength": 1 + }, + "finalUrl": { + "type": "string", + "minLength": 1 + }, + "imagePath": { + "type": "string", + "minLength": 1 + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "viewport": { + "type": "object", + "additionalProperties": false, + "required": ["width", "height"], + "properties": { + "width": { + "type": "number", + "minimum": 1 + }, + "height": { + "type": "number", + "minimum": 1 + } + } + }, + "pageTitle": { + "type": "string", + "minLength": 1 + }, + "waitFor": { + "type": "string", + "minLength": 1 + } + } +} diff --git a/packages/interfacectl-cli/schemas/generation-attempt-review.schema.json b/packages/interfacectl-cli/schemas/generation-attempt-review.schema.json new file mode 100644 index 0000000..6cc6b54 --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-attempt-review.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/generation-attempt-review.schema.json", + "title": "GenerationAttemptReview", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "surfaceId", "sessionId", "attemptNumber", "status", "findingCodes", "rationale", "reviewedAt"], + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "attemptNumber": { + "type": "number", + "minimum": 1 + }, + "status": { + "type": "string", + "enum": ["accepted", "rejected"] + }, + "findingCodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "rationale": { + "type": "string", + "minLength": 1 + }, + "reviewedAt": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/packages/interfacectl-cli/schemas/generation-benchmark-report.schema.json b/packages/interfacectl-cli/schemas/generation-benchmark-report.schema.json new file mode 100644 index 0000000..8fd8eb3 --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-benchmark-report.schema.json @@ -0,0 +1,125 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/generation-benchmark-report.schema.json", + "title": "GenerationBenchmarkReport", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "generatedAt", "comparisons", "suggestions", "overall"], + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "comparisons": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["surfaceId", "tool", "comparisonPath", "meetsGoal", "guidedFewerFirstAttemptBlockingFindings", "guidedReachedAcceptableNoLater", "guidedRubricBetterDimensions"], + "properties": { + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "tool": { + "type": "string", + "enum": ["codex", "cursor"] + }, + "comparisonPath": { + "type": "string", + "minLength": 1 + }, + "meetsGoal": { + "type": "boolean" + }, + "guidedFewerFirstAttemptBlockingFindings": { + "type": "boolean" + }, + "guidedReachedAcceptableNoLater": { + "type": "boolean" + }, + "guidedRubricBetterDimensions": { + "type": "array", + "items": { + "type": "string", + "enum": ["structure", "components", "boundary", "visual", "responsiveness"] + } + } + } + } + }, + "suggestions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["surfaceId", "sessionId", "suggestionsPath", "proposedCount", "acceptedCount", "rejectedCount"], + "properties": { + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "suggestionsPath": { + "type": "string", + "minLength": 1 + }, + "proposedCount": { + "type": "number", + "minimum": 0 + }, + "acceptedCount": { + "type": "number", + "minimum": 0 + }, + "rejectedCount": { + "type": "number", + "minimum": 0 + } + } + } + }, + "overall": { + "type": "object", + "additionalProperties": false, + "required": ["surfaceCount", "surfacesMeetingGoal", "guidedFewerFirstAttemptBlockingFindings", "guidedReachedAcceptableNoLater", "acceptedSuggestionCount", "rejectedSuggestionCount", "proposedSuggestionCount"], + "properties": { + "surfaceCount": { + "type": "number", + "minimum": 0 + }, + "surfacesMeetingGoal": { + "type": "number", + "minimum": 0 + }, + "guidedFewerFirstAttemptBlockingFindings": { + "type": "number", + "minimum": 0 + }, + "guidedReachedAcceptableNoLater": { + "type": "number", + "minimum": 0 + }, + "acceptedSuggestionCount": { + "type": "number", + "minimum": 0 + }, + "rejectedSuggestionCount": { + "type": "number", + "minimum": 0 + }, + "proposedSuggestionCount": { + "type": "number", + "minimum": 0 + } + } + } + } +} diff --git a/packages/interfacectl-cli/schemas/generation-session-comparison.schema.json b/packages/interfacectl-cli/schemas/generation-session-comparison.schema.json new file mode 100644 index 0000000..dab752f --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-session-comparison.schema.json @@ -0,0 +1,377 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://surfaces.dev/schemas/generation-session-comparison.schema.json", + "title": "GenerationSessionComparison", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "surfaceId", "tool", "brief", "baseline", "guided", "delta", "checks", "paths"], + "properties": { + "schemaVersion": { + "type": "number", + "const": 2 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "tool": { + "type": "string", + "enum": ["codex", "cursor"] + }, + "brief": { + "type": "object", + "additionalProperties": false, + "required": ["sha256", "baselinePath", "guidedPath"], + "properties": { + "sha256": { + "type": "string", + "minLength": 1 + }, + "baselinePath": { + "type": "string", + "minLength": 1 + }, + "guidedPath": { + "type": "string", + "minLength": 1 + } + } + }, + "baseline": { + "$ref": "#/$defs/sessionSnapshot" + }, + "guided": { + "$ref": "#/$defs/sessionSnapshot" + }, + "delta": { + "type": "object", + "additionalProperties": false, + "required": ["firstAttemptVerdict", "firstAttemptFindingCountDelta", "firstAttemptBlockingFindingCountDelta", "firstAttemptWarningFindingCountDelta", "latestFindingCountDelta", "attemptsToAcceptableOutcome", "rubric"], + "properties": { + "firstAttemptVerdict": { + "type": "object", + "additionalProperties": false, + "required": ["baseline", "guided"], + "properties": { + "baseline": { + "type": "string", + "enum": ["pass", "accepted-warn", "warn", "block"] + }, + "guided": { + "type": "string", + "enum": ["pass", "accepted-warn", "warn", "block"] + } + } + }, + "firstAttemptFindingCountDelta": { + "type": "number" + }, + "firstAttemptBlockingFindingCountDelta": { + "type": "number" + }, + "firstAttemptWarningFindingCountDelta": { + "type": "number" + }, + "latestFindingCountDelta": { + "type": "number" + }, + "attemptsToAcceptableOutcome": { + "type": "object", + "additionalProperties": false, + "required": ["baseline", "guided", "delta"], + "properties": { + "baseline": { + "type": ["number", "null"] + }, + "guided": { + "type": ["number", "null"] + }, + "delta": { + "type": ["number", "null"] + } + } + }, + "rubric": { + "type": "object", + "additionalProperties": false, + "required": ["structure", "components", "boundary", "visual", "responsiveness"], + "properties": { + "structure": { + "$ref": "#/$defs/rubricDelta" + }, + "components": { + "$ref": "#/$defs/rubricDelta" + }, + "boundary": { + "$ref": "#/$defs/rubricDelta" + }, + "visual": { + "$ref": "#/$defs/rubricDelta" + }, + "responsiveness": { + "$ref": "#/$defs/rubricDelta" + } + } + } + } + }, + "checks": { + "type": "object", + "additionalProperties": false, + "required": ["guidedFewerFirstAttemptBlockingFindings", "guidedReachedAcceptableNoLater", "guidedRubricAtLeastAsGood", "guidedRubricBetterDimensions", "meetsGoal"], + "properties": { + "guidedFewerFirstAttemptBlockingFindings": { + "type": "boolean" + }, + "guidedReachedAcceptableNoLater": { + "type": "boolean" + }, + "guidedRubricAtLeastAsGood": { + "type": "boolean" + }, + "guidedRubricBetterDimensions": { + "type": "array", + "items": { + "type": "string", + "enum": ["structure", "components", "boundary", "visual", "responsiveness"] + } + }, + "meetsGoal": { + "type": "boolean" + } + } + }, + "paths": { + "type": "object", + "additionalProperties": false, + "required": ["baselineSessionDir", "guidedSessionDir"], + "properties": { + "baselineSessionDir": { + "type": "string", + "minLength": 1 + }, + "guidedSessionDir": { + "type": "string", + "minLength": 1 + } + } + } + }, + "$defs": { + "assessment": { + "type": "object", + "additionalProperties": false, + "required": ["structure", "components", "boundary", "visual", "responsiveness", "notes"], + "properties": { + "structure": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "components": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "boundary": { + "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 + } + } + } + }, + "attemptSnapshot": { + "type": "object", + "additionalProperties": false, + "required": ["attemptNumber", "status", "outcome", "findingCount", "blockingFindingCount", "warningFindingCount", "findingCodes", "assessment"], + "properties": { + "attemptNumber": { + "type": "number", + "minimum": 1 + }, + "status": { + "type": "string", + "enum": ["pass", "warn", "block"] + }, + "outcome": { + "type": "string", + "enum": ["pass", "accepted-warn", "warn", "block"] + }, + "findingCount": { + "type": "number", + "minimum": 0 + }, + "blockingFindingCount": { + "type": "number", + "minimum": 0 + }, + "warningFindingCount": { + "type": "number", + "minimum": 0 + }, + "findingCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "assessment": { + "$ref": "#/$defs/assessment" + }, + "preview": { + "$ref": "#/$defs/previewRef" + } + } + }, + "sessionSnapshot": { + "type": "object", + "additionalProperties": false, + "required": ["sessionId", "sessionDir", "guidanceMode", "attemptCount", "firstAcceptableAttempt", "latestOutcome", "firstAttempt", "latestAttempt", "recurringFindingCodes", "recurringRepairCodes"], + "properties": { + "sessionId": { + "type": "string", + "minLength": 1 + }, + "sessionDir": { + "type": "string", + "minLength": 1 + }, + "guidanceMode": { + "type": "string", + "enum": ["prepared", "unguided"] + }, + "attemptCount": { + "type": "number", + "minimum": 1 + }, + "firstAcceptableAttempt": { + "type": ["number", "null"] + }, + "latestOutcome": { + "type": "string", + "enum": ["pass", "accepted-warn", "warn", "block"] + }, + "firstAttempt": { + "$ref": "#/$defs/attemptSnapshot" + }, + "latestAttempt": { + "$ref": "#/$defs/attemptSnapshot" + }, + "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 + } + } + } + } + } + }, + "rubricDelta": { + "type": "object", + "additionalProperties": false, + "required": ["baseline", "guided", "delta"], + "properties": { + "baseline": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "guided": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "delta": { + "type": "number" + } + } + }, + "previewRef": { + "type": "object", + "additionalProperties": false, + "required": ["imagePath", "metadataPath", "url", "finalUrl", "capturedAt"], + "properties": { + "imagePath": { + "type": "string", + "minLength": 1 + }, + "metadataPath": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "minLength": 1 + }, + "finalUrl": { + "type": "string", + "minLength": 1 + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "waitFor": { + "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..91946ca --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-session-summary.schema.json @@ -0,0 +1,351 @@ +{ + "$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", + "guidanceMode", + "attemptCount", + "firstPassAttempt", + "firstAcceptableAttempt", + "latestStatus", + "latestOutcome", + "recurringFindingCodes", + "recurringRepairCodes", + "latestAssessment", + "latestReview", + "successRule", + "paths", + "attempts" + ], + "properties": { + "schemaVersion": { + "type": "number", + "const": 3 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "tool": { + "type": "string", + "enum": ["codex", "cursor"] + }, + "guidanceMode": { + "type": "string", + "enum": ["prepared", "unguided"] + }, + "attemptCount": { + "type": "number", + "minimum": 1 + }, + "firstPassAttempt": { + "type": ["number", "null"] + }, + "firstAcceptableAttempt": { + "type": ["number", "null"] + }, + "latestStatus": { + "type": "string", + "enum": ["pass", "warn", "block"] + }, + "latestOutcome": { + "type": "string", + "enum": ["pass", "accepted-warn", "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", "components", "boundary", "visual", "responsiveness", "notes"], + "properties": { + "structure": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "components": { + "type": "string", + "enum": ["strong", "partial", "weak"] + }, + "boundary": { + "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 + } + } + } + } + ] + }, + "latestReview": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "surfaceId", "sessionId", "attemptNumber", "status", "findingCodes", "rationale", "reviewedAt"], + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "attemptNumber": { + "type": "number", + "minimum": 1 + }, + "status": { + "type": "string", + "enum": ["accepted", "rejected"] + }, + "findingCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "rationale": { + "type": "string", + "minLength": 1 + }, + "reviewedAt": { + "type": "string", + "format": "date-time" + } + } + } + ] + }, + "brief": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "minLength": 1 + } + } + }, + "successRule": { + "type": "object", + "additionalProperties": false, + "required": ["finalStatus"], + "properties": { + "finalStatus": { + "type": "string", + "const": "pass-or-reviewed-warn" + } + } + }, + "paths": { + "type": "object", + "additionalProperties": false, + "required": ["sessionPath", "bundleRoot", "preparedInputPath"], + "properties": { + "sessionPath": { + "type": "string", + "minLength": 1 + }, + "bundleRoot": { + "type": "string", + "minLength": 1 + }, + "preparedInputPath": { + "type": ["string", "null"], + "minLength": 1 + } + } + }, + "attempts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "attemptNumber", + "status", + "outcome", + "findingCodes", + "validatePath", + "assessmentPath", + "metadataPath" + ], + "properties": { + "attemptNumber": { + "type": "number", + "minimum": 1 + }, + "status": { + "type": "string", + "enum": ["pass", "warn", "block"] + }, + "outcome": { + "type": "string", + "enum": ["pass", "accepted-warn", "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 + }, + "reviewPath": { + "type": "string", + "minLength": 1 + }, + "reviewStatus": { + "type": "string", + "enum": ["accepted", "rejected"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "preview": { + "$ref": "#/$defs/previewRef" + } + } + } + } + }, + "$defs": { + "previewRef": { + "type": "object", + "additionalProperties": false, + "required": ["imagePath", "metadataPath", "url", "finalUrl", "capturedAt"], + "properties": { + "imagePath": { + "type": "string", + "minLength": 1 + }, + "metadataPath": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "minLength": 1 + }, + "finalUrl": { + "type": "string", + "minLength": 1 + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "waitFor": { + "type": "string", + "minLength": 1 + } + } + } + } +} 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..4c3c66c --- /dev/null +++ b/packages/interfacectl-cli/schemas/generation-session.schema.json @@ -0,0 +1,103 @@ +{ + "$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", + "guidanceMode", + "workspaceRoot", + "sourceBundleRoot", + "sessionDir", + "bundleRoot", + "preparedInputPath", + "contractPath", + "repairMapPath", + "startedAt", + "successRule" + ], + "properties": { + "schemaVersion": { + "type": "number", + "const": 2 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "tool": { + "type": "string", + "enum": ["codex", "cursor"] + }, + "guidanceMode": { + "type": "string", + "enum": ["prepared", "unguided"] + }, + "workspaceRoot": { + "type": "string", + "minLength": 1 + }, + "sourceBundleRoot": { + "type": "string", + "minLength": 1 + }, + "sessionDir": { + "type": "string", + "minLength": 1 + }, + "bundleRoot": { + "type": "string", + "minLength": 1 + }, + "preparedInputPath": { + "type": ["string", "null"], + "minLength": 1 + }, + "contractPath": { + "type": "string", + "minLength": 1 + }, + "repairMapPath": { + "type": "string", + "minLength": 1 + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "brief": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "minLength": 1 + } + } + }, + "successRule": { + "type": "object", + "additionalProperties": false, + "required": ["finalStatus"], + "properties": { + "finalStatus": { + "type": "string", + "const": "pass-or-reviewed-warn" + } + } + } + } +} 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/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/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..02d83f1 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/generation-session.ts @@ -0,0 +1,2261 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { chromium } from "playwright"; +import { + AdapterInputError, + isRecord, + 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"; +type GuidanceMode = "prepared" | "unguided"; +type SessionSuccessRule = "pass" | "pass-or-reviewed-warn"; +type AttemptReviewStatus = "accepted" | "rejected"; +type AttemptOutcome = ValidateStatus | "accepted-warn"; +type SuggestionStatus = "proposed" | "accepted" | "rejected"; + +type AssessmentDimension = "structure" | "components" | "boundary" | "visual" | "responsiveness"; + +export interface InitGenerationSessionCommandOptions { + bundleRoot?: string; + surfaceId?: string; + workspaceRoot?: string; + tool?: string; + sessionId?: string; + artifactsRoot?: string; + guidanceMode?: string; + briefFile?: string; +} + +export interface RecordGenerationAttemptCommandOptions { + sessionDir?: string; + assessmentFile?: string; +} + +export interface CaptureGenerationPreviewCommandOptions { + sessionDir?: string; + attemptNumber?: string | number; + url?: string; + waitFor?: string; +} + +export interface ReviewGenerationAttemptCommandOptions { + sessionDir?: string; + attemptNumber?: string | number; + reviewFile?: string; +} + +export interface SummarizeGenerationSessionCommandOptions { + sessionDir?: string; +} + +export interface CompareGenerationSessionsCommandOptions { + baselineSessionDir?: string; + guidedSessionDir?: string; + outDir?: string; +} + +export interface SuggestContractDeltasCommandOptions { + sessionDir?: string; + outPath?: string; +} + +export interface ReviewContractDeltaSuggestionsCommandOptions { + suggestionsPath?: string; + reviewFile?: string; + outPath?: string; +} + +export interface SummarizeGenerationBenchmarkCommandOptions { + comparisonPaths?: string; + suggestionPaths?: string; + outDir?: string; +} + +interface GenerationAssessment { + structure: AssessmentGrade; + components: AssessmentGrade; + boundary: AssessmentGrade; + visual: AssessmentGrade; + responsiveness: AssessmentGrade; + notes: string; + touchedFiles?: string[]; +} + +interface GenerationBrief { + path: string; + sha256: string; +} + +interface GenerationSession { + schemaVersion: 2; + surfaceId: string; + sessionId: string; + tool: SessionTool; + guidanceMode: GuidanceMode; + workspaceRoot: string; + sourceBundleRoot: string; + sessionDir: string; + bundleRoot: string; + preparedInputPath: string | null; + contractPath: string; + repairMapPath: string; + startedAt: string; + brief?: GenerationBrief; + successRule: { + finalStatus: SessionSuccessRule; + }; +} + +interface GenerationAttemptReview { + schemaVersion: 1; + surfaceId: string; + sessionId: string; + attemptNumber: number; + status: AttemptReviewStatus; + findingCodes: string[]; + rationale: string; + reviewedAt: string; +} + +interface GenerationAttemptPreview { + schemaVersion: 1; + surfaceId: string; + sessionId: string; + attemptNumber: number; + url: string; + finalUrl: string; + imagePath: string; + capturedAt: string; + viewport: { + width: number; + height: number; + }; + pageTitle?: string; + waitFor?: string; +} + +interface GenerationSessionAttemptMetadata { + schemaVersion: 2; + surfaceId: string; + sessionId: string; + attemptNumber: number; + tool: SessionTool; + guidanceMode: GuidanceMode; + 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: 3; + surfaceId: string; + sessionId: string; + tool: SessionTool; + guidanceMode: GuidanceMode; + attemptCount: number; + firstPassAttempt: number | null; + firstAcceptableAttempt: number | null; + latestStatus: ValidateStatus; + latestOutcome: AttemptOutcome; + recurringFindingCodes: Array<{ code: string; count: number }>; + recurringRepairCodes: Array<{ + code: string; + count: number; + priority: string; + category: string; + actionType: string; + }>; + latestAssessment: GenerationAssessment | null; + latestReview: GenerationAttemptReview | null; + brief?: GenerationBrief; + successRule: { + finalStatus: SessionSuccessRule; + }; + paths: { + sessionPath: string; + bundleRoot: string; + preparedInputPath: string | null; + }; + attempts: Array<{ + attemptNumber: number; + status: ValidateStatus; + outcome: AttemptOutcome; + findingCodes: string[]; + validatePath: string; + assessmentPath: string; + metadataPath: string; + reviewPath?: string; + reviewStatus?: AttemptReviewStatus; + createdAt?: string; + preview?: { + imagePath: string; + metadataPath: string; + url: string; + finalUrl: string; + capturedAt: string; + waitFor?: string; + }; + }>; +} + +interface SessionComparisonSnapshot { + sessionId: string; + sessionDir: string; + guidanceMode: GuidanceMode; + attemptCount: number; + firstAcceptableAttempt: number | null; + latestOutcome: AttemptOutcome; + firstAttempt: ComparisonAttemptSnapshot; + latestAttempt: ComparisonAttemptSnapshot; + recurringFindingCodes: Array<{ code: string; count: number }>; + recurringRepairCodes: Array<{ + code: string; + count: number; + priority: string; + category: string; + actionType: string; + }>; +} + +interface ComparisonAttemptSnapshot { + attemptNumber: number; + status: ValidateStatus; + outcome: AttemptOutcome; + findingCount: number; + blockingFindingCount: number; + warningFindingCount: number; + findingCodes: string[]; + assessment: GenerationAssessment; + preview?: { + imagePath: string; + metadataPath: string; + url: string; + finalUrl: string; + capturedAt: string; + waitFor?: string; + }; +} + +interface GenerationSessionComparison { + schemaVersion: 2; + surfaceId: string; + tool: SessionTool; + brief: { + sha256: string; + baselinePath: string; + guidedPath: string; + }; + baseline: SessionComparisonSnapshot; + guided: SessionComparisonSnapshot; + delta: { + firstAttemptVerdict: { + baseline: AttemptOutcome; + guided: AttemptOutcome; + }; + firstAttemptFindingCountDelta: number; + firstAttemptBlockingFindingCountDelta: number; + firstAttemptWarningFindingCountDelta: number; + latestFindingCountDelta: number; + attemptsToAcceptableOutcome: { + baseline: number | null; + guided: number | null; + delta: number | null; + }; + rubric: Record; + }; + checks: { + guidedFewerFirstAttemptBlockingFindings: boolean; + guidedReachedAcceptableNoLater: boolean; + guidedRubricAtLeastAsGood: boolean; + guidedRubricBetterDimensions: AssessmentDimension[]; + meetsGoal: boolean; + }; + paths: { + baselineSessionDir: string; + guidedSessionDir: string; + }; +} + +interface ContractDeltaSuggestion { + suggestionId: string; + findingCode: string; + findingMessage: string; + repeatedFailureCount: number; + confidence: number; + status: SuggestionStatus; + repair: { + priority: string; + category: string; + actionType: string; + }; + evidenceRefs: unknown[]; + proposedChange: { + path: string; + actionType: string; + summary: string; + valueHints: string[]; + }; + decision?: { + rationale: string; + decidedAt: string; + }; +} + +interface ContractDeltaSuggestionsArtifact { + schemaVersion: 1; + surfaceId: string; + sessionId: string; + tool: SessionTool; + guidanceMode: GuidanceMode; + generatedAt: string; + contract: { + path: string; + }; + session: { + sessionPath: string; + summaryPath: string; + repairMapPath: string; + }; + suggestions: ContractDeltaSuggestion[]; +} + +interface GenerationBenchmarkReport { + schemaVersion: 1; + generatedAt: string; + comparisons: Array<{ + surfaceId: string; + tool: SessionTool; + comparisonPath: string; + meetsGoal: boolean; + guidedFewerFirstAttemptBlockingFindings: boolean; + guidedReachedAcceptableNoLater: boolean; + guidedRubricBetterDimensions: AssessmentDimension[]; + }>; + suggestions: Array<{ + surfaceId: string; + sessionId: string; + suggestionsPath: string; + proposedCount: number; + acceptedCount: number; + rejectedCount: number; + }>; + overall: { + surfaceCount: number; + surfacesMeetingGoal: number; + guidedFewerFirstAttemptBlockingFindings: number; + guidedReachedAcceptableNoLater: number; + acceptedSuggestionCount: number; + rejectedSuggestionCount: number; + proposedSuggestionCount: number; + }; +} + +interface LoadedAttempt { + attemptNumber: number; + validate: JsonRecord; + assessment: JsonRecord; + metadata: JsonRecord; + review: GenerationAttemptReview | null; + preview: GenerationAttemptPreview | null; + validatePath: string; + assessmentPath: string; + metadataPath: string; + reviewPath?: string; + previewMetadataPath?: string; +} + +const VALID_TOOLS = new Set(["codex", "cursor"]); +const VALID_GRADES = new Set(["strong", "partial", "weak"]); +const VALID_GUIDANCE_MODES = new Set(["prepared", "unguided"]); +const VALID_REVIEW_STATUSES = new Set(["accepted", "rejected"]); +const VALID_SUGGESTION_STATUSES = new Set(["proposed", "accepted", "rejected"]); +const VALID_SUCCESS_RULES = new Set(["pass", "pass-or-reviewed-warn"]); +const ASSESSMENT_DIMENSIONS: AssessmentDimension[] = [ + "structure", + "components", + "boundary", + "visual", + "responsiveness", +]; + +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 asRecord(value: unknown): JsonRecord { + return isRecord(value) ? value : {}; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [...new Set( + value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean), + )].sort((left, right) => left.localeCompare(right)); +} + +function sameStringSet(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function countBySeverity(validatePayload: JsonRecord) { + const findings = Array.isArray(validatePayload.findings) ? validatePayload.findings : []; + let errors = 0; + let warnings = 0; + for (const finding of findings) { + if (!finding || typeof finding !== "object") continue; + const severity = (finding as Record).severity; + if (severity === "error") { + errors += 1; + } else if (severity === "warning") { + warnings += 1; + } + } + return { errors, warnings, total: errors + warnings }; +} + +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 ensureGuidanceMode(guidanceMode?: string): GuidanceMode { + const normalized = typeof guidanceMode === "string" ? guidanceMode.trim().toLowerCase() : "prepared"; + if (!VALID_GUIDANCE_MODES.has(normalized as GuidanceMode)) { + throw new SessionInputError( + `Invalid --guidance-mode value "${guidanceMode ?? ""}". Expected prepared|unguided.`, + ); + } + return normalized as GuidanceMode; +} + +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"), + suggestionsJsonPath: path.join(sessionDir, "contract-delta-suggestions.json"), + suggestionsMarkdownPath: path.join(sessionDir, "contract-delta-suggestions.md"), + }; +} + +function getAttemptPaths(attemptsDir: string, attemptNumber: number) { + const attemptId = formatAttemptNumber(attemptNumber); + return { + validatePath: path.join(attemptsDir, `${attemptId}.validate.json`), + assessmentPath: path.join(attemptsDir, `${attemptId}.assessment.json`), + metadataPath: path.join(attemptsDir, `${attemptId}.metadata.json`), + reviewPath: path.join(attemptsDir, `${attemptId}.review.json`), + previewMetadataPath: path.join(attemptsDir, `${attemptId}.preview.json`), + previewImagePath: path.join(attemptsDir, `${attemptId}.preview.png`), + }; +} + +function normalizeAssessment( + payload: JsonRecord, + filePath: string, + options: { allowLegacyMissing?: boolean } = {}, +): GenerationAssessment { + const structureFallback = payload.structure; + const grade = (key: AssessmentDimension) => { + let value = payload[key]; + if (value === undefined && options.allowLegacyMissing && (key === "components" || key === "boundary")) { + value = structureFallback; + } + 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"), + components: grade("components"), + boundary: grade("boundary"), + 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 loadStoredAttemptReview(reviewPath: string): GenerationAttemptReview | null { + if (!fs.existsSync(reviewPath)) { + return null; + } + const payload = readJsonFile(reviewPath, "generation attempt review"); + const status = payload.status; + if (!VALID_REVIEW_STATUSES.has(status as AttemptReviewStatus)) { + throw new SessionInputError(`Unsupported review status in ${reviewPath}.`); + } + const rationale = typeof payload.rationale === "string" ? payload.rationale.trim() : ""; + if (!rationale) { + throw new SessionInputError(`Review rationale must be a non-empty string: ${reviewPath}.`); + } + return { + schemaVersion: 1, + surfaceId: asString(payload.surfaceId) ?? "", + sessionId: asString(payload.sessionId) ?? "", + attemptNumber: Number(payload.attemptNumber), + status: status as AttemptReviewStatus, + findingCodes: asStringArray(payload.findingCodes), + rationale, + reviewedAt: asString(payload.reviewedAt) ?? "", + }; +} + +function loadStoredAttemptPreview(previewMetadataPath: string, previewImagePath: string): GenerationAttemptPreview | null { + if (!fs.existsSync(previewMetadataPath)) { + return null; + } + if (!fs.existsSync(previewImagePath)) { + throw new SessionInputError(`Preview image not found at ${previewImagePath}.`); + } + const payload = readJsonFile(previewMetadataPath, "generation attempt preview"); + const pageTitle = asString(payload.pageTitle); + const waitFor = asString(payload.waitFor); + const viewport = asRecord(payload.viewport); + const width = Number(viewport.width); + const height = Number(viewport.height); + if (!Number.isFinite(width) || width < 1 || !Number.isFinite(height) || height < 1) { + throw new SessionInputError(`Preview viewport is invalid in ${previewMetadataPath}.`); + } + const preview: GenerationAttemptPreview = { + schemaVersion: 1, + surfaceId: asString(payload.surfaceId) ?? "", + sessionId: asString(payload.sessionId) ?? "", + attemptNumber: Number(payload.attemptNumber), + url: asString(payload.url) ?? "", + finalUrl: asString(payload.finalUrl) ?? "", + imagePath: asString(payload.imagePath) ?? previewImagePath, + capturedAt: asString(payload.capturedAt) ?? "", + viewport: { + width, + height, + }, + ...(pageTitle ? { pageTitle } : {}), + ...(waitFor ? { waitFor } : {}), + }; + if ( + preview.attemptNumber < 1 || + !preview.surfaceId || + !preview.sessionId || + !preview.url || + !preview.finalUrl || + !preview.imagePath || + !preview.capturedAt + ) { + throw new SessionInputError(`Generation attempt preview is missing required fields: ${previewMetadataPath}.`); + } + return preview; +} + +function toPreviewReference( + preview: GenerationAttemptPreview | null, + previewMetadataPath?: string, +): ComparisonAttemptSnapshot["preview"] | GenerationSessionSummary["attempts"][number]["preview"] | undefined { + if (!preview || !previewMetadataPath) { + return undefined; + } + return { + imagePath: preview.imagePath, + metadataPath: previewMetadataPath, + url: preview.url, + finalUrl: preview.finalUrl, + capturedAt: preview.capturedAt, + ...(preview.waitFor ? { waitFor: preview.waitFor } : {}), + }; +} + +function normalizeReviewInput( + payload: JsonRecord, + filePath: string, + findingCodes: string[], +): Omit { + const status = payload.status; + if (!VALID_REVIEW_STATUSES.has(status as AttemptReviewStatus)) { + throw new SessionInputError( + `Review field "status" must be accepted|rejected: ${filePath}.`, + ); + } + + const rationale = typeof payload.rationale === "string" ? payload.rationale.trim() : ""; + if (!rationale) { + throw new SessionInputError(`Review field "rationale" must be a non-empty string: ${filePath}.`); + } + + const reviewedCodes = asStringArray(payload.findingCodes); + if (reviewedCodes.length === 0) { + throw new SessionInputError(`Review field "findingCodes" must list the reviewed findings: ${filePath}.`); + } + for (const code of reviewedCodes) { + if (!findingCodes.includes(code)) { + throw new SessionInputError(`Review field "findingCodes" includes unknown finding code "${code}": ${filePath}.`); + } + } + if (status === "accepted" && !sameStringSet(reviewedCodes, findingCodes)) { + throw new SessionInputError( + `Accepted warn reviews must cover every remaining finding code: ${filePath}.`, + ); + } + + return { + status: status as AttemptReviewStatus, + findingCodes: reviewedCodes, + rationale, + }; +} + +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 payload = readJsonFile(paths.sessionPath, "generation session"); + const schemaVersion = Number(payload.schemaVersion ?? 1); + if (schemaVersion !== 1 && schemaVersion !== 2) { + throw new SessionInputError(`Unsupported generation session schemaVersion "${String(payload.schemaVersion ?? "unknown")}".`); + } + + const tool = ensureSessionTool(asString(payload.tool)); + const guidanceMode = ensureGuidanceMode(asString(payload.guidanceMode) ?? "prepared"); + const finalStatus = asString(asRecord(payload.successRule).finalStatus) ?? "pass"; + if (!VALID_SUCCESS_RULES.has(finalStatus as SessionSuccessRule)) { + throw new SessionInputError(`Unsupported session successRule.finalStatus "${finalStatus}".`); + } + + const briefRecord = asRecord(payload.brief); + const briefPath = asString(briefRecord.path); + const briefSha256 = asString(briefRecord.sha256); + const session: GenerationSession = { + schemaVersion: 2, + surfaceId: asString(payload.surfaceId) ?? "", + sessionId: asString(payload.sessionId) ?? "", + tool, + guidanceMode, + workspaceRoot: asString(payload.workspaceRoot) ?? "", + sourceBundleRoot: asString(payload.sourceBundleRoot) ?? "", + sessionDir: asString(payload.sessionDir) ?? sessionDir, + bundleRoot: asString(payload.bundleRoot) ?? "", + preparedInputPath: typeof payload.preparedInputPath === "string" ? payload.preparedInputPath : null, + contractPath: asString(payload.contractPath) ?? "", + repairMapPath: asString(payload.repairMapPath) ?? "", + startedAt: asString(payload.startedAt) ?? "", + ...(briefPath && briefSha256 ? { brief: { path: briefPath, sha256: briefSha256 } } : {}), + successRule: { + finalStatus: finalStatus as SessionSuccessRule, + }, + }; + + if (!session.surfaceId || !session.sessionId || !session.workspaceRoot || !session.bundleRoot || !session.contractPath || !session.repairMapPath || !session.startedAt) { + throw new SessionInputError(`Generation session is missing required fields: ${paths.sessionPath}.`); + } + + return { + session, + paths, + }; +} + +function toBrowserLaunchError(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + if (/Executable doesn't exist|browserType\.launch/i.test(message)) { + return new Error( + `Playwright Chromium is not installed. Run "pnpm exec playwright install chromium" in /Users/mike/SurfacesPlatform/interfacectl.`, + ); + } + return error instanceof Error ? error : new Error(message); +} + +async function waitForPageSettle(page: { + waitForLoadState: (state: "domcontentloaded" | "networkidle", options?: { timeout?: number }) => Promise; + waitForTimeout: (timeout: number) => Promise; +}) { + await page.waitForLoadState("domcontentloaded"); + await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => undefined); + await page.waitForTimeout(300); +} + +async function waitForPreviewCondition( + page: { + locator: (selector: string) => { first: () => { waitFor: (options: { state: "visible"; timeout: number }) => Promise } }; + getByText: (text: string, options: { exact: boolean }) => { first: () => { waitFor: (options: { state: "visible"; timeout: number }) => Promise } }; + }, + waitFor: string, +) { + const timeout = 5_000; + try { + await page.locator(waitFor).first().waitFor({ state: "visible", timeout }); + return; + } catch (selectorError) { + try { + await page.getByText(waitFor, { exact: false }).first().waitFor({ state: "visible", timeout }); + return; + } catch (textError) { + const selectorMessage = selectorError instanceof Error ? selectorError.message : String(selectorError); + const textMessage = textError instanceof Error ? textError.message : String(textError); + throw new SessionInputError( + `Preview wait condition "${waitFor}" was not satisfied. Selector error: ${selectorMessage}. Text error: ${textMessage}.`, + ); + } + } +} + +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}`, + `Guidance mode: ${summary.guidanceMode}`, + `Latest status: ${summary.latestStatus}`, + `Latest outcome: ${summary.latestOutcome}`, + `Attempts: ${summary.attemptCount}`, + `First pass attempt: ${summary.firstPassAttempt ?? "not yet reached"}`, + `First acceptable attempt: ${summary.firstAcceptableAttempt ?? "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(`- components: ${summary.latestAssessment?.components ?? "n/a"}`); + lines.push(`- boundary: ${summary.latestAssessment?.boundary ?? "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(", ")}`); + } + if (summary.latestReview) { + lines.push(`- latest review: ${summary.latestReview.status} (${summary.latestReview.findingCodes.join(", ")})`); + lines.push(`- review rationale: ${summary.latestReview.rationale}`); + } + + return `${lines.join("\n")}\n`; +} + +function getSuccessOutcome( + status: ValidateStatus, + review: GenerationAttemptReview | null, + findingCodes: string[], + successRule: SessionSuccessRule, +): AttemptOutcome { + if (status === "pass") { + return "pass"; + } + if ( + status === "warn" && + successRule === "pass-or-reviewed-warn" && + review && + review.status === "accepted" && + sameStringSet(review.findingCodes, findingCodes) + ) { + return "accepted-warn"; + } + return status; +} + +function loadAttemptRecords(paths: ReturnType): LoadedAttempt[] { + if (!fs.existsSync(paths.attemptsDir)) { + return []; + } + + 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); + + return attemptNumbers.map((attemptNumber) => { + const attemptPaths = getAttemptPaths(paths.attemptsDir, attemptNumber); + const review = loadStoredAttemptReview(attemptPaths.reviewPath); + const preview = loadStoredAttemptPreview(attemptPaths.previewMetadataPath, attemptPaths.previewImagePath); + return { + attemptNumber, + validate: readJsonFile(attemptPaths.validatePath, `attempt ${attemptNumber} validate payload`), + assessment: readJsonFile(attemptPaths.assessmentPath, `attempt ${attemptNumber} assessment`), + metadata: readJsonFile(attemptPaths.metadataPath, `attempt ${attemptNumber} metadata`), + review, + preview, + validatePath: attemptPaths.validatePath, + assessmentPath: attemptPaths.assessmentPath, + metadataPath: attemptPaths.metadataPath, + ...(review ? { reviewPath: attemptPaths.reviewPath } : {}), + ...(preview ? { previewMetadataPath: attemptPaths.previewMetadataPath } : {}), + }; + }); +} + +function buildGenerationSessionSummary(sessionDirInput: string) { + const { session, paths } = loadSession(sessionDirInput); + const attempts = loadAttemptRecords(paths); + if (attempts.length === 0) { + throw new SessionInputError(`No attempts recorded for session ${session.sessionId}.`); + } + + const firstPassAttempt = attempts.find((attempt) => attempt.validate.status === "pass")?.attemptNumber ?? null; + const firstAcceptableAttempt = attempts.find((attempt) => { + const status = attempt.validate.status; + if (status !== "pass" && status !== "warn" && status !== "block") { + throw new SessionInputError(`Unsupported validate status "${String(status)}" in ${attempt.validatePath}.`); + } + const outcome = getSuccessOutcome(status, attempt.review, parseFindingCodes(attempt.validate), session.successRule.finalStatus); + return outcome === "pass" || outcome === "accepted-warn"; + })?.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, { + allowLegacyMissing: true, + }); + const latestStatus = latestAttempt.validate.status; + if (latestStatus !== "pass" && latestStatus !== "warn" && latestStatus !== "block") { + throw new SessionInputError(`Unsupported validate status "${String(latestStatus)}" in ${latestAttempt.validatePath}.`); + } + const latestOutcome = getSuccessOutcome( + latestStatus, + latestAttempt.review, + parseFindingCodes(latestAttempt.validate), + session.successRule.finalStatus, + ); + + const summary: GenerationSessionSummary = { + schemaVersion: 3, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + tool: session.tool, + guidanceMode: session.guidanceMode, + attemptCount: attempts.length, + firstPassAttempt, + firstAcceptableAttempt, + latestStatus, + latestOutcome, + recurringFindingCodes, + recurringRepairCodes, + latestAssessment, + latestReview: latestAttempt.review, + ...(session.brief ? { brief: session.brief } : {}), + successRule: session.successRule, + paths: { + sessionPath: paths.sessionPath, + bundleRoot: session.bundleRoot, + preparedInputPath: session.preparedInputPath, + }, + attempts: attempts.map((attempt) => { + const status = attempt.validate.status; + if (status !== "pass" && status !== "warn" && status !== "block") { + throw new SessionInputError(`Unsupported validate status "${String(status)}" in ${attempt.validatePath}.`); + } + const findingCodes = parseFindingCodes(attempt.validate); + return { + attemptNumber: attempt.attemptNumber, + status, + outcome: getSuccessOutcome(status, attempt.review, findingCodes, session.successRule.finalStatus), + findingCodes, + validatePath: attempt.validatePath, + assessmentPath: attempt.assessmentPath, + metadataPath: attempt.metadataPath, + ...(attempt.reviewPath ? { reviewPath: attempt.reviewPath } : {}), + ...(attempt.review ? { reviewStatus: attempt.review.status } : {}), + createdAt: typeof attempt.metadata.createdAt === "string" ? attempt.metadata.createdAt : undefined, + ...(toPreviewReference(attempt.preview, attempt.previewMetadataPath) + ? { preview: toPreviewReference(attempt.preview, attempt.previewMetadataPath) } + : {}), + }; + }), + }; + + return { + session, + paths, + attempts, + summary, + }; +} + +function toComparisonAttemptSnapshot( + attempt: LoadedAttempt, + successRule: SessionSuccessRule, +): ComparisonAttemptSnapshot { + const status = attempt.validate.status; + if (status !== "pass" && status !== "warn" && status !== "block") { + throw new SessionInputError(`Unsupported validate status "${String(status)}" in ${attempt.validatePath}.`); + } + const findingCodes = parseFindingCodes(attempt.validate); + const counts = countBySeverity(attempt.validate); + return { + attemptNumber: attempt.attemptNumber, + status, + outcome: getSuccessOutcome(status, attempt.review, findingCodes, successRule), + findingCount: counts.total, + blockingFindingCount: counts.errors, + warningFindingCount: counts.warnings, + findingCodes, + assessment: normalizeAssessment(attempt.assessment, attempt.assessmentPath, { allowLegacyMissing: true }), + ...(toPreviewReference(attempt.preview, attempt.previewMetadataPath) + ? { preview: toPreviewReference(attempt.preview, attempt.previewMetadataPath) } + : {}), + }; +} + +function gradeToScore(grade: AssessmentGrade): number { + switch (grade) { + case "weak": + return 0; + case "partial": + return 1; + case "strong": + return 2; + } +} + +function getRepairPriorityRank(priority: string): number { + switch (priority) { + case "high": + return 0; + case "medium": + return 1; + case "low": + return 2; + default: + return 3; + } +} + +function renderComparisonMarkdown(comparison: GenerationSessionComparison): string { + const lines = [ + "# Generation Session Comparison", + "", + `Surface: ${comparison.surfaceId}`, + `Tool: ${comparison.tool}`, + `Baseline session: ${comparison.baseline.sessionId}`, + `Guided session: ${comparison.guided.sessionId}`, + `Meets goal: ${comparison.checks.meetsGoal ? "yes" : "no"}`, + "", + "## First attempt", + `- baseline outcome: ${comparison.baseline.firstAttempt.outcome}`, + `- guided outcome: ${comparison.guided.firstAttempt.outcome}`, + `- blocking finding delta: ${comparison.delta.firstAttemptBlockingFindingCountDelta}`, + `- warning finding delta: ${comparison.delta.firstAttemptWarningFindingCountDelta}`, + "", + "## Convergence", + `- baseline first acceptable attempt: ${comparison.baseline.firstAcceptableAttempt ?? "not reached"}`, + `- guided first acceptable attempt: ${comparison.guided.firstAcceptableAttempt ?? "not reached"}`, + `- attempts-to-acceptable delta: ${comparison.delta.attemptsToAcceptableOutcome.delta ?? "n/a"}`, + "", + "## Rubric delta", + ]; + + for (const dimension of ASSESSMENT_DIMENSIONS) { + const rubric = comparison.delta.rubric[dimension]; + lines.push(`- ${dimension}: ${rubric.baseline} -> ${rubric.guided} (${rubric.delta})`); + } + + if (comparison.checks.guidedRubricBetterDimensions.length > 0) { + lines.push( + "", + `Guided improved dimensions: ${comparison.checks.guidedRubricBetterDimensions.join(", ")}`, + ); + } + + return `${lines.join("\n")}\n`; +} + +function renderSuggestionsMarkdown(artifact: ContractDeltaSuggestionsArtifact): string { + const lines = [ + "# Contract Delta Suggestions", + "", + `Surface: ${artifact.surfaceId}`, + `Session: ${artifact.sessionId}`, + `Tool: ${artifact.tool}`, + `Guidance mode: ${artifact.guidanceMode}`, + "", + ]; + + if (artifact.suggestions.length === 0) { + lines.push("No suggestions."); + return `${lines.join("\n")}\n`; + } + + for (const suggestion of artifact.suggestions) { + lines.push(`## ${suggestion.suggestionId}`); + lines.push(`- finding: ${suggestion.findingCode}`); + lines.push(`- status: ${suggestion.status}`); + lines.push(`- repeated failures: ${suggestion.repeatedFailureCount}`); + lines.push(`- confidence: ${suggestion.confidence}`); + lines.push(`- contract path: ${suggestion.proposedChange.path}`); + lines.push(`- action: ${suggestion.proposedChange.actionType}`); + lines.push(`- summary: ${suggestion.proposedChange.summary}`); + if (suggestion.decision) { + lines.push(`- rationale: ${suggestion.decision.rationale}`); + } + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +function renderBenchmarkReportMarkdown(report: GenerationBenchmarkReport): string { + const lines = [ + "# Generation Benchmark Report", + "", + `Generated at: ${report.generatedAt}`, + `Surfaces: ${report.overall.surfaceCount}`, + `Surfaces meeting goal: ${report.overall.surfacesMeetingGoal}`, + `Guided fewer first-attempt blocking findings: ${report.overall.guidedFewerFirstAttemptBlockingFindings}`, + `Guided reached acceptable no later: ${report.overall.guidedReachedAcceptableNoLater}`, + "", + "## Comparisons", + ]; + + for (const comparison of report.comparisons) { + lines.push( + `- ${comparison.surfaceId}: meetsGoal=${comparison.meetsGoal}, improved dimensions=${comparison.guidedRubricBetterDimensions.join(", ") || "none"}`, + ); + } + + lines.push("", "## Suggestion decisions"); + for (const suggestion of report.suggestions) { + lines.push( + `- ${suggestion.surfaceId}: proposed=${suggestion.proposedCount}, accepted=${suggestion.acceptedCount}, rejected=${suggestion.rejectedCount}`, + ); + } + + return `${lines.join("\n")}\n`; +} + +function freezeBriefFile(sessionDir: string, briefFile: string): GenerationBrief { + const sourcePath = path.resolve(briefFile); + if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) { + throw new SessionInputError(`Brief file not found at ${sourcePath}.`); + } + const extension = path.extname(sourcePath) || ".txt"; + const destinationPath = path.join(sessionDir, `task-brief${extension}`); + const content = fs.readFileSync(sourcePath); + fs.writeFileSync(destinationPath, content); + return { + path: destinationPath, + sha256: crypto.createHash("sha256").update(content).digest("hex"), + }; +} + +function defaultComparisonDir(baseline: GenerationSession, guided: GenerationSession): string { + return path.join( + baseline.workspaceRoot, + "artifacts", + "generation-benchmarks", + baseline.surfaceId, + `${baseline.sessionId}--vs--${guided.sessionId}`, + ); +} + +function defaultBenchmarkReportDir(comparisonPaths: string[]): string { + const firstPath = comparisonPaths[0]; + return path.join( + path.dirname(path.dirname(firstPath)), + "report", + ); +} + +function parseCsvPaths(value?: string): string[] { + if (!value) return []; + return [...new Set( + value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => path.resolve(entry)), + )]; +} + +function buildComparisonArtifact( + baselineSessionDir: string, + guidedSessionDir: string, +): GenerationSessionComparison { + const baselineBuilt = buildGenerationSessionSummary(baselineSessionDir); + const guidedBuilt = buildGenerationSessionSummary(guidedSessionDir); + + if (baselineBuilt.session.surfaceId !== guidedBuilt.session.surfaceId) { + throw new SessionInputError("Baseline and guided sessions must target the same surface."); + } + if (baselineBuilt.session.tool !== guidedBuilt.session.tool) { + throw new SessionInputError("Baseline and guided sessions must use the same tool."); + } + if (baselineBuilt.session.guidanceMode !== "unguided") { + throw new SessionInputError("Baseline session must use guidanceMode=unguided."); + } + if (guidedBuilt.session.guidanceMode !== "prepared") { + throw new SessionInputError("Guided session must use guidanceMode=prepared."); + } + if (!baselineBuilt.session.brief || !guidedBuilt.session.brief) { + throw new SessionInputError("Both sessions must freeze the same implementation brief before comparison."); + } + if (baselineBuilt.session.brief.sha256 !== guidedBuilt.session.brief.sha256) { + throw new SessionInputError("Baseline and guided sessions must use the same implementation brief."); + } + + const baselineFirstAttempt = toComparisonAttemptSnapshot( + baselineBuilt.attempts[0], + baselineBuilt.session.successRule.finalStatus, + ); + const guidedFirstAttempt = toComparisonAttemptSnapshot( + guidedBuilt.attempts[0], + guidedBuilt.session.successRule.finalStatus, + ); + const baselineLatestAttempt = toComparisonAttemptSnapshot( + baselineBuilt.attempts[baselineBuilt.attempts.length - 1], + baselineBuilt.session.successRule.finalStatus, + ); + const guidedLatestAttempt = toComparisonAttemptSnapshot( + guidedBuilt.attempts[guidedBuilt.attempts.length - 1], + guidedBuilt.session.successRule.finalStatus, + ); + + const rubric = Object.fromEntries( + ASSESSMENT_DIMENSIONS.map((dimension) => { + const baseline = baselineLatestAttempt.assessment[dimension]; + const guided = guidedLatestAttempt.assessment[dimension]; + return [ + dimension, + { + baseline, + guided, + delta: gradeToScore(guided) - gradeToScore(baseline), + }, + ]; + }), + ) as GenerationSessionComparison["delta"]["rubric"]; + + const guidedRubricBetterDimensions = ASSESSMENT_DIMENSIONS.filter( + (dimension) => rubric[dimension].delta > 0, + ); + const guidedRubricAtLeastAsGood = ASSESSMENT_DIMENSIONS.every( + (dimension) => rubric[dimension].delta >= 0, + ); + const guidedReachedAcceptableNoLater = baselineBuilt.summary.firstAcceptableAttempt === null + ? guidedBuilt.summary.firstAcceptableAttempt !== null + : guidedBuilt.summary.firstAcceptableAttempt !== null && + guidedBuilt.summary.firstAcceptableAttempt <= baselineBuilt.summary.firstAcceptableAttempt; + const guidedFewerFirstAttemptBlockingFindings = + guidedFirstAttempt.blockingFindingCount < baselineFirstAttempt.blockingFindingCount; + + return { + schemaVersion: 2, + surfaceId: baselineBuilt.session.surfaceId, + tool: baselineBuilt.session.tool, + brief: { + sha256: baselineBuilt.session.brief.sha256, + baselinePath: baselineBuilt.session.brief.path, + guidedPath: guidedBuilt.session.brief.path, + }, + baseline: { + sessionId: baselineBuilt.session.sessionId, + sessionDir: baselineBuilt.session.sessionDir, + guidanceMode: baselineBuilt.session.guidanceMode, + attemptCount: baselineBuilt.summary.attemptCount, + firstAcceptableAttempt: baselineBuilt.summary.firstAcceptableAttempt, + latestOutcome: baselineBuilt.summary.latestOutcome, + firstAttempt: baselineFirstAttempt, + latestAttempt: baselineLatestAttempt, + recurringFindingCodes: baselineBuilt.summary.recurringFindingCodes, + recurringRepairCodes: baselineBuilt.summary.recurringRepairCodes, + }, + guided: { + sessionId: guidedBuilt.session.sessionId, + sessionDir: guidedBuilt.session.sessionDir, + guidanceMode: guidedBuilt.session.guidanceMode, + attemptCount: guidedBuilt.summary.attemptCount, + firstAcceptableAttempt: guidedBuilt.summary.firstAcceptableAttempt, + latestOutcome: guidedBuilt.summary.latestOutcome, + firstAttempt: guidedFirstAttempt, + latestAttempt: guidedLatestAttempt, + recurringFindingCodes: guidedBuilt.summary.recurringFindingCodes, + recurringRepairCodes: guidedBuilt.summary.recurringRepairCodes, + }, + delta: { + firstAttemptVerdict: { + baseline: baselineFirstAttempt.outcome, + guided: guidedFirstAttempt.outcome, + }, + firstAttemptFindingCountDelta: guidedFirstAttempt.findingCount - baselineFirstAttempt.findingCount, + firstAttemptBlockingFindingCountDelta: + guidedFirstAttempt.blockingFindingCount - baselineFirstAttempt.blockingFindingCount, + firstAttemptWarningFindingCountDelta: + guidedFirstAttempt.warningFindingCount - baselineFirstAttempt.warningFindingCount, + latestFindingCountDelta: guidedLatestAttempt.findingCount - baselineLatestAttempt.findingCount, + attemptsToAcceptableOutcome: { + baseline: baselineBuilt.summary.firstAcceptableAttempt, + guided: guidedBuilt.summary.firstAcceptableAttempt, + delta: + baselineBuilt.summary.firstAcceptableAttempt !== null && + guidedBuilt.summary.firstAcceptableAttempt !== null + ? guidedBuilt.summary.firstAcceptableAttempt - baselineBuilt.summary.firstAcceptableAttempt + : null, + }, + rubric, + }, + checks: { + guidedFewerFirstAttemptBlockingFindings, + guidedReachedAcceptableNoLater, + guidedRubricAtLeastAsGood, + guidedRubricBetterDimensions, + meetsGoal: + guidedFewerFirstAttemptBlockingFindings && + guidedReachedAcceptableNoLater && + guidedRubricAtLeastAsGood && + guidedRubricBetterDimensions.length >= 2, + }, + paths: { + baselineSessionDir: baselineBuilt.session.sessionDir, + guidedSessionDir: guidedBuilt.session.sessionDir, + }, + }; +} + +function inferContractPath(surfaceId: string, findingCode: string, repair?: JsonRecord): string { + const actionType = asString(asRecord(repair?.action).type) ?? ""; + switch (actionType) { + case "ensure-required-sections": + case "restrict-section-set": + case "restore-top-level-sections": + case "restore-section-order": + return `surfaces[id=${surfaceId}].requiredSections`; + case "restrict-fonts": + case "restore-marketing-typography-profile": + return `surfaces[id=${surfaceId}].allowedFonts`; + case "restrict-colors": + return "color.allowedValues"; + case "restrict-icon-sources": + return "icons.allowedSources"; + case "restrict-motion-durations": + return "constraints.motion.allowedDurationsMs"; + case "restrict-motion-timing": + return "constraints.motion.allowedTimingFunctions"; + case "reduce-content-width": + case "ensure-required-containers": + case "align-page-frame": + case "restore-page-background-mode": + case "restore-landing-pattern-signals": + return `surfaces[id=${surfaceId}].layout`; + case "restore-required-flows": + case "restore-required-flow-steps": + case "restore-required-transitions": + return `surfaces[id=${surfaceId}].flows`; + case "remove-prohibited-primitives": + return `surfaces[id=${surfaceId}].shell`; + default: + if (findingCode.startsWith("section.")) return `surfaces[id=${surfaceId}].requiredSections`; + if (findingCode.startsWith("layout.") || findingCode.startsWith("landing.")) return `surfaces[id=${surfaceId}].layout`; + if (findingCode.startsWith("font.")) return `surfaces[id=${surfaceId}].allowedFonts`; + if (findingCode.startsWith("color.")) return "color.allowedValues"; + if (findingCode.startsWith("icon.")) return "icons.allowedSources"; + if (findingCode.startsWith("motion.")) return "constraints.motion"; + if (findingCode.startsWith("flow.")) return `surfaces[id=${surfaceId}].flows`; + return `surfaces[id=${surfaceId}]`; + } +} + +function getSuggestionConfidence( + repeatedFailureCount: number, + repairPriority: string, + evidenceRefs: unknown[], + contractPath: string, +): number { + let confidence = 0.45; + if (repeatedFailureCount > 1) confidence += 0.2; + if (repairPriority === "high") confidence += 0.15; + if (repairPriority === "medium") confidence += 0.05; + if (evidenceRefs.length > 0) confidence += 0.1; + if (contractPath === "surfaces[id=unknown]") confidence -= 0.15; + return Math.max(0.1, Math.min(0.95, Math.round(confidence * 100) / 100)); +} + +function getSuggestionSortKey(left: ContractDeltaSuggestion, right: ContractDeltaSuggestion): number { + return ( + right.repeatedFailureCount - left.repeatedFailureCount || + getRepairPriorityRank(left.repair.priority) - getRepairPriorityRank(right.repair.priority) || + left.findingCode.localeCompare(right.findingCode) + ); +} + +function buildSuggestionArtifact(sessionDir: string): ContractDeltaSuggestionsArtifact { + const built = buildGenerationSessionSummary(sessionDir); + if (built.session.guidanceMode !== "prepared") { + throw new SessionInputError("Contract delta suggestions require a guided prepared session."); + } + + const repairMapDoc = readJsonFile(built.session.repairMapPath, "repair map"); + const repairs = Array.isArray(repairMapDoc.repairs) ? repairMapDoc.repairs : []; + const repairMapByCode = new Map( + repairs + .filter((entry): entry is JsonRecord => isRecord(entry)) + .map((entry) => [asString(entry.code) ?? "", entry] as const) + .filter(([code]) => Boolean(code)), + ); + const recurringCounts = new Map( + built.summary.recurringFindingCodes.map((entry) => [entry.code, entry.count] as const), + ); + + const latestAttempt = built.attempts[built.attempts.length - 1]; + const latestFindings = Array.isArray(latestAttempt.validate.findings) ? latestAttempt.validate.findings : []; + const latestFindingByCode = new Map(); + for (const finding of latestFindings) { + if (!finding || typeof finding !== "object") continue; + const entry = finding as JsonRecord; + const code = asString(entry.code); + if (!code) continue; + latestFindingByCode.set(code, entry); + } + + const allCodes = [...new Set( + built.attempts.flatMap((attempt) => parseFindingCodes(attempt.validate)), + )].sort((left, right) => left.localeCompare(right)); + + const suggestions = allCodes.map((code) => { + const repair = repairMapByCode.get(code); + const latestFinding = latestFindingByCode.get(code); + const action = asRecord(repair?.action); + const repairPriority = asString(repair?.priority) ?? "medium"; + const repairCategory = asString(repair?.category) ?? "unknown"; + const actionType = asString(action.type) ?? "review-contract"; + const evidence = latestFinding?.evidence; + const evidenceRefs = isRecord(evidence) && Object.keys(evidence).length > 0 ? [evidence] : []; + const contractPath = inferContractPath(built.session.surfaceId, code, repair); + const valueHints = [ + ...(Array.isArray(action.sectionIds) ? action.sectionIds.filter((entry) => typeof entry === "string") as string[] : []), + ...(Array.isArray(action.sectionOrder) ? action.sectionOrder.filter((entry) => typeof entry === "string") as string[] : []), + ...(Array.isArray(action.allowedValues) ? action.allowedValues.filter((entry) => typeof entry === "string") as string[] : []), + ...(Array.isArray(action.allowedSources) ? action.allowedSources.filter((entry) => typeof entry === "string") as string[] : []), + ...(Array.isArray(action.requiredContainers) ? action.requiredContainers.filter((entry) => typeof entry === "string") as string[] : []), + ].slice(0, 6); + const repeatedFailureCount = recurringCounts.get(code) ?? 1; + + return { + suggestionId: `suggestion:${code}`, + findingCode: code, + findingMessage: asString(latestFinding?.message) ?? `Review contract coverage for ${code}.`, + repeatedFailureCount, + confidence: getSuggestionConfidence(repeatedFailureCount, repairPriority, evidenceRefs, contractPath), + status: "proposed" as const, + repair: { + priority: repairPriority, + category: repairCategory, + actionType, + }, + evidenceRefs, + proposedChange: { + path: contractPath, + actionType, + summary: + asString(latestFinding?.message) ?? + `Review whether ${contractPath} needs refinement to better constrain ${code}.`, + valueHints, + }, + }; + }).sort(getSuggestionSortKey); + + return { + schemaVersion: 1, + surfaceId: built.session.surfaceId, + sessionId: built.session.sessionId, + tool: built.session.tool, + guidanceMode: built.session.guidanceMode, + generatedAt: + asString(latestAttempt.metadata.createdAt) ?? + asString(latestAttempt.validate.provenance && asRecord(latestAttempt.validate.provenance).evaluatedAt) ?? + built.session.startedAt, + contract: { + path: built.session.contractPath, + }, + session: { + sessionPath: built.paths.sessionPath, + summaryPath: built.paths.summaryJsonPath, + repairMapPath: built.session.repairMapPath, + }, + suggestions, + }; +} + +function normalizeSuggestionReviewFile(filePath: string): Array<{ + suggestionId: string; + status: Exclude; + rationale: string; +}> { + const payload = readJsonFile(filePath, "contract delta suggestion review"); + const decisions = Array.isArray(payload.decisions) ? payload.decisions : []; + if (decisions.length === 0) { + throw new SessionInputError(`Review file must include a non-empty "decisions" array: ${filePath}.`); + } + return decisions.map((entry, index) => { + if (!entry || typeof entry !== "object") { + throw new SessionInputError(`Review decision at index ${index} is invalid: ${filePath}.`); + } + const decision = entry as JsonRecord; + const suggestionId = asString(decision.suggestionId); + const status = asString(decision.status); + const rationale = typeof decision.rationale === "string" ? decision.rationale.trim() : ""; + if (!suggestionId) { + throw new SessionInputError(`Review decision at index ${index} is missing suggestionId: ${filePath}.`); + } + if (status !== "accepted" && status !== "rejected") { + throw new SessionInputError(`Review decision for ${suggestionId} must use accepted|rejected: ${filePath}.`); + } + if (!rationale) { + throw new SessionInputError(`Review decision for ${suggestionId} must include rationale: ${filePath}.`); + } + return { + suggestionId, + status, + rationale, + }; + }); +} + +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 guidanceMode = ensureGuidanceMode(options.guidanceMode); + 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()); + let preparedInputPath: string | null = null; + if (guidanceMode === "prepared") { + const preparedPayload = buildPreparedGenerationPayload(sessionBundle); + writeDeterministicJsonSync(paths.preparedInputPath, preparedPayload); + preparedInputPath = paths.preparedInputPath; + } + + const session: GenerationSession = { + schemaVersion: 2, + surfaceId: options.surfaceId, + sessionId, + tool, + guidanceMode, + workspaceRoot, + sourceBundleRoot: loadedBundle.root, + sessionDir: paths.sessionDir, + bundleRoot: paths.bundleRoot, + preparedInputPath, + contractPath: sessionBundle.contract.path, + repairMapPath: sessionBundle.surface.repairMap.path, + startedAt: new Date().toISOString(), + ...(options.briefFile ? { brief: freezeBriefFile(paths.sessionDir, options.briefFile) } : {}), + successRule: { + finalStatus: "pass-or-reviewed-warn", + }, + }; + 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 attemptPaths = getAttemptPaths(paths.attemptsDir, attemptNumber); + fs.mkdirSync(paths.attemptsDir, { recursive: true }); + + 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(attemptPaths.validatePath, response); + writeDeterministicJsonSync(attemptPaths.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: attemptPaths.validatePath, + findingCodes, + workspaceId: session.sessionId, + idempotencyKey: `${session.surfaceId}:${session.sessionId}:${formatAttemptNumber(attemptNumber)}`, + }); + + const metadata: GenerationSessionAttemptMetadata = { + schemaVersion: 2, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + attemptNumber, + tool: session.tool, + guidanceMode: session.guidanceMode, + createdAt: new Date().toISOString(), + validateStatus: response.status, + validateExitCode: response.status === "block" ? 30 : 0, + findingCodes, + assessmentPath: attemptPaths.assessmentPath, + validatePath: attemptPaths.validatePath, + touchedFiles: assessment.touchedFiles ?? [], + contractRun, + }; + writeDeterministicJsonSync(attemptPaths.metadataPath, metadata); + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + session, + attempt: { + attemptNumber, + validatePath: attemptPaths.validatePath, + assessmentPath: attemptPaths.assessmentPath, + metadataPath: attemptPaths.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 runCaptureGenerationPreviewCommand( + options: CaptureGenerationPreviewCommandOptions, +): Promise { + try { + if (!options.sessionDir) { + throw new SessionInputError("--session-dir is required."); + } + if (!options.url) { + throw new SessionInputError("--url is required."); + } + const attemptNumber = typeof options.attemptNumber === "number" + ? options.attemptNumber + : Number.parseInt(String(options.attemptNumber ?? ""), 10); + if (!Number.isInteger(attemptNumber) || attemptNumber < 1) { + throw new SessionInputError("--attempt must be a positive integer."); + } + + const { session, paths } = loadSession(options.sessionDir); + const attemptPaths = getAttemptPaths(paths.attemptsDir, attemptNumber); + if (!fs.existsSync(attemptPaths.metadataPath) || !fs.existsSync(attemptPaths.validatePath)) { + throw new SessionInputError(`Attempt ${attemptNumber} not found in ${paths.attemptsDir}.`); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(options.url); + } catch { + throw new SessionInputError(`Preview URL must be an absolute URL: ${options.url}.`); + } + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new SessionInputError(`Preview URL must use http or https: ${options.url}.`); + } + + const viewport = { width: 1440, height: 1024 }; + const browser = await chromium.launch({ + headless: + process.env.INTERFACECTL_PLAYWRIGHT_HEADLESS !== "0" && + process.env.INTERFACECTL_PLAYWRIGHT_HEADLESS !== "false", + }).catch((error) => { + throw toBrowserLaunchError(error); + }); + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + + try { + await page.goto(parsedUrl.toString(), { waitUntil: "load", timeout: 15_000 }); + await waitForPageSettle(page); + const waitFor = asString(options.waitFor); + if (waitFor) { + await waitForPreviewCondition(page, waitFor); + await waitForPageSettle(page); + } + await page.screenshot({ path: attemptPaths.previewImagePath, fullPage: true, type: "png" }); + const preview: GenerationAttemptPreview = { + schemaVersion: 1, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + attemptNumber, + url: parsedUrl.toString(), + finalUrl: page.url(), + imagePath: attemptPaths.previewImagePath, + capturedAt: new Date().toISOString(), + viewport, + ...(asString(await page.title()) ? { pageTitle: asString(await page.title()) } : {}), + ...(waitFor ? { waitFor } : {}), + }; + writeDeterministicJsonSync(attemptPaths.previewMetadataPath, preview); + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + preview, + paths: { + metadataPath: attemptPaths.previewMetadataPath, + imagePath: attemptPaths.previewImagePath, + }, + }, + null, + 2, + )}\n`, + ); + return 0; + } catch (error) { + if (error instanceof SessionInputError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new SessionInputError(`Failed to capture preview for attempt ${attemptNumber}: ${message}.`); + } finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } + } 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 runReviewGenerationAttemptCommand( + options: ReviewGenerationAttemptCommandOptions, +): Promise { + try { + if (!options.sessionDir) { + throw new SessionInputError("--session-dir is required."); + } + if (!options.reviewFile) { + throw new SessionInputError("--review-file is required."); + } + const attemptNumber = typeof options.attemptNumber === "number" + ? options.attemptNumber + : Number.parseInt(String(options.attemptNumber ?? ""), 10); + if (!Number.isInteger(attemptNumber) || attemptNumber < 1) { + throw new SessionInputError("--attempt must be a positive integer."); + } + + const { session, paths } = loadSession(options.sessionDir); + const attemptPaths = getAttemptPaths(paths.attemptsDir, attemptNumber); + if (!fs.existsSync(attemptPaths.validatePath)) { + throw new SessionInputError(`Attempt ${attemptNumber} validate payload not found at ${attemptPaths.validatePath}.`); + } + const validatePayload = readJsonFile(attemptPaths.validatePath, `attempt ${attemptNumber} validate payload`); + if (validatePayload.status !== "warn") { + throw new SessionInputError(`Attempt ${attemptNumber} is ${String(validatePayload.status)}; only warn attempts can be reviewed.`); + } + const findingCodes = parseFindingCodes(validatePayload); + if (findingCodes.length === 0) { + throw new SessionInputError(`Attempt ${attemptNumber} does not have remaining finding codes to review.`); + } + + const reviewInputPath = path.resolve(options.reviewFile); + if (!fs.existsSync(reviewInputPath)) { + throw new SessionInputError(`Review file not found at ${reviewInputPath}.`); + } + const reviewInput = normalizeReviewInput( + readJsonFile(reviewInputPath, "generation attempt review input"), + reviewInputPath, + findingCodes, + ); + + const review: GenerationAttemptReview = { + schemaVersion: 1, + surfaceId: session.surfaceId, + sessionId: session.sessionId, + attemptNumber, + status: reviewInput.status, + findingCodes: reviewInput.findingCodes, + rationale: reviewInput.rationale, + reviewedAt: new Date().toISOString(), + }; + writeDeterministicJsonSync(attemptPaths.reviewPath, review); + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + review, + reviewPath: attemptPaths.reviewPath, + }, + 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 { paths, summary } = buildGenerationSessionSummary(options.sessionDir); + writeDeterministicJsonSync(paths.summaryJsonPath, summary); + fs.writeFileSync(paths.summaryMarkdownPath, renderSummaryMarkdown(summary), "utf8"); + process.stdout.write(`${JSON.stringify({ ok: true, summary, paths }, null, 2)}\n`); + return summary.latestOutcome === "pass" || summary.latestOutcome === "accepted-warn" ? 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; + } +} + +export async function runCompareGenerationSessionsCommand( + options: CompareGenerationSessionsCommandOptions, +): Promise { + try { + if (!options.baselineSessionDir) { + throw new SessionInputError("--baseline-session-dir is required."); + } + if (!options.guidedSessionDir) { + throw new SessionInputError("--guided-session-dir is required."); + } + + const comparison = buildComparisonArtifact(options.baselineSessionDir, options.guidedSessionDir); + const baseline = loadSession(options.baselineSessionDir).session; + const guided = loadSession(options.guidedSessionDir).session; + const outDir = options.outDir + ? path.resolve(options.outDir) + : defaultComparisonDir(baseline, guided); + const jsonPath = path.join(outDir, "comparison.json"); + const markdownPath = path.join(outDir, "comparison.md"); + writeDeterministicJsonSync(jsonPath, comparison); + fs.mkdirSync(path.dirname(markdownPath), { recursive: true }); + fs.writeFileSync(markdownPath, renderComparisonMarkdown(comparison), "utf8"); + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + comparison, + paths: { + jsonPath, + markdownPath, + }, + }, + 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 runSuggestContractDeltasCommand( + options: SuggestContractDeltasCommandOptions, +): Promise { + try { + if (!options.sessionDir) { + throw new SessionInputError("--session-dir is required."); + } + const artifact = buildSuggestionArtifact(options.sessionDir); + const sessionPaths = getSessionPaths(path.resolve(options.sessionDir)); + const outPath = options.outPath + ? path.resolve(options.outPath) + : sessionPaths.suggestionsJsonPath; + const markdownPath = outPath.endsWith(".json") + ? `${outPath.slice(0, -5)}.md` + : `${outPath}.md`; + writeDeterministicJsonSync(outPath, artifact); + fs.mkdirSync(path.dirname(markdownPath), { recursive: true }); + fs.writeFileSync(markdownPath, renderSuggestionsMarkdown(artifact), "utf8"); + process.stdout.write( + `${JSON.stringify( + { + ok: true, + artifact, + paths: { + jsonPath: outPath, + markdownPath, + }, + }, + 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 runReviewContractDeltaSuggestionsCommand( + options: ReviewContractDeltaSuggestionsCommandOptions, +): Promise { + try { + if (!options.suggestionsPath) { + throw new SessionInputError("--suggestions is required."); + } + if (!options.reviewFile) { + throw new SessionInputError("--review-file is required."); + } + const suggestionsPath = path.resolve(options.suggestionsPath); + if (!fs.existsSync(suggestionsPath)) { + throw new SessionInputError(`Suggestions file not found at ${suggestionsPath}.`); + } + const artifact = readJsonFile( + suggestionsPath, + "contract delta suggestions artifact", + ) as unknown as ContractDeltaSuggestionsArtifact; + const reviewFile = path.resolve(options.reviewFile); + if (!fs.existsSync(reviewFile)) { + throw new SessionInputError(`Review file not found at ${reviewFile}.`); + } + const decisions = normalizeSuggestionReviewFile(reviewFile); + const decisionMap = new Map(decisions.map((entry) => [entry.suggestionId, entry] as const)); + const knownIds = new Set(artifact.suggestions.map((suggestion) => suggestion.suggestionId)); + for (const suggestionId of decisionMap.keys()) { + if (!knownIds.has(suggestionId)) { + throw new SessionInputError(`Unknown suggestionId "${suggestionId}" in ${reviewFile}.`); + } + } + + const updatedArtifact: ContractDeltaSuggestionsArtifact = { + ...artifact, + suggestions: artifact.suggestions.map((suggestion) => { + const decision = decisionMap.get(suggestion.suggestionId); + if (!decision) { + return suggestion; + } + return { + ...suggestion, + status: decision.status, + decision: { + rationale: decision.rationale, + decidedAt: new Date().toISOString(), + }, + }; + }), + }; + + const outPath = options.outPath ? path.resolve(options.outPath) : suggestionsPath; + const markdownPath = outPath.endsWith(".json") + ? `${outPath.slice(0, -5)}.md` + : `${outPath}.md`; + writeDeterministicJsonSync(outPath, updatedArtifact); + fs.mkdirSync(path.dirname(markdownPath), { recursive: true }); + fs.writeFileSync(markdownPath, renderSuggestionsMarkdown(updatedArtifact), "utf8"); + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + artifact: updatedArtifact, + paths: { + jsonPath: outPath, + markdownPath, + }, + }, + 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 runSummarizeGenerationBenchmarkCommand( + options: SummarizeGenerationBenchmarkCommandOptions, +): Promise { + try { + const comparisonPaths = parseCsvPaths(options.comparisonPaths); + if (comparisonPaths.length === 0) { + throw new SessionInputError("--comparisons must include at least one comparison artifact path."); + } + const suggestionPaths = parseCsvPaths(options.suggestionPaths); + const comparisons = comparisonPaths.map((comparisonPath) => ({ + path: comparisonPath, + value: readJsonFile(comparisonPath, "generation session comparison") as unknown as GenerationSessionComparison, + })); + const suggestions = suggestionPaths.map((suggestionPath) => ({ + path: suggestionPath, + value: readJsonFile(suggestionPath, "contract delta suggestions artifact") as unknown as ContractDeltaSuggestionsArtifact, + })); + + const report: GenerationBenchmarkReport = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + comparisons: comparisons.map(({ path: comparisonPath, value }) => ({ + surfaceId: value.surfaceId, + tool: value.tool, + comparisonPath, + meetsGoal: value.checks.meetsGoal, + guidedFewerFirstAttemptBlockingFindings: value.checks.guidedFewerFirstAttemptBlockingFindings, + guidedReachedAcceptableNoLater: value.checks.guidedReachedAcceptableNoLater, + guidedRubricBetterDimensions: value.checks.guidedRubricBetterDimensions, + })), + suggestions: suggestions.map(({ path: suggestionsPath, value }) => ({ + surfaceId: value.surfaceId, + sessionId: value.sessionId, + suggestionsPath, + proposedCount: value.suggestions.filter((entry) => entry.status === "proposed").length, + acceptedCount: value.suggestions.filter((entry) => entry.status === "accepted").length, + rejectedCount: value.suggestions.filter((entry) => entry.status === "rejected").length, + })), + overall: { + surfaceCount: comparisons.length, + surfacesMeetingGoal: comparisons.filter(({ value }) => value.checks.meetsGoal).length, + guidedFewerFirstAttemptBlockingFindings: comparisons.filter( + ({ value }) => value.checks.guidedFewerFirstAttemptBlockingFindings, + ).length, + guidedReachedAcceptableNoLater: comparisons.filter( + ({ value }) => value.checks.guidedReachedAcceptableNoLater, + ).length, + acceptedSuggestionCount: suggestions.reduce( + (total, entry) => total + entry.value.suggestions.filter((suggestion) => suggestion.status === "accepted").length, + 0, + ), + rejectedSuggestionCount: suggestions.reduce( + (total, entry) => total + entry.value.suggestions.filter((suggestion) => suggestion.status === "rejected").length, + 0, + ), + proposedSuggestionCount: suggestions.reduce( + (total, entry) => total + entry.value.suggestions.filter((suggestion) => suggestion.status === "proposed").length, + 0, + ), + }, + }; + + const outDir = options.outDir + ? path.resolve(options.outDir) + : defaultBenchmarkReportDir(comparisonPaths); + const jsonPath = path.join(outDir, "benchmark-report.json"); + const markdownPath = path.join(outDir, "benchmark-report.md"); + writeDeterministicJsonSync(jsonPath, report); + fs.mkdirSync(path.dirname(markdownPath), { recursive: true }); + fs.writeFileSync(markdownPath, renderBenchmarkReportMarkdown(report), "utf8"); + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + report, + paths: { + jsonPath, + markdownPath, + }, + }, + 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; + } +} 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..8a0940b --- /dev/null +++ b/packages/interfacectl-cli/src/commands/prepare-generation.ts @@ -0,0 +1,263 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + AdapterInputError, + isRecord, + loadCompiledSurfaceBundle, + type JsonRecord, + type LoadedCompiledSurfaceBundle, +} from "../adapter/bundle.js"; +import { stringifyDeterministicJson } from "../utils/deterministic-json.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 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), + }; +} + +export 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 : [], + }; +} + +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( + { + 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 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 }); + 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..e0a704e 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -10,6 +10,21 @@ 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 { runEmitRunArtifactCommand } from "./commands/emit-run-artifact.js"; +import { + runCaptureGenerationPreviewCommand, + runCompareGenerationSessionsCommand, + runInitGenerationSessionCommand, + runRecordGenerationAttemptCommand, + runReviewContractDeltaSuggestionsCommand, + runReviewGenerationAttemptCommand, + runSuggestContractDeltasCommand, + runSummarizeGenerationSessionCommand, + runSummarizeGenerationBenchmarkCommand, +} from "./commands/generation-session.js"; import { runInitCommand } from "./commands/init.js"; import { runAnalyzeCommand } from "./commands/analyze.js"; import { @@ -285,6 +300,228 @@ 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("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("--guidance-mode ", "Session guidance mode (prepared|unguided)") + .option("--brief-file ", "Optional implementation brief file to freeze into the session") + .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, + guidanceMode: options.guidanceMode, + briefFile: options.briefFile, + 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("capture-generation-preview") + .description("Capture a visual preview for one recorded generation attempt") + .requiredOption("--session-dir ", "Path to the generation session directory") + .requiredOption("--attempt ", "Attempt number to capture") + .requiredOption("--url ", "Absolute preview URL to capture") + .option("--wait-for ", "Optional text or selector to wait for before capturing") + .action(async (options) => { + process.exitCode = await runCaptureGenerationPreviewCommand({ + sessionDir: options.sessionDir, + attemptNumber: options.attempt, + url: options.url, + waitFor: options.waitFor, + }); + }); + +program + .command("review-generation-attempt") + .description("Review the remaining findings for one warn attempt in a tracked generation session") + .requiredOption("--session-dir ", "Path to the generation session directory") + .requiredOption("--attempt ", "Attempt number to review") + .requiredOption("--review-file ", "Path to the review JSON file") + .action(async (options) => { + process.exitCode = await runReviewGenerationAttemptCommand({ + sessionDir: options.sessionDir, + attemptNumber: options.attempt, + reviewFile: options.reviewFile, + }); + }); + +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("compare-generation-sessions") + .description("Compare one unguided session against one prepared guided session") + .requiredOption("--baseline-session-dir ", "Path to the unguided baseline session directory") + .requiredOption("--guided-session-dir ", "Path to the prepared guided session directory") + .option("--out-dir ", "Output directory for comparison artifacts") + .action(async (options) => { + process.exitCode = await runCompareGenerationSessionsCommand({ + baselineSessionDir: options.baselineSessionDir, + guidedSessionDir: options.guidedSessionDir, + outDir: options.outDir, + }); + }); + +program + .command("suggest-contract-deltas") + .description("Generate evidence-backed contract refinement suggestions from one guided session") + .requiredOption("--session-dir ", "Path to the guided generation session directory") + .option("--out ", "Write suggestion JSON to the provided file") + .action(async (options) => { + process.exitCode = await runSuggestContractDeltasCommand({ + sessionDir: options.sessionDir, + outPath: options.out, + }); + }); + +program + .command("review-contract-delta-suggestions") + .description("Apply human accept/reject decisions to contract delta suggestions without mutating the contract") + .requiredOption("--suggestions ", "Path to the contract delta suggestions JSON file") + .requiredOption("--review-file ", "Path to the review decisions JSON file") + .option("--out ", "Write the updated suggestions JSON to the provided file") + .action(async (options) => { + process.exitCode = await runReviewContractDeltaSuggestionsCommand({ + suggestionsPath: options.suggestions, + reviewFile: options.reviewFile, + outPath: options.out, + }); + }); + +program + .command("summarize-generation-benchmark") + .description("Aggregate one or more comparison and suggestion artifacts into a benchmark report") + .requiredOption("--comparisons ", "Comma-separated generation session comparison JSON paths") + .option("--suggestions ", "Comma-separated contract delta suggestion JSON paths") + .option("--out-dir ", "Output directory for the benchmark report") + .action(async (options) => { + process.exitCode = await runSummarizeGenerationBenchmarkCommand({ + comparisonPaths: options.comparisons, + suggestionPaths: options.suggestions, + outDir: options.outDir, + }); + }); + +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("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/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/generation-benchmark.test.mjs b/packages/interfacectl-cli/test/generation-benchmark.test.mjs new file mode 100644 index 0000000..7e408c2 --- /dev/null +++ b/packages/interfacectl-cli/test/generation-benchmark.test.mjs @@ -0,0 +1,569 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import os from "node:os"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { validateDiffOutput } from "@surfaces/interfacectl-validator"; +import generationSessionComparisonSchema from "../schemas/generation-session-comparison.schema.json" with { type: "json" }; +import contractDeltaSuggestionsSchema from "../schemas/contract-delta-suggestions.schema.json" with { type: "json" }; +import generationBenchmarkReportSchema from "../schemas/generation-benchmark-report.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-benchmark-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, { sectionValid, colorValid }) { + 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: ${colorValid ? "#111111" : "#ff00ff"}; + background: #ffffff; +} + +[data-contract="page-container"] { + max-width: 960px; +} + +[data-contract-section="main.hero"] { + transition: opacity 120ms linear; +} +`, + ); + + const sectionAttribute = sectionValid ? ' data-contract-section="main.hero"' : ""; + const containerAttribute = sectionValid ? ' data-contract-container="page-container"' : ""; + await writeText( + path.join(workspaceRoot, "apps", "demo-surface", "app", "page.tsx"), + `export default function Page() { + return ( +
+ +

Demo Surface

+ +
+ ); +} +`, + ); +} + +function buildAssessment({ + structure, + components, + boundary, + visual, + responsiveness, + notes, +}) { + return { + structure, + components, + boundary, + visual, + responsiveness, + notes, + }; +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +let chromiumAvailability; + +async function ensureChromiumAvailable(t) { + if (chromiumAvailability === undefined) { + chromiumAvailability = await new Promise((resolve) => { + const child = spawn( + "node", + [ + "-e", + "import('playwright').then(async ({ chromium }) => { const browser = await chromium.launch({ headless: true }); await browser.close(); }).then(() => process.exit(0)).catch(() => process.exit(1));", + ], + { + cwd: path.resolve(__dirname, ".."), + env: process.env, + }, + ); + child.on("exit", (code) => resolve(code === 0)); + }); + } + if (!chromiumAvailability) { + t.skip("Playwright Chromium is not installed."); + } +} + +async function withServer(handler, callback) { + const server = http.createServer(handler); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + const origin = `http://127.0.0.1:${address.port}`; + try { + return await callback(origin); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +test("guided vs unguided benchmark artifacts compare sessions, emit deterministic suggestions, track reviewed decisions, and carry explicit preview refs", async (t) => { + await ensureChromiumAvailable(t); + + const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-benchmark-")); + const workspaceRoot = path.join(tempRoot, "workspace"); + const bundleRoot = path.join(tempRoot, "bundle"); + const baselineSessionDir = path.join(workspaceRoot, "artifacts", "generation-sessions", "demo-surface", "baseline-unguided"); + const guidedSessionDir = path.join(workspaceRoot, "artifacts", "generation-sessions", "demo-surface", "guided-prepared"); + const briefPath = path.join(tempRoot, "task-brief.md"); + + try { + await writeDemoWorkspace(workspaceRoot, { sectionValid: false, colorValid: true }); + await writeText( + briefPath, + "# Demo surface brief\nImplement the hero section for the demo surface using the existing page frame.\n", + ); + + const compileResult = await runCli( + [ + "compile", + "--contract", + path.join(workspaceRoot, "contracts", "surfaces.web.contract.json"), + "--out", + bundleRoot, + ], + tempRoot, + ); + assert.equal(compileResult.exitCode, 0, compileResult.stderr); + + for (const [sessionId, guidanceMode] of [ + ["baseline-unguided", "unguided"], + ["guided-prepared", "prepared"], + ]) { + const initResult = await runCli( + [ + "init-generation-session", + "--bundle-root", + bundleRoot, + "--surface", + "demo-surface", + "--workspace-root", + workspaceRoot, + "--session", + sessionId, + "--guidance-mode", + guidanceMode, + "--brief-file", + briefPath, + ], + tempRoot, + ); + assert.equal(initResult.exitCode, 0, initResult.stderr); + } + + const baselineAssessmentOne = path.join(tempRoot, "baseline-1.json"); + await writeJson( + baselineAssessmentOne, + buildAssessment({ + structure: "weak", + components: "weak", + boundary: "weak", + visual: "partial", + responsiveness: "weak", + notes: "Unguided baseline missed the contract markers on the first attempt.", + }), + ); + const baselineAttemptOne = await runCli( + [ + "record-generation-attempt", + "--session-dir", + baselineSessionDir, + "--assessment-file", + baselineAssessmentOne, + ], + tempRoot, + ); + assert.equal(baselineAttemptOne.exitCode, 0, baselineAttemptOne.stderr); + await withServer((request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end("

Baseline First

ready

"); + }, async (origin) => { + const previewResult = await runCli( + [ + "capture-generation-preview", + "--session-dir", + baselineSessionDir, + "--attempt", + "1", + "--url", + `${origin}/baseline-first`, + "--wait-for", + "ready", + ], + tempRoot, + ); + assert.equal(previewResult.exitCode, 0, previewResult.stderr); + }); + + await writeDemoWorkspace(workspaceRoot, { sectionValid: true, colorValid: false }); + const baselineAssessmentTwo = path.join(tempRoot, "baseline-2.json"); + await writeJson( + baselineAssessmentTwo, + buildAssessment({ + structure: "partial", + components: "partial", + boundary: "partial", + visual: "partial", + responsiveness: "partial", + notes: "Baseline corrected the structure but still drifted on color.", + }), + ); + const baselineAttemptTwo = await runCli( + [ + "record-generation-attempt", + "--session-dir", + baselineSessionDir, + "--assessment-file", + baselineAssessmentTwo, + ], + tempRoot, + ); + assert.equal(baselineAttemptTwo.exitCode, 0, baselineAttemptTwo.stderr); + await withServer((request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end("

Baseline Latest

ready

"); + }, async (origin) => { + const previewResult = await runCli( + [ + "capture-generation-preview", + "--session-dir", + baselineSessionDir, + "--attempt", + "2", + "--url", + `${origin}/baseline-latest`, + "--wait-for", + "ready", + ], + tempRoot, + ); + assert.equal(previewResult.exitCode, 0, previewResult.stderr); + }); + + const baselineWarnPayload = JSON.parse( + await fsp.readFile(path.join(baselineSessionDir, "attempts", "002.validate.json"), "utf8"), + ); + const baselineReviewFile = path.join(tempRoot, "baseline-review.json"); + await writeJson(baselineReviewFile, { + status: "accepted", + findingCodes: baselineWarnPayload.findings.map((entry) => entry.code).sort(), + rationale: "The benchmark accepts the remaining baseline warning after review.", + }); + const baselineReview = await runCli( + [ + "review-generation-attempt", + "--session-dir", + baselineSessionDir, + "--attempt", + "2", + "--review-file", + baselineReviewFile, + ], + tempRoot, + ); + assert.equal(baselineReview.exitCode, 0, baselineReview.stderr); + + const baselineSummary = await runCli( + ["summarize-generation-session", "--session-dir", baselineSessionDir], + tempRoot, + ); + assert.equal(baselineSummary.exitCode, 0, baselineSummary.stderr); + + const guidedAssessmentOne = path.join(tempRoot, "guided-1.json"); + await writeJson( + guidedAssessmentOne, + buildAssessment({ + structure: "strong", + components: "strong", + boundary: "strong", + visual: "partial", + responsiveness: "strong", + notes: "Guided attempt matched the structure but still carried a color warning.", + }), + ); + const guidedAttemptOne = await runCli( + [ + "record-generation-attempt", + "--session-dir", + guidedSessionDir, + "--assessment-file", + guidedAssessmentOne, + ], + tempRoot, + ); + assert.equal(guidedAttemptOne.exitCode, 0, guidedAttemptOne.stderr); + await withServer((request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end("

Guided First

ready

"); + }, async (origin) => { + const previewResult = await runCli( + [ + "capture-generation-preview", + "--session-dir", + guidedSessionDir, + "--attempt", + "1", + "--url", + `${origin}/guided-first`, + "--wait-for", + "ready", + ], + tempRoot, + ); + assert.equal(previewResult.exitCode, 0, previewResult.stderr); + }); + + const guidedWarnPayload = JSON.parse( + await fsp.readFile(path.join(guidedSessionDir, "attempts", "001.validate.json"), "utf8"), + ); + assert.equal(guidedWarnPayload.status, "warn"); + const guidedReviewFile = path.join(tempRoot, "guided-review.json"); + await writeJson(guidedReviewFile, { + status: "accepted", + findingCodes: guidedWarnPayload.findings.map((entry) => entry.code).sort(), + rationale: "The remaining guided warning is acceptable for this benchmark.", + }); + const guidedReview = await runCli( + [ + "review-generation-attempt", + "--session-dir", + guidedSessionDir, + "--attempt", + "1", + "--review-file", + guidedReviewFile, + ], + tempRoot, + ); + assert.equal(guidedReview.exitCode, 0, guidedReview.stderr); + + const guidedSummary = await runCli( + ["summarize-generation-session", "--session-dir", guidedSessionDir], + tempRoot, + ); + assert.equal(guidedSummary.exitCode, 0, guidedSummary.stderr); + + const compareResult = await runCli( + [ + "compare-generation-sessions", + "--baseline-session-dir", + baselineSessionDir, + "--guided-session-dir", + guidedSessionDir, + ], + tempRoot, + ); + assert.equal(compareResult.exitCode, 0, compareResult.stderr); + const compareOutput = JSON.parse(compareResult.stdout); + const comparison = JSON.parse(await fsp.readFile(compareOutput.paths.jsonPath, "utf8")); + validateWithSchema(comparison, generationSessionComparisonSchema, "generation session comparison"); + assert.equal(Boolean(comparison.baseline.firstAttempt.preview), true); + assert.equal(Boolean(comparison.baseline.latestAttempt.preview), true); + assert.equal(Boolean(comparison.guided.firstAttempt.preview), true); + assert.equal(comparison.baseline.firstAttempt.preview.metadataPath.endsWith(".preview.json"), true); + assert.equal(comparison.guided.latestAttempt.preview.imagePath.endsWith(".preview.png"), true); + assert.equal(comparison.checks.meetsGoal, true); + assert.equal(comparison.delta.firstAttemptBlockingFindingCountDelta < 0, true); + assert.equal(comparison.delta.attemptsToAcceptableOutcome.baseline, 2); + assert.equal(comparison.delta.attemptsToAcceptableOutcome.guided, 1); + + const contractPath = path.join(workspaceRoot, "contracts", "generated", "demo-surface.contract.json"); + const contractBeforeReview = await fsp.readFile(contractPath); + + const suggestionOutOne = path.join(tempRoot, "suggestions-1.json"); + const suggestionResultOne = await runCli( + [ + "suggest-contract-deltas", + "--session-dir", + guidedSessionDir, + "--out", + suggestionOutOne, + ], + tempRoot, + ); + assert.equal(suggestionResultOne.exitCode, 0, suggestionResultOne.stderr); + const suggestionsOne = JSON.parse(await fsp.readFile(suggestionOutOne, "utf8")); + validateWithSchema(suggestionsOne, contractDeltaSuggestionsSchema, "contract delta suggestions"); + assert.equal(suggestionsOne.suggestions.length > 0, true); + + const suggestionOutTwo = path.join(tempRoot, "suggestions-2.json"); + const suggestionResultTwo = await runCli( + [ + "suggest-contract-deltas", + "--session-dir", + guidedSessionDir, + "--out", + suggestionOutTwo, + ], + tempRoot, + ); + assert.equal(suggestionResultTwo.exitCode, 0, suggestionResultTwo.stderr); + const suggestionsOneBytes = await fsp.readFile(suggestionOutOne, "utf8"); + const suggestionsTwoBytes = await fsp.readFile(suggestionOutTwo, "utf8"); + assert.equal(sha256(suggestionsOneBytes), sha256(suggestionsTwoBytes)); + + const decisionsPath = path.join(tempRoot, "suggestion-decisions.json"); + await writeJson(decisionsPath, { + decisions: [ + { + suggestionId: suggestionsOne.suggestions[0].suggestionId, + status: "accepted", + rationale: "We want to review this contract path next.", + }, + ], + }); + const reviewedSuggestionsPath = path.join(tempRoot, "suggestions-reviewed.json"); + const reviewSuggestions = await runCli( + [ + "review-contract-delta-suggestions", + "--suggestions", + suggestionOutOne, + "--review-file", + decisionsPath, + "--out", + reviewedSuggestionsPath, + ], + tempRoot, + ); + assert.equal(reviewSuggestions.exitCode, 0, reviewSuggestions.stderr); + const reviewedSuggestions = JSON.parse(await fsp.readFile(reviewedSuggestionsPath, "utf8")); + validateWithSchema(reviewedSuggestions, contractDeltaSuggestionsSchema, "reviewed contract delta suggestions"); + assert.equal(reviewedSuggestions.suggestions[0].status, "accepted"); + assert.equal(reviewedSuggestions.suggestions[0].decision.rationale, "We want to review this contract path next."); + + const contractAfterReview = await fsp.readFile(contractPath); + assert.equal(sha256(contractBeforeReview), sha256(contractAfterReview)); + + const benchmarkOutDir = path.join(tempRoot, "benchmark-report"); + const benchmarkResult = await runCli( + [ + "summarize-generation-benchmark", + "--comparisons", + compareOutput.paths.jsonPath, + "--suggestions", + reviewedSuggestionsPath, + "--out-dir", + benchmarkOutDir, + ], + tempRoot, + ); + assert.equal(benchmarkResult.exitCode, 0, benchmarkResult.stderr); + const benchmarkOutput = JSON.parse(benchmarkResult.stdout); + const benchmarkReport = JSON.parse(await fsp.readFile(benchmarkOutput.paths.jsonPath, "utf8")); + validateWithSchema(benchmarkReport, generationBenchmarkReportSchema, "generation benchmark report"); + assert.equal(benchmarkReport.overall.surfaceCount, 1); + assert.equal(benchmarkReport.overall.surfacesMeetingGoal, 1); + assert.equal(benchmarkReport.overall.acceptedSuggestionCount, 1); + } finally { + await fsp.rm(tempRoot, { recursive: true, force: true }); + } +}); 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..e3c8bb7 --- /dev/null +++ b/packages/interfacectl-cli/test/generation-session.test.mjs @@ -0,0 +1,981 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +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 generationAttemptReviewSchema from "../schemas/generation-attempt-review.schema.json" with { type: "json" }; +import generationAttemptPreviewSchema from "../schemas/generation-attempt-preview.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, { sectionValid, colorValid }) { + 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: ${colorValid ? "#111111" : "#ff00ff"}; + background: #ffffff; +} + +[data-contract="page-container"] { + max-width: 960px; +} + +[data-contract-section="main.hero"] { + transition: opacity 120ms linear; +} +`, + ); + + const sectionAttribute = sectionValid ? ' data-contract-section="main.hero"' : ""; + const containerAttribute = sectionValid ? ' data-contract-container="page-container"' : ""; + await writeText( + path.join(workspaceRoot, "apps", "demo-surface", "app", "page.tsx"), + `export default function Page() { + return ( +
+ +

Demo Surface

+ +
+ ); +} +`, + ); +} + +function buildAssessment({ + structure, + components, + boundary, + visual, + responsiveness, + notes, + touchedFiles, +}) { + return { + structure, + components, + boundary, + visual, + responsiveness, + notes, + ...(touchedFiles ? { touchedFiles } : {}), + }; +} + +let chromiumAvailability; + +async function ensureChromiumAvailable(t) { + if (chromiumAvailability === undefined) { + chromiumAvailability = await new Promise((resolve) => { + const child = spawn( + "node", + [ + "-e", + "import('playwright').then(async ({ chromium }) => { const browser = await chromium.launch({ headless: true }); await browser.close(); }).then(() => process.exit(0)).catch(() => process.exit(1));", + ], + { + cwd: path.resolve(__dirname, ".."), + env: process.env, + }, + ); + child.on("exit", (code) => resolve(code === 0)); + }); + } + if (!chromiumAvailability) { + t.skip("Playwright Chromium is not installed."); + } +} + +async function withServer(handler, callback) { + const server = http.createServer(handler); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + const origin = `http://127.0.0.1:${address.port}`; + try { + return await callback(origin); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +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, { sectionValid: false, colorValid: 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 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.equal(session.guidanceMode, "prepared"); + 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, + buildAssessment({ + structure: "weak", + components: "weak", + boundary: "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, { sectionValid: true, colorValid: true }); + const assessmentTwoPath = path.join(tempRoot, "assessment-2.json"); + await writeJson( + assessmentTwoPath, + buildAssessment({ + structure: "strong", + components: "strong", + boundary: "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.firstAcceptableAttempt, 2); + assert.equal(summary.latestStatus, "pass"); + assert.equal(summary.latestOutcome, "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("review-generation-attempt marks reviewed warnings acceptable without changing the validate payload", async () => { + const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-review-")); + const workspaceRoot = path.join(tempRoot, "workspace"); + const bundleRoot = path.join(tempRoot, "bundle"); + const sessionDir = path.join(workspaceRoot, "artifacts", "generation-sessions", "demo-surface", "warn-session"); + + try { + await writeDemoWorkspace(workspaceRoot, { sectionValid: true, colorValid: 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, + "--session", + "warn-session", + ], + tempRoot, + ); + assert.equal(initResult.exitCode, 0, initResult.stderr); + + const assessmentPath = path.join(tempRoot, "warn-assessment.json"); + await writeJson( + assessmentPath, + buildAssessment({ + structure: "strong", + components: "strong", + boundary: "strong", + visual: "partial", + responsiveness: "strong", + notes: "Everything matches except the text color.", + }), + ); + + const recordResult = await runCli( + [ + "record-generation-attempt", + "--session-dir", + sessionDir, + "--assessment-file", + assessmentPath, + ], + tempRoot, + ); + assert.equal(recordResult.exitCode, 0, recordResult.stderr); + + const validatePayload = JSON.parse( + await fsp.readFile(path.join(sessionDir, "attempts", "001.validate.json"), "utf8"), + ); + assert.equal(validatePayload.status, "warn"); + const findingCodes = validatePayload.findings.map((entry) => entry.code).sort(); + + const reviewFile = path.join(tempRoot, "warn-review.json"); + await writeJson(reviewFile, { + status: "accepted", + findingCodes, + rationale: "The remaining color warning is acceptable for this benchmark attempt.", + }); + + const reviewResult = await runCli( + [ + "review-generation-attempt", + "--session-dir", + sessionDir, + "--attempt", + "1", + "--review-file", + reviewFile, + ], + tempRoot, + ); + assert.equal(reviewResult.exitCode, 0, reviewResult.stderr); + + const reviewPayload = JSON.parse( + await fsp.readFile(path.join(sessionDir, "attempts", "001.review.json"), "utf8"), + ); + validateWithSchema(reviewPayload, generationAttemptReviewSchema, "generation attempt review"); + assert.equal(reviewPayload.status, "accepted"); + + const summaryResult = await runCli( + ["summarize-generation-session", "--session-dir", sessionDir], + tempRoot, + ); + assert.equal(summaryResult.exitCode, 0, summaryResult.stderr); + + const summary = JSON.parse( + await fsp.readFile(path.join(sessionDir, "summary.json"), "utf8"), + ); + validateWithSchema(summary, generationSessionSummarySchema, "generation session summary"); + assert.equal(summary.latestStatus, "warn"); + assert.equal(summary.latestOutcome, "accepted-warn"); + assert.equal(summary.firstAcceptableAttempt, 1); + assert.equal(summary.latestReview.status, "accepted"); + + const validatePayloadAfterReview = JSON.parse( + await fsp.readFile(path.join(sessionDir, "attempts", "001.validate.json"), "utf8"), + ); + assert.deepEqual(validatePayloadAfterReview, validatePayload); + } finally { + await fsp.rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("capture-generation-preview writes preview artifacts and surfaces explicit preview refs in the session summary", async (t) => { + await ensureChromiumAvailable(t); + + const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-preview-")); + const workspaceRoot = path.join(tempRoot, "workspace"); + const bundleRoot = path.join(tempRoot, "bundle"); + const sessionDir = path.join(workspaceRoot, "artifacts", "generation-sessions", "demo-surface", "preview-session"); + + try { + await writeDemoWorkspace(workspaceRoot, { sectionValid: true, colorValid: 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 initResult = await runCli( + [ + "init-generation-session", + "--bundle-root", + bundleRoot, + "--surface", + "demo-surface", + "--workspace-root", + workspaceRoot, + "--session", + "preview-session", + ], + tempRoot, + ); + assert.equal(initResult.exitCode, 0, initResult.stderr); + + const assessmentPath = path.join(tempRoot, "preview-assessment.json"); + await writeJson( + assessmentPath, + buildAssessment({ + structure: "strong", + components: "strong", + boundary: "strong", + visual: "strong", + responsiveness: "strong", + notes: "Preview-ready attempt.", + }), + ); + + const recordResult = await runCli( + [ + "record-generation-attempt", + "--session-dir", + sessionDir, + "--assessment-file", + assessmentPath, + ], + tempRoot, + ); + assert.equal(recordResult.exitCode, 0, recordResult.stderr); + + await withServer((request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(` + + Preview target + +
+

Demo Surface Preview

+

Benchmark ready

+
+ +`); + }, async (origin) => { + const captureResult = await runCli( + [ + "capture-generation-preview", + "--session-dir", + sessionDir, + "--attempt", + "1", + "--url", + `${origin}/preview`, + "--wait-for", + "Benchmark ready", + ], + tempRoot, + ); + assert.equal(captureResult.exitCode, 0, captureResult.stderr); + }); + + const preview = JSON.parse( + await fsp.readFile(path.join(sessionDir, "attempts", "001.preview.json"), "utf8"), + ); + validateWithSchema(preview, generationAttemptPreviewSchema, "generation attempt preview"); + assert.equal(fs.existsSync(path.join(sessionDir, "attempts", "001.preview.png")), true); + assert.equal(preview.pageTitle, "Preview target"); + assert.equal(preview.waitFor, "Benchmark ready"); + + const summaryResult = await runCli( + ["summarize-generation-session", "--session-dir", sessionDir], + tempRoot, + ); + assert.equal(summaryResult.exitCode, 0, summaryResult.stderr); + + const summary = JSON.parse( + await fsp.readFile(path.join(sessionDir, "summary.json"), "utf8"), + ); + validateWithSchema(summary, generationSessionSummarySchema, "generation session summary"); + assert.equal(summary.schemaVersion, 3); + assert.equal(summary.attempts[0].preview.imagePath, path.join(sessionDir, "attempts", "001.preview.png")); + assert.equal(summary.attempts[0].preview.metadataPath, path.join(sessionDir, "attempts", "001.preview.json")); + assert.equal(summary.attempts[0].preview.url.endsWith("/preview"), true); + } finally { + await fsp.rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("generation session commands reject invalid bundle roots, duplicate sessions, invalid assessments, invalid reviews, 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, { sectionValid: true, colorValid: 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", + components: "partial", + boundary: "weak", + 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/); + + await writeDemoWorkspace(workspaceRoot, { sectionValid: true, colorValid: false }); + const warnAssessmentPath = path.join(tempRoot, "assessment-warn.json"); + await writeJson( + warnAssessmentPath, + buildAssessment({ + structure: "strong", + components: "strong", + boundary: "strong", + visual: "partial", + responsiveness: "strong", + notes: "Warn attempt for invalid review coverage.", + }), + ); + const warnAttempt = await runCli( + [ + "record-generation-attempt", + "--session-dir", + sessionDir, + "--assessment-file", + warnAssessmentPath, + ], + tempRoot, + ); + assert.equal(warnAttempt.exitCode, 0, warnAttempt.stderr); + + const invalidReviewPath = path.join(tempRoot, "review-invalid.json"); + await writeJson(invalidReviewPath, { + status: "accepted", + findingCodes: ["missing.code"], + rationale: "This should fail because the finding code is wrong.", + }); + const invalidReview = await runCli( + [ + "review-generation-attempt", + "--session-dir", + sessionDir, + "--attempt", + "1", + "--review-file", + invalidReviewPath, + ], + tempRoot, + ); + assert.equal(invalidReview.exitCode, 10); + assert.match(invalidReview.stderr, /unknown finding code/); + + 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("capture-generation-preview rejects missing attempts, invalid URLs, and unsatisfied wait conditions", async (t) => { + const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "interfacectl-generation-preview-errors-")); + const workspaceRoot = path.join(tempRoot, "workspace"); + const bundleRoot = path.join(tempRoot, "bundle"); + const sessionDir = path.join(workspaceRoot, "artifacts", "generation-sessions", "demo-surface", "preview-errors"); + + try { + await writeDemoWorkspace(workspaceRoot, { sectionValid: true, colorValid: 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 initResult = await runCli( + [ + "init-generation-session", + "--bundle-root", + bundleRoot, + "--surface", + "demo-surface", + "--workspace-root", + workspaceRoot, + "--session", + "preview-errors", + ], + tempRoot, + ); + assert.equal(initResult.exitCode, 0, initResult.stderr); + + const assessmentPath = path.join(tempRoot, "preview-errors-assessment.json"); + await writeJson( + assessmentPath, + buildAssessment({ + structure: "strong", + components: "strong", + boundary: "strong", + visual: "strong", + responsiveness: "strong", + notes: "Recorded attempt for preview error cases.", + }), + ); + const recordResult = await runCli( + [ + "record-generation-attempt", + "--session-dir", + sessionDir, + "--assessment-file", + assessmentPath, + ], + tempRoot, + ); + assert.equal(recordResult.exitCode, 0, recordResult.stderr); + + const missingAttempt = await runCli( + [ + "capture-generation-preview", + "--session-dir", + sessionDir, + "--attempt", + "2", + "--url", + "https://example.com", + ], + tempRoot, + ); + assert.equal(missingAttempt.exitCode, 10); + assert.match(missingAttempt.stderr, /Attempt 2 not found/); + + const invalidUrl = await runCli( + [ + "capture-generation-preview", + "--session-dir", + sessionDir, + "--attempt", + "1", + "--url", + "not-a-url", + ], + tempRoot, + ); + assert.equal(invalidUrl.exitCode, 10); + assert.match(invalidUrl.stderr, /absolute URL/); + + await ensureChromiumAvailable(t); + await withServer((request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end("

Preview

"); + }, async (origin) => { + const waitFailure = await runCli( + [ + "capture-generation-preview", + "--session-dir", + sessionDir, + "--attempt", + "1", + "--url", + `${origin}/preview`, + "--wait-for", + "Never appears", + ], + tempRoot, + ); + assert.equal(waitFailure.exitCode, 10); + assert.match(waitFailure.stderr, /wait condition/i); + }); + } 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: 2, + surfaceId: "demo-surface", + sessionId: "summary-session", + tool: "codex", + guidanceMode: "prepared", + 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-or-reviewed-warn" }, + }); + 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"), + buildAssessment({ + structure: "partial", + components: "partial", + boundary: "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"), + buildAssessment({ + structure: "partial", + components: "partial", + boundary: "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/prepare-generation.test.mjs b/packages/interfacectl-cli/test/prepare-generation.test.mjs new file mode 100644 index 0000000..973ea15 --- /dev/null +++ b/packages/interfacectl-cli/test/prepare-generation.test.mjs @@ -0,0 +1,368 @@ +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 prepareGenerationOutputSchema from "../schemas/prepare-generation-output.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"); +} + +function validatePreparedGenerationOutput(payload) { + return validateDiffOutput(payload, prepareGenerationOutputSchema); +} + +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 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")); + 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")); + 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 }); + } +}); + +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 }); + } +}); 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 }); + } +}); 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. -

-
- - - - -
- - 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" -}