From b83ec779711613e39127a00e0d42052b39d84d9d Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sat, 28 Mar 2026 19:03:00 -0700 Subject: [PATCH] feat: make UI AST the canonical governed UI contract --- README.md | 20 +- .../plans/ui-ast-v2-cutover-pr-description.md | 58 ++ docs/plans/ui-ast-v2-cutover-rfc.md | 103 +++ .../interfacectl-cli/dist/adapter/bundle.d.ts | 5 +- .../dist/adapter/bundle.d.ts.map | 2 +- .../interfacectl-cli/dist/adapter/bundle.js | 52 +- .../dist/commands/compile.d.ts | 3 +- .../dist/commands/compile.d.ts.map | 2 +- .../interfacectl-cli/dist/commands/compile.js | 221 ++++-- .../interfacectl-cli/dist/commands/diff.d.ts | 1 + .../dist/commands/diff.d.ts.map | 2 +- .../interfacectl-cli/dist/commands/diff.js | 65 +- .../dist/commands/enforce.d.ts | 1 + .../dist/commands/enforce.d.ts.map | 2 +- .../interfacectl-cli/dist/commands/enforce.js | 1 + .../dist/commands/migrate-ui-ast.d.ts | 8 + .../dist/commands/migrate-ui-ast.d.ts.map | 1 + .../dist/commands/migrate-ui-ast.js | 49 ++ .../dist/commands/prepare-generation.d.ts | 82 +- .../dist/commands/prepare-generation.d.ts.map | 2 +- .../dist/commands/prepare-generation.js | 22 + .../dist/commands/prepare-runtime.d.ts | 62 +- .../dist/commands/prepare-runtime.d.ts.map | 2 +- .../dist/commands/prepare-runtime.js | 26 +- .../dist/commands/validate.d.ts | 1 + .../dist/commands/validate.d.ts.map | 2 +- .../dist/commands/validate.js | 101 +-- packages/interfacectl-cli/dist/index.js | 55 +- .../interfacectl-cli/dist/utils/ui-ast.d.ts | 25 + .../dist/utils/ui-ast.d.ts.map | 1 + .../interfacectl-cli/dist/utils/ui-ast.js | 350 +++++++++ .../prepare-generation-output.schema.json | 40 + .../prepare-runtime-output.schema.json | 31 + .../interfacectl-cli/src/adapter/bundle.ts | 60 +- .../interfacectl-cli/src/commands/compile.ts | 244 ++++-- .../interfacectl-cli/src/commands/diff.ts | 71 +- .../interfacectl-cli/src/commands/enforce.ts | 2 + .../src/commands/migrate-ui-ast.ts | 73 ++ .../src/commands/prepare-generation.ts | 22 + .../src/commands/prepare-runtime.ts | 26 +- .../interfacectl-cli/src/commands/validate.ts | 116 +-- packages/interfacectl-cli/src/index.ts | 64 +- packages/interfacectl-cli/src/utils/ui-ast.ts | 461 ++++++++++++ .../interfacectl-cli/test/compile.test.mjs | 151 +++- .../compile/expected/ast/normalized.json | 63 ++ .../contract.normalized.json} | 0 .../expected/surfaces/demo-surface/ast.json | 35 + .../surfaces/demo-surface/components.json | 4 +- .../surfaces/demo-surface/constraints.json | 4 +- .../surfaces/demo-surface/generation.json | 17 +- .../surfaces/demo-surface/platforms.json | 23 + .../surfaces/demo-surface/repair-map.json | 4 +- .../surfaces/demo-surface/runtime.json | 17 +- .../surfaces/demo-surface/sections.json | 4 +- .../test/generation-adapter.test.mjs | 8 +- .../test/migrate-ui-ast.test.mjs | 144 ++++ .../test/prepare-generation.test.mjs | 15 +- .../test/prepare-runtime.test.mjs | 7 + .../interfacectl-validator/dist/index.d.ts | 1 + .../dist/index.d.ts.map | 2 +- packages/interfacectl-validator/dist/index.js | 1 + .../dist/schema/ui.surface.ast.schema.json | 707 ++++++++++++++++++ .../interfacectl-validator/dist/ui-ast.d.ts | 93 +++ .../dist/ui-ast.d.ts.map | 1 + .../interfacectl-validator/dist/ui-ast.js | 132 ++++ .../scripts/copy-schema.mjs | 2 +- .../interfacectl-validator/src/index.d.ts | 3 +- packages/interfacectl-validator/src/index.ts | 20 + .../src/schema/ui.surface.ast.schema.json | 707 ++++++++++++++++++ packages/interfacectl-validator/src/ui-ast.ts | 317 ++++++++ .../test/ui-ast.test.mjs | 123 +++ 71 files changed, 4577 insertions(+), 565 deletions(-) create mode 100644 docs/plans/ui-ast-v2-cutover-pr-description.md create mode 100644 docs/plans/ui-ast-v2-cutover-rfc.md create mode 100644 packages/interfacectl-cli/dist/commands/migrate-ui-ast.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/migrate-ui-ast.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/migrate-ui-ast.js create mode 100644 packages/interfacectl-cli/dist/utils/ui-ast.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/ui-ast.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/ui-ast.js create mode 100644 packages/interfacectl-cli/src/commands/migrate-ui-ast.ts create mode 100644 packages/interfacectl-cli/src/utils/ui-ast.ts create mode 100644 packages/interfacectl-cli/test/fixtures/compile/expected/ast/normalized.json rename packages/interfacectl-cli/test/fixtures/compile/expected/{contract/normalized.json => derived/contract.normalized.json} (100%) create mode 100644 packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/ast.json create mode 100644 packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/platforms.json create mode 100644 packages/interfacectl-cli/test/migrate-ui-ast.test.mjs create mode 100644 packages/interfacectl-validator/dist/schema/ui.surface.ast.schema.json create mode 100644 packages/interfacectl-validator/dist/ui-ast.d.ts create mode 100644 packages/interfacectl-validator/dist/ui-ast.d.ts.map create mode 100644 packages/interfacectl-validator/dist/ui-ast.js create mode 100644 packages/interfacectl-validator/src/schema/ui.surface.ast.schema.json create mode 100644 packages/interfacectl-validator/src/ui-ast.ts create mode 100644 packages/interfacectl-validator/test/ui-ast.test.mjs diff --git a/README.md b/README.md index 8736454..dd7164a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # interfacectl -Interface contract tooling for the Surfaces ecosystem. Validates, compares, compiles, and enforces compliance between defined interface contracts and observed implementation artifacts across checked-out surfaces and browser-observed runtime sessions. +Governed UI AST tooling for the Surfaces ecosystem. `interfacectl` validates, compares, compiles, and enforces compliance between a semantic UI AST contract and observed implementation artifacts across checked-out surfaces and browser-observed runtime sessions. ## Planning @@ -61,12 +61,18 @@ interfacectl init --url https://app.example.com --surface customer-app --auth-pr `interfacectl` replays the saved browser session in Chromium, analyzes the rendered authenticated page, and keeps auth state out of generated artifacts. -For checked-out code and optional runtime observation, use `validate`: +Compatibility note: + +- The canonical authored artifact is `contracts/ui.surface.ast.json`. +- `--contract` remains supported only as a deprecated migration/compatibility input for legacy `web.surface.contract` JSON. +- Use `interfacectl migrate-ui-ast --contract --out contracts/ui.surface.ast.json` to move legacy inputs onto the canonical AST path. + +For checked-out code and optional runtime observation, use `validate` against the canonical UI AST: ```bash -interfacectl validate --workspace-root . --contract ./contracts/surfaces.web.contract.json --format json --exit-codes v2 +interfacectl validate --workspace-root . --ast ./contracts/ui.surface.ast.json --format json --exit-codes v2 -interfacectl validate --workspace-root . --contract ./contracts/surfaces.web.contract.json \ +interfacectl validate --workspace-root . --ast ./contracts/ui.surface.ast.json \ --surface customer-app \ --remote-url https://app.example.com/dashboard \ --format json \ @@ -159,12 +165,14 @@ interfacectl enforce [options] ### `compile` -Compiles a validated interface contract into a deterministic generation-and-runtime bundle. The bundle includes a manifest, `contract/normalized.json`, and per-surface slices for downstream generators, runtime adapters, repair guidance, and workbench consumers. +Compiles a validated UI AST into a deterministic generation-and-runtime bundle. The canonical bundle includes `ast/normalized.json`, per-surface AST/platform slices, and a derived contract compatibility file for downstream consumers that still need contract-shaped data. + +`--contract` remains available here only to import or bridge legacy inputs during migration. New consumers should compile from `--ast`. This command does **not** perform enforcement or runtime gating. It produces a stable artifact intended for inspection, tooling, or future runtime consumption. ```bash -interfacectl compile --contract --out +interfacectl compile --ast --out ``` ### `prepare-generation` diff --git a/docs/plans/ui-ast-v2-cutover-pr-description.md b/docs/plans/ui-ast-v2-cutover-pr-description.md new file mode 100644 index 0000000..ea78faa --- /dev/null +++ b/docs/plans/ui-ast-v2-cutover-pr-description.md @@ -0,0 +1,58 @@ +# PR Description: UI AST V2 Cutover + +Use this content when opening the PR. Reference: docs/plans/ui-ast-v2-cutover-rfc.md. + +--- + +## Title + +feat: make UI AST the canonical governed UI contract + +--- + +## Strategy check + +- [x] I read "docs/strategy.md" +- [x] This PR strengthens the decision filter sentence by moving governed UI review and enforcement to a bounded semantic contract +- [x] Enforcement timing is explicit: generation time plus downstream runtime-consumption preparation +- [x] Violation handling is defined: unsupported or invalid AST input fails closed; legacy contract input is migration-only compatibility +- [x] CLI behavior is tied to governed UI semantics, not free-form UI generation + +## What changed + +- Added a canonical UI AST schema, types, and validator entrypoints for bounded governed UI semantics. +- Added AST resolution and migration helpers so CLI commands resolve `--ast` first and only fall back to legacy `--contract` input for migration compatibility. +- Added `migrate-ui-ast` to deterministically import legacy `web.surface.contract` files into AST drafts. +- Made `compile`, `prepare-generation`, and `prepare-runtime` AST-first on bundle format `3.0`. +- Canonical bundle artifacts are now `ast/normalized.json`, `surfaces//ast.json`, and `surfaces//platforms.json`, with `derived/contract.normalized.json` kept only for compatibility. +- Updated tests and compile goldens for AST-first bundle output, including multi-platform projection coverage. +- Added RFC/README framing so UI AST is the lead model and legacy contracts are described as migration-only compatibility. + +## Why it matters + +This makes the semantic contract explicit and reviewable before rendering. Models, validators, runtime consumers, and downstream repos now meet on one bounded artifact instead of inferring meaning from implementation-shaped input. It also creates a clear migration path off the legacy contract model without pretending both models are long-term co-equals. + +## Contract and enforcement notes + +1. Canonical contract model changed from legacy `web.surface.contract` input to UI AST v2 input. +2. Enforcement point remains generation time; runtime consumers still consume prepared artifacts derived from the compiled bundle. +3. Legacy contract support is explicitly deprecated and migration-only. Invalid AST or invalid migrated AST output fails closed. + +## Not in scope + +- No second rollout surface beyond the existing benchmark proof downstream. +- No AST-native local-workbench authoring flow. +- No broad consumer-doc sweep or root-script deprecation cleanup beyond the AST-first framing needed for this cutover. + +## Tests + +- `packages/interfacectl-validator`: `node --test test/authoring-contract.test.mjs test/ui-ast.test.mjs` +- `packages/interfacectl-cli`: `node --test test/color-deprecation.test.mjs test/compile.test.mjs test/prepare-generation.test.mjs test/prepare-runtime.test.mjs test/generation-adapter.test.mjs test/migrate-ui-ast.test.mjs` + +## Review checklist + +- [x] UI AST is clearly the canonical contract model in code and docs. +- [x] Legacy `--contract` support is described as migration-only compatibility. +- [x] Bundle `3.0` canonical files are AST-first and compatibility output is explicit. +- [x] Migration command and AST schema coverage are test-backed. +- [x] Scope stays limited to the AST cutover itself, without adding more rollout surfaces. diff --git a/docs/plans/ui-ast-v2-cutover-rfc.md b/docs/plans/ui-ast-v2-cutover-rfc.md new file mode 100644 index 0000000..a8d2c5e --- /dev/null +++ b/docs/plans/ui-ast-v2-cutover-rfc.md @@ -0,0 +1,103 @@ +# UI AST V2 Cutover RFC + +## Decision + +Adopt UI AST v2 as the canonical semantic contract for governed UI in `interfacectl`. + +Legacy `web.surface.contract` remains supported only as a migration input. It is no longer the canonical artifact. + +## Why + +- Intent needs a bounded, reviewable artifact before rendering. +- Generation should stop handing arbitrary UI code directly to downstream systems. +- Governance, accessibility, and design-system checks should run at the semantic boundary instead of after implementation drift appears. +- Multi-platform output needs one durable source of truth for node identity, actions, and policy metadata. + +## Scope + +v1 AST scope is limited to governed application surfaces: + +- settings pages +- forms +- onboarding flows +- transactional detail views +- empty states +- alerts and confirmations +- simple list and table surfaces +- account and preference management + +Excluded from the first rollout: + +- marketing pages +- bespoke editorial experiences +- unconstrained canvases +- custom data visualizations +- animation-led or experimental interaction models + +## Canonical Artifact + +Canonical input path: + +- `contracts/ui.surface.ast.json` + +Primary CLI flag: + +- `--ast` + +Legacy compatibility input: + +- `--contract` for existing `web.surface.contract` JSON +- deprecated and migration-oriented only + +## Bundle Changes + +Compiled bundles now use format `3.0`. + +Canonical bundle source files: + +- `ast/normalized.json` +- `surfaces//ast.json` +- `surfaces//platforms.json` + +Compatibility output remains available for downstream consumers that still need contract-shaped data: + +- `derived/contract.normalized.json` + +## Implementation Notes + +- Validator owns the UI AST schema and bounded vocabulary. +- CLI resolves AST first, falls back to legacy contracts, and migrates legacy input into AST drafts deterministically. +- `compile`, `prepare-generation`, and `prepare-runtime` now treat AST as the normalized source artifact. +- `migrate-ui-ast` imports legacy contracts into AST drafts and emits escalation markers when semantics cannot be preserved safely. + +## Rollout + +Phase 1: + +- land AST schema, migration command, bundle v3, and AST-aware preparation flows in `interfacectl` + +Phase 2: + +- prove consumer compatibility in `surfaces-webapps` on `benchmark-async-data-web` +- validate the live fixture against the AST draft +- compile and prepare generation/runtime payloads from the AST-derived bundle + +Phase 3: + +- migrate additional governed surfaces +- remove legacy contract-as-canonical assumptions from downstream tooling + +## Guardrails + +- Semantics over presentation +- Stable node identity across generation, approval, rendering, and observation +- Bounded vocabulary over free-form styling +- Governance metadata is first-class +- Unsupported cases fail closed or escalate + +## Exit Criteria + +- AST schema fixtures cover valid ASTs plus unsupported styling, logic, ids, and vocabulary failures +- bundle v3 is deterministic and proven with multi-platform projections +- migration from legacy contracts is deterministic and test-covered +- `surfaces-webapps` consumes AST-derived bundles on at least one governed benchmark surface diff --git a/packages/interfacectl-cli/dist/adapter/bundle.d.ts b/packages/interfacectl-cli/dist/adapter/bundle.d.ts index deb1474..25dc298 100644 --- a/packages/interfacectl-cli/dist/adapter/bundle.d.ts +++ b/packages/interfacectl-cli/dist/adapter/bundle.d.ts @@ -1,4 +1,4 @@ -export declare const SUPPORTED_BUNDLE_VERSION = "2.0"; +export declare const SUPPORTED_BUNDLE_VERSION = "3.0"; export interface JsonRecord { [key: string]: unknown; } @@ -17,10 +17,13 @@ export interface LoadedCompiledSurfaceBundle { contractId: string; contractVersion: string; manifest: LoadedJsonFile; + ast?: LoadedJsonFile; contract: LoadedJsonFile; surface: { id: string; dir: string; + ast?: LoadedJsonFile; + platforms?: LoadedJsonFile; generation: LoadedJsonFile; sections: LoadedJsonFile; components: LoadedJsonFile; diff --git a/packages/interfacectl-cli/dist/adapter/bundle.d.ts.map b/packages/interfacectl-cli/dist/adapter/bundle.d.ts.map index 9edd36f..9169829 100644 --- a/packages/interfacectl-cli/dist/adapter/bundle.d.ts.map +++ b/packages/interfacectl-cli/dist/adapter/bundle.d.ts.map @@ -1 +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,OAAO,CAAC,EAAE,cAAc,CAAC;QACzB,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,CA+H7B"} \ No newline at end of file +{"version":3,"file":"bundle.d.ts","sourceRoot":"","sources":["../../src/adapter/bundle.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,wBAAwB,QAAQ,CAAC;AAG9C,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,GAAG,CAAC,EAAE,cAAc,CAAC;IACrB,QAAQ,EAAE,cAAc,CAAC;IACzB,OAAO,EAAE;QACP,EAAE,EAAE,MAAM,CAAC;QACX,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,CAAC,EAAE,cAAc,CAAC;QACrB,SAAS,CAAC,EAAE,cAAc,CAAC;QAC3B,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,OAAO,CAAC,EAAE,cAAc,CAAC;QACzB,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,CA6K7B"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/adapter/bundle.js b/packages/interfacectl-cli/dist/adapter/bundle.js index f0e1104..33359fd 100644 --- a/packages/interfacectl-cli/dist/adapter/bundle.js +++ b/packages/interfacectl-cli/dist/adapter/bundle.js @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -export const SUPPORTED_BUNDLE_VERSION = "2.0"; +export const SUPPORTED_BUNDLE_VERSION = "3.0"; +const SUPPORTED_BUNDLE_VERSIONS = new Set(["2.0", "3.0"]); export class AdapterInputError extends Error { code; meta; @@ -58,8 +59,8 @@ export function loadCompiledSurfaceBundle(bundleRootInput, surfaceId, cwd) { 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" }); + if (!SUPPORTED_BUNDLE_VERSIONS.has(manifest.bundleVersion ?? "")) { + throw new AdapterInputError(`Unsupported bundle version "${manifest.bundleVersion ?? "unknown"}". Expected one of ${[...SUPPORTED_BUNDLE_VERSIONS].join(", ")}.`, { code: "adapter.bundle.version-unsupported" }); } const surfaceDir = path.join(bundleRoot, "surfaces", surfaceId); ensureReadableDirectory(surfaceDir, "Surface bundle"); @@ -98,9 +99,23 @@ export function loadCompiledSurfaceBundle(bundleRootInput, surfaceId, cwd) { value: readJsonFile(repairMapPath, "repair map"), }; const refs = isRecord(generation.value.refs) ? generation.value.refs : {}; + let ast; + if (manifest.bundleVersion === "3.0") { + const astRef = typeof refs.ast === "string" && refs.ast.trim().length > 0 + ? refs.ast + : "../../ast/normalized.json"; + const astPath = path.resolve(path.dirname(generationPath), astRef); + ensureReadableFile(astPath, "Compiled UI AST"); + ast = { + path: astPath, + value: readJsonFile(astPath, "Compiled UI AST"), + }; + } const contractRef = typeof refs.contract === "string" && refs.contract.trim().length > 0 ? refs.contract - : "../../contract/normalized.json"; + : manifest.bundleVersion === "3.0" + ? "../../derived/contract.normalized.json" + : "../../contract/normalized.json"; const contractPath = path.resolve(path.dirname(generationPath), contractRef); ensureReadableFile(contractPath, "Compiled contract"); const contract = { @@ -127,6 +142,30 @@ export function loadCompiledSurfaceBundle(bundleRootInput, surfaceId, cwd) { value: readJsonFile(runtimePath, "Runtime bundle"), }; } + let surfaceAst; + if (manifest.bundleVersion === "3.0") { + const astSliceRef = typeof refs.astSlice === "string" && refs.astSlice.trim().length > 0 + ? refs.astSlice + : "./ast.json"; + const astSlicePath = path.resolve(path.dirname(generationPath), astSliceRef); + ensureReadableFile(astSlicePath, "Surface AST bundle"); + surfaceAst = { + path: astSlicePath, + value: readJsonFile(astSlicePath, "Surface AST bundle"), + }; + } + let platforms; + if (manifest.bundleVersion === "3.0") { + const platformsRef = typeof refs.platforms === "string" && refs.platforms.trim().length > 0 + ? refs.platforms + : "./platforms.json"; + const platformsPath = path.resolve(path.dirname(generationPath), platformsRef); + ensureReadableFile(platformsPath, "Surface platform bundle"); + platforms = { + path: platformsPath, + value: readJsonFile(platformsPath, "Surface platform bundle"), + }; + } const generationProvenance = isRecord(generation.value.provenance) ? generation.value.provenance : undefined; @@ -142,17 +181,20 @@ export function loadCompiledSurfaceBundle(bundleRootInput, surfaceId, cwd) { "unknown"; return { root: bundleRoot, - version: SUPPORTED_BUNDLE_VERSION, + version: manifest.bundleVersion ?? SUPPORTED_BUNDLE_VERSION, contractId, contractVersion, manifest: { path: manifestPath, value: manifest, }, + ...(ast ? { ast } : {}), contract, surface: { id: surfaceId, dir: surfaceDir, + ...(surfaceAst ? { ast: surfaceAst } : {}), + ...(platforms ? { platforms } : {}), generation, sections, components, diff --git a/packages/interfacectl-cli/dist/commands/compile.d.ts b/packages/interfacectl-cli/dist/commands/compile.d.ts index 1a2d197..9d3a543 100644 --- a/packages/interfacectl-cli/dist/commands/compile.d.ts +++ b/packages/interfacectl-cli/dist/commands/compile.d.ts @@ -1,5 +1,6 @@ export interface CompileCommandOptions { - contractPath: string; + astPath?: string; + contractPath?: string; outDir: string; schemaPath?: string; format?: "json"; diff --git a/packages/interfacectl-cli/dist/commands/compile.d.ts.map b/packages/interfacectl-cli/dist/commands/compile.d.ts.map index 976353e..abd968f 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":"AA8BA,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;AAmmCD,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,qBAAqB,EAC9B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CAkGjB"} \ No newline at end of file +{"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/commands/compile.ts"],"names":[],"mappings":"AA6BA,MAAM,WAAW,qBAAqB;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAirCD,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,qBAAqB,EAC9B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CAwHjB"} \ 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 d9af5e5..eb3b3f1 100644 --- a/packages/interfacectl-cli/dist/commands/compile.js +++ b/packages/interfacectl-cli/dist/commands/compile.js @@ -1,10 +1,10 @@ import path from "node:path"; -import { readFile, writeFile, mkdir, rename } from "node:fs/promises"; +import { 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 = "2.0"; -const SCHEMA_VERSION = "surfaces.web.contract@1"; +import { resolveUiAstInput } from "../utils/ui-ast.js"; +const BUNDLE_VERSION = "3.0"; +const SCHEMA_VERSION = "surfaces.ui.ast@2"; const DEFAULT_TARGET_ACQUISITION_MODALITY = "touch-mouse"; const DEFAULT_MIN_HIT_AREA_PX = 44; const DEFAULT_MIN_GAP_PX = 8; @@ -40,8 +40,10 @@ async function writeAtomic(filePath, content) { await writeFile(tmpPath, content, "utf8"); await rename(tmpPath, filePath); } -function makeBundleProvenance(contract, surfaceId) { +function makeBundleProvenance(ast, contract, surfaceId) { return { + astId: ast.astId, + astVersion: ast.version, contractId: contract.contractId, contractVersion: contract.version, bundleVersion: BUNDLE_VERSION, @@ -277,10 +279,10 @@ function buildSectionOrderHints(surface) { } return hints; } -function buildSectionsPayload(contract, surface, sections) { +function buildSectionsPayload(ast, contract, surface, sections) { const orderHints = buildSectionOrderHints(surface); return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), sections: sections.map((section) => { const hint = orderHints.get(section.id); return { @@ -334,7 +336,7 @@ function buildSectionsPayload(contract, surface, sections) { }), }; } -function buildComponentsPayload(contract, surface, components) { +function buildComponentsPayload(ast, contract, surface, components) { const catalog = components.map((component) => ({ id: component.id, intent: component.intent, @@ -347,7 +349,7 @@ function buildComponentsPayload(contract, surface, components) { ...(component.references ? { references: component.references } : {}), })); return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), components: catalog, }; } @@ -356,11 +358,11 @@ function resolveProfileById(profiles, profileId) { return null; return profiles.find((profile) => profile.id === profileId) ?? null; } -function buildConstraintsPayload(contract, surface) { +function buildConstraintsPayload(ast, 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), + provenance: makeBundleProvenance(ast, contract, surface.id), constraints: { motion: contract.constraints.motion, color: contract.color, @@ -436,6 +438,24 @@ function buildGuidance(contract, surface, sections) { ]), }; } +function buildAstPayload(ast, contract, astSurface, surface) { + return { + provenance: makeBundleProvenance(ast, contract, surface.id), + ast: { + kind: astSurface.kind, + rootNodeId: astSurface.rootNodeId, + nodes: astSurface.nodes, + states: astSurface.states ?? [], + migrationEscalations: ast.migration?.escalations.filter((entry) => entry.surfaceId === surface.id) ?? [], + }, + }; +} +function buildPlatformsPayload(ast, contract, astSurface, surface) { + return { + provenance: makeBundleProvenance(ast, contract, surface.id), + platforms: astSurface.platforms, + }; +} function buildObservationRefs(contract) { const refs = []; if (contract.x_extracted) { @@ -446,7 +466,7 @@ function buildObservationRefs(contract) { } return refs; } -function buildGenerationPayload(contract, surface, sections) { +function buildGenerationPayload(ast, contract, surface, sections, astSurface) { const shellOwns = contract.shell?.owns ?? []; const mustNotEmit = surface.mustNotEmit ?? []; const requiredSections = surface.requiredSections; @@ -467,7 +487,13 @@ function buildGenerationPayload(contract, surface, sections) { displayName: surface.displayName, type: surface.type, }, - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), + ast: { + rootNodeId: astSurface.rootNodeId, + nodeCount: astSurface.nodes.length, + stateCount: astSurface.states?.length ?? 0, + platformIds: astSurface.platforms.map((platform) => platform.platform), + }, boundary: { shellOwns, contentSlot: contract.shell?.contentSlot ?? null, @@ -522,7 +548,10 @@ function buildGenerationPayload(contract, surface, sections) { adaptation, guidance: buildGuidance(contract, surface, sections), refs: { - contract: "../../contract/normalized.json", + ast: "../../ast/normalized.json", + contract: "../../derived/contract.normalized.json", + astSlice: "./ast.json", + platforms: "./platforms.json", sections: "./sections.json", components: "./components.json", constraints: "./constraints.json", @@ -533,11 +562,11 @@ function buildGenerationPayload(contract, surface, sections) { }, }; } -function buildAuthoringPayload(contract, surface) { +function buildAuthoringPayload(ast, contract, surface) { if (!surface.authoring) return null; return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), authoring: { ...surface.authoring, sourcePriority: (surface.authoring.sourcePriority ?? []).map((source) => source), @@ -547,7 +576,7 @@ function buildAuthoringPayload(contract, surface) { function addRepair(repairs, code, priority, category, action) { repairs.push({ code, priority, category, action }); } -function buildRepairMapPayload(contract, surface, sections) { +function buildRepairMapPayload(ast, contract, surface, sections) { const repairs = []; const shellOwns = contract.shell?.owns ?? []; const mustNotEmit = surface.mustNotEmit ?? []; @@ -744,22 +773,28 @@ function buildRepairMapPayload(contract, surface, sections) { }); } return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), repairs, }; } -function buildRuntimePayload(contract, surface, sections, components) { +function buildRuntimePayload(ast, contract, surface, sections, components, astSurface) { const policySeverities = buildPolicySeverities(contract, surface); const mutationEnvelope = buildMutationEnvelope(surface, sections); const targetAcquisition = resolveTargetAcquisitionPolicy(surface.layout.targetAcquisition); const feedbackRecovery = resolveFeedbackRecoveryPolicy(surface.runtime?.feedbackRecovery, surface.runtime?.contexts); return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), identity: { surfaceId: surface.id, displayName: surface.displayName, type: surface.type, }, + ast: { + rootNodeId: astSurface.rootNodeId, + nodeCount: astSurface.nodes.length, + stateCount: astSurface.states?.length ?? 0, + platformIds: astSurface.platforms.map((platform) => platform.platform), + }, governance: buildGovernancePayload(surface), runtime: { policy: surface.runtime?.policy ?? policySeverities.runtime, @@ -812,7 +847,10 @@ function buildRuntimePayload(contract, surface, sections, components) { : {}), }, refs: { - contract: "../../contract/normalized.json", + ast: "../../ast/normalized.json", + contract: "../../derived/contract.normalized.json", + astSlice: "./ast.json", + platforms: "./platforms.json", sections: "./sections.json", components: "./components.json", constraints: "./constraints.json", @@ -820,18 +858,28 @@ function buildRuntimePayload(contract, surface, sections, components) { }, }; } -function buildSurfaceBundleFiles(contract, surface) { +function buildSurfaceBundleFiles(ast, contract, surface, astSurface) { 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 runtimePayload = buildRuntimePayload(contract, surface, sections, components); + const astPayload = buildAstPayload(ast, contract, astSurface, surface); + const platformsPayload = buildPlatformsPayload(ast, contract, astSurface, surface); + const constraintsPayload = buildConstraintsPayload(ast, contract, surface); + const generationPayload = buildGenerationPayload(ast, contract, surface, sections, astSurface); + const sectionsPayload = buildSectionsPayload(ast, contract, surface, sections); + const componentsPayload = buildComponentsPayload(ast, contract, surface, components); + const repairMapPayload = buildRepairMapPayload(ast, contract, surface, sections); + const authoringPayload = buildAuthoringPayload(ast, contract, surface); + const runtimePayload = buildRuntimePayload(ast, contract, surface, sections, components, astSurface); const files = [ + { + path: `${surfaceDir}/ast.json`, + content: stringifyDeterministic(astPayload), + }, + { + path: `${surfaceDir}/platforms.json`, + content: stringifyDeterministic(platformsPayload), + }, { path: `${surfaceDir}/generation.json`, content: stringifyDeterministic(generationPayload), @@ -867,62 +915,72 @@ function buildSurfaceBundleFiles(contract, surface) { } export async function runCompileCommand(options, toolVersion) { const outDir = path.resolve(options.outDir); - const contractInput = path.resolve(options.contractPath); - const schemaPath = options.schemaPath - ? path.resolve(options.schemaPath) - : undefined; - let contractRaw; - try { - contractRaw = await readFile(contractInput, "utf8"); - } - catch (err) { - const message = err.code === "ENOENT" - ? `Contract file not found: ${contractInput}` - : `Failed to read contract: ${err.message}`; - console.error(message); - return 1; - } - let contractData; - try { - contractData = JSON.parse(contractRaw); - } - catch (err) { - console.error(`Invalid contract JSON: ${err.message}`); + const workspaceRoot = process.cwd(); + const resolvedInput = await resolveUiAstInput({ + workspaceRoot, + astPath: options.astPath, + contractPath: options.contractPath, + schemaPath: options.schemaPath, + }); + if ("error" in resolvedInput) { + console.error(resolvedInput.error); return 1; } - let schema; - if (schemaPath) { - try { - const raw = await readFile(schemaPath, "utf8"); - schema = JSON.parse(raw); - } - catch (err) { - const message = err.code === "ENOENT" - ? `Schema file not found: ${schemaPath}` - : `Failed to read schema: ${err.message}`; - console.error(message); - return 1; - } - } - else { - schema = getBundledContractSchema(); + for (const warning of resolvedInput.warnings) { + console.error(`Warning: ${warning}`); } - const structureResult = validateContractStructure(contractData, schema); - if (!structureResult.ok || !structureResult.contract) { - console.error("Contract schema validation failed:"); - for (const error of structureResult.errors) { - console.error(` • ${error}`); - } - return 1; - } - const contract = structureResult.contract; - const { contract: normalizedContract } = normalizeContract(contract); + const ast = resolvedInput.ast; + const { contract: normalizedContract } = normalizeContract(resolvedInput.derivedContract); + const surfaceMap = new Map(ast.surfaces.map((surface) => [surface.id, surface])); const bundleFiles = [ { - path: "contract/normalized.json", + path: "ast/normalized.json", + content: stringifyDeterministic(ast), + }, + { + path: "derived/contract.normalized.json", content: stringifyDeterministic(normalizedContract), }, - ...normalizedContract.surfaces.flatMap((surface) => buildSurfaceBundleFiles(normalizedContract, surface)), + ...normalizedContract.surfaces.flatMap((surface) => buildSurfaceBundleFiles(ast, normalizedContract, surface, surfaceMap.get(surface.id) ?? { + id: surface.id, + displayName: surface.displayName, + kind: "application", + rootNodeId: `${surface.id}.root`, + nodes: [ + { + id: `${surface.id}.root`, + kind: "group", + label: surface.displayName, + children: surface.requiredSections, + }, + ...surface.requiredSections.map((sectionId) => ({ + id: sectionId, + kind: "section", + sectionId, + intent: "section", + label: sectionId, + })), + ], + platforms: [ + { + platform: "web", + allowedFonts: surface.allowedFonts, + layout: { + maxContentWidth: surface.layout.maxContentWidth, + ...(surface.layout.requiredContainers + ? { requiredContainers: surface.layout.requiredContainers } + : {}), + ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), + ...(surface.layout.chromePolicy + ? { chromePolicy: surface.layout.chromePolicy } + : {}), + ...(surface.layout.targetAcquisition + ? { targetAcquisition: surface.layout.targetAcquisition } + : {}), + }, + }, + ], + })), ]; const filesSorted = [...bundleFiles].sort((a, b) => a.path.localeCompare(b.path)); const fileEntries = filesSorted.map(({ path: p, content }) => ({ @@ -931,13 +989,16 @@ export async function runCompileCommand(options, toolVersion) { })); const manifest = { bundleVersion: BUNDLE_VERSION, + astId: ast.astId, + astVersion: ast.version, contractId: normalizedContract.contractId, contractVersion: normalizedContract.version, schemaVersion: SCHEMA_VERSION, + sourceFormat: "ui-ast", tool: { name: "interfacectl", version: toolVersion }, inputs: { - contractPath: options.contractPath, - schemaPath: schemaPath ?? null, + contractPath: resolvedInput.sourcePath, + schemaPath: options.schemaPath ?? null, }, files: fileEntries, }; diff --git a/packages/interfacectl-cli/dist/commands/diff.d.ts b/packages/interfacectl-cli/dist/commands/diff.d.ts index 1cc9f5f..c41ce28 100644 --- a/packages/interfacectl-cli/dist/commands/diff.d.ts +++ b/packages/interfacectl-cli/dist/commands/diff.d.ts @@ -1,6 +1,7 @@ import { type ExitCodeVersion } from "../utils/exit-codes.js"; type OutputFormat = "text" | "json"; export interface DiffCommandOptions { + astPath?: string; contractPath?: string; schemaPath?: string; workspaceRoot?: string; diff --git a/packages/interfacectl-cli/dist/commands/diff.d.ts.map b/packages/interfacectl-cli/dist/commands/diff.d.ts.map index e44f2bb..7296718 100644 --- a/packages/interfacectl-cli/dist/commands/diff.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/diff.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/commands/diff.ts"],"names":[],"mappings":"AA2BA,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAKlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAMpC,MAAM,WAAW,kBAAkB;IACjC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAiRD,wBAAsB,cAAc,CAClC,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,MAAM,CAAC,CAkRjB"} \ No newline at end of file +{"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/commands/diff.ts"],"names":[],"mappings":"AA2BA,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAMlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAMpC,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAiRD,wBAAsB,cAAc,CAClC,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,MAAM,CAAC,CAyOjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/diff.js b/packages/interfacectl-cli/dist/commands/diff.js index 02db278..725beb6 100644 --- a/packages/interfacectl-cli/dist/commands/diff.js +++ b/packages/interfacectl-cli/dist/commands/diff.js @@ -2,7 +2,7 @@ import path from "node:path"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import pc from "picocolors"; import pkg from "../../package.json" with { type: "json" }; -import { validateContractStructure, getBundledContractSchema, validateDiffOutput, } from "@surfaces/interfacectl-validator"; +import { validateDiffOutput, } from "@surfaces/interfacectl-validator"; import { collectSurfaceDescriptors, } from "../descriptors/static-analysis.js"; import { normalizeContract, normalizeDescriptor } from "../utils/normalize.js"; import { compareContractToDescriptor, } from "../utils/compare.js"; @@ -12,6 +12,7 @@ import { getExitCodeVersion } from "../utils/exit-codes.js"; import { getMaxSeverity } from "../utils/violation-classifier.js"; import { applyPolicySeverityOverrides } from "../utils/apply-policy-severity.js"; import { enrichDiffEntry } from "../utils/traceability.js"; +import { resolveUiAstInput } from "../utils/ui-ast.js"; async function loadConfigFile(configPath) { try { const raw = await readFile(configPath, "utf-8"); @@ -219,15 +220,6 @@ function formatDiffText(output) { } export async function runDiffCommand(options) { const workspaceRoot = path.resolve(options.workspaceRoot ?? process.cwd()); - const contractInput = options.contractPath ?? "contracts/surfaces.web.contract.json"; - const contractPath = path.isAbsolute(contractInput) - ? contractInput - : path.resolve(workspaceRoot, contractInput); - const schemaPath = options.schemaPath - ? path.isAbsolute(options.schemaPath) - ? options.schemaPath - : path.resolve(workspaceRoot, options.schemaPath) - : undefined; const outputFormat = options.outputFormat ?? "text"; const isJson = outputFormat === "json"; const outputPath = options.outputPath @@ -267,15 +259,19 @@ export async function runDiffCommand(options) { } return exitCode; }; - // Load contract - const contractSource = await loadJson(contractPath, "contract"); - if (!contractSource.ok) { + const resolvedInput = await resolveUiAstInput({ + workspaceRoot, + astPath: options.astPath, + contractPath: options.contractPath, + schemaPath: options.schemaPath, + }); + if ("error" in resolvedInput) { const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; if (isJson) { const errorOutput = { schemaVersion: "1.0.0", tool: { name: "interfacectl", version: pkg.version ?? "0.0.0" }, - contract: { path: contractPath, version: "unknown" }, + contract: { path: options.astPath ?? options.contractPath ?? "unknown", version: "unknown" }, observed: { root: workspaceRoot }, normalization: { enabled: normalizeEnabled, reorderedPaths: [], strippedPaths: [] }, summary: { @@ -288,46 +284,17 @@ export async function runDiffCommand(options) { await finalize(e0ExitCode, errorOutput); } else { - console.error(`Failed to read contract: ${contractSource.error}`); + console.error(resolvedInput.error); } return e0ExitCode; } - const initialContractVersion = extractContractVersion(contractSource.value); - // Validate contract structure - const schemaResult = schemaPath - ? await loadJson(schemaPath, "schema") - : { ok: false, error: "" }; - const schema = schemaResult.ok - ? schemaResult.value - : getBundledContractSchema(); - const structureResult = validateContractStructure(contractSource.value, schema); - if (!structureResult.ok || !structureResult.contract) { - const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; - if (isJson) { - const errorOutput = { - schemaVersion: "1.0.0", - tool: { name: "interfacectl", version: pkg.version ?? "0.0.0" }, - contract: { path: contractPath, version: initialContractVersion ?? "unknown" }, - observed: { root: workspaceRoot }, - normalization: { enabled: normalizeEnabled, reorderedPaths: [], strippedPaths: [] }, - summary: { - totalChanges: 0, - byType: { added: 0, removed: 0, modified: 0, renamed: 0 }, - bySeverity: { error: 0, warning: 0, info: 0 }, - }, - entries: [], - }; - await finalize(e0ExitCode, errorOutput); - } - else { - console.error("Contract structure validation failed:"); - for (const error of structureResult.errors) { - console.error(` • ${error}`); - } + for (const warning of resolvedInput.warnings) { + if (!isJson) { + console.error(`Warning: ${warning}`); } - return e0ExitCode; } - const contract = structureResult.contract; + const contractPath = resolvedInput.sourcePath; + const contract = resolvedInput.derivedContract; // Load config const configResult = await loadConfigFile(configPath); const surfaceRootMap = new Map(); diff --git a/packages/interfacectl-cli/dist/commands/enforce.d.ts b/packages/interfacectl-cli/dist/commands/enforce.d.ts index 7fe6096..f1bd584 100644 --- a/packages/interfacectl-cli/dist/commands/enforce.d.ts +++ b/packages/interfacectl-cli/dist/commands/enforce.d.ts @@ -5,6 +5,7 @@ export interface EnforceCommandOptions { mode?: EnforcementMode; strict?: boolean; policyPath?: string; + astPath?: string; contractPath?: string; workspaceRoot?: string; surfaceFilters?: string[]; diff --git a/packages/interfacectl-cli/dist/commands/enforce.d.ts.map b/packages/interfacectl-cli/dist/commands/enforce.d.ts.map index 8de4c6a..dbd8f05 100644 --- a/packages/interfacectl-cli/dist/commands/enforce.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/enforce.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"enforce.d.ts","sourceRoot":"","sources":["../../src/commands/enforce.ts"],"names":[],"mappings":"AAIA,OAAO,EAML,eAAe,EAChB,MAAM,kCAAkC,CAAC;AAU1C,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAEpC,MAAM,WAAW,qBAAqB;IACpC,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAkDD,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,MAAM,CAAC,CA8RjB"} \ No newline at end of file +{"version":3,"file":"enforce.d.ts","sourceRoot":"","sources":["../../src/commands/enforce.ts"],"names":[],"mappings":"AAIA,OAAO,EAML,eAAe,EAChB,MAAM,kCAAkC,CAAC;AAU1C,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAEpC,MAAM,WAAW,qBAAqB;IACpC,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAkDD,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,MAAM,CAAC,CA+RjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/enforce.js b/packages/interfacectl-cli/dist/commands/enforce.js index 56d3954..b231dff 100644 --- a/packages/interfacectl-cli/dist/commands/enforce.js +++ b/packages/interfacectl-cli/dist/commands/enforce.js @@ -106,6 +106,7 @@ export async function runEnforceCommand(options) { const { randomUUID } = await import("node:crypto"); const tempDiffPath = path.join(tmpdir(), `interfacectl-diff-${randomUUID()}.json`); const diffResult = await runDiffCommand({ + astPath: options.astPath, contractPath: options.contractPath, workspaceRoot, surfaceFilters: options.surfaceFilters, diff --git a/packages/interfacectl-cli/dist/commands/migrate-ui-ast.d.ts b/packages/interfacectl-cli/dist/commands/migrate-ui-ast.d.ts new file mode 100644 index 0000000..80ac087 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/migrate-ui-ast.d.ts @@ -0,0 +1,8 @@ +export interface MigrateUiAstCommandOptions { + contractPath?: string; + outPath?: string; + schemaPath?: string; + format?: "text" | "json"; +} +export declare function runMigrateUiAstCommand(options: MigrateUiAstCommandOptions): Promise; +//# sourceMappingURL=migrate-ui-ast.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/migrate-ui-ast.d.ts.map b/packages/interfacectl-cli/dist/commands/migrate-ui-ast.d.ts.map new file mode 100644 index 0000000..2fdad4c --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/migrate-ui-ast.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"migrate-ui-ast.d.ts","sourceRoot":"","sources":["../../src/commands/migrate-ui-ast.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,0BAA0B;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC1B;AAED,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,MAAM,CAAC,CA2DjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/migrate-ui-ast.js b/packages/interfacectl-cli/dist/commands/migrate-ui-ast.js new file mode 100644 index 0000000..52b9fa2 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/migrate-ui-ast.js @@ -0,0 +1,49 @@ +import path from "node:path"; +import { writeDeterministicJson } from "../utils/deterministic-json.js"; +import { DEFAULT_AST_PATH, resolveUiAstInput } from "../utils/ui-ast.js"; +export async function runMigrateUiAstCommand(options) { + if (!options.contractPath) { + console.error("--contract is required."); + return 1; + } + const workspaceRoot = process.cwd(); + const resolved = await resolveUiAstInput({ + workspaceRoot, + contractPath: options.contractPath, + schemaPath: options.schemaPath, + }); + if ("error" in resolved) { + console.error(resolved.error); + return 1; + } + const outPath = path.resolve(options.outPath ?? DEFAULT_AST_PATH); + await writeDeterministicJson(outPath, resolved.ast); + if (options.format === "json") { + process.stdout.write(`${JSON.stringify({ + status: "ok", + sourceKind: resolved.sourceKind, + sourcePath: resolved.sourcePath, + outPath, + astId: resolved.ast.astId, + version: resolved.ast.version, + surfaceIds: resolved.ast.surfaces.map((surface) => surface.id), + escalations: resolved.ast.migration?.escalations ?? [], + warnings: resolved.warnings, + }, null, 2)}\n`); + return 0; + } + process.stdout.write(`Wrote UI AST draft to ${outPath}\n`); + if (resolved.warnings.length > 0) { + for (const warning of resolved.warnings) { + process.stdout.write(`Warning: ${warning}\n`); + } + } + const escalations = resolved.ast.migration?.escalations ?? []; + if (escalations.length > 0) { + process.stdout.write("Escalations:\n"); + for (const escalation of escalations) { + process.stdout.write(`- [${escalation.surfaceId ?? "global"}] ${escalation.code}: ${escalation.message}\n`); + } + } + return 0; +} diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts index e72915e..558e8a5 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts @@ -15,31 +15,6 @@ export declare function buildPreparedGenerationPayload(bundle: LoadedCompiledSur evidenceRefs: any[]; authoring?: JsonRecord | undefined; runtime?: JsonRecord | undefined; - surface: { - surfaceId: string; - displayName: string; - type: string; - }; - bundle: { - root: string; - version: string; - manifestPath: string; - sourcePaths: { - authoring?: string | undefined; - runtime?: 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[]; @@ -60,16 +35,18 @@ export declare function buildPreparedGenerationPayload(bundle: LoadedCompiledSur governance: JsonRecord; adaptation: JsonRecord; guidance: JsonRecord; + platforms?: any[] | undefined; + ast?: JsonRecord | undefined; }; sections: any[]; components: any[]; constraints: JsonRecord; repairMap: any[]; -}; -export declare function loadPreparedGenerationPayload(bundleRoot: string, surfaceId: string, cwd?: string): { - evidenceRefs: any[]; - authoring?: JsonRecord | undefined; - runtime?: JsonRecord | undefined; + ast?: { + id: string; + version: string; + normalizedPath: string; + } | undefined; surface: { surfaceId: string; displayName: string; @@ -82,12 +59,15 @@ export declare function loadPreparedGenerationPayload(bundleRoot: string, surfac sourcePaths: { authoring?: string | undefined; runtime?: string | undefined; - contract: string; generation: string; sections: string; components: string; constraints: string; repairMap: string; + platforms?: string | undefined; + astSlice?: string | undefined; + contract: string; + ast?: string | undefined; }; }; contract: { @@ -95,6 +75,11 @@ export declare function loadPreparedGenerationPayload(bundleRoot: string, surfac version: string; normalizedPath: string; }; +}; +export declare function loadPreparedGenerationPayload(bundleRoot: string, surfaceId: string, cwd?: string): { + evidenceRefs: any[]; + authoring?: JsonRecord | undefined; + runtime?: JsonRecord | undefined; summary: { text: string; focusOrder: string[]; @@ -115,11 +100,46 @@ export declare function loadPreparedGenerationPayload(bundleRoot: string, surfac governance: JsonRecord; adaptation: JsonRecord; guidance: JsonRecord; + platforms?: any[] | undefined; + ast?: JsonRecord | undefined; }; sections: any[]; components: any[]; constraints: JsonRecord; repairMap: any[]; + ast?: { + id: string; + version: string; + normalizedPath: string; + } | undefined; + surface: { + surfaceId: string; + displayName: string; + type: string; + }; + bundle: { + root: string; + version: string; + manifestPath: string; + sourcePaths: { + authoring?: string | undefined; + runtime?: string | undefined; + generation: string; + sections: string; + components: string; + constraints: string; + repairMap: string; + platforms?: string | undefined; + astSlice?: string | undefined; + contract: string; + ast?: string | undefined; + }; + }; + contract: { + id: string; + version: string; + normalizedPath: string; + }; }; export declare function runPrepareGenerationCommand(options: PrepareGenerationCommandOptions): Promise; export {}; diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map index bac370c..d664f5b 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"prepare-generation.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-generation.ts"],"names":[],"mappings":"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;AA4LD,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBAjHnD,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;;;EAgLnE;AAED,wBAAgB,6BAA6B,CAC3C,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,SAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBArLU,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;;;EAyLnE;AAgBD,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,MAAM,CAAC,CA+BjB"} \ No newline at end of file +{"version":3,"file":"prepare-generation.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-generation.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,UAAU,EACf,KAAK,2BAA2B,EACjC,MAAM,sBAAsB,CAAC;AAG9B,MAAM,WAAW,+BAA+B;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,KAAK,cAAc,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;AAEhD,UAAU,iBAAiB;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,cAAc,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AA4LD,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,2BAA2B;;;;;;;;;;gBAjHnD,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsMnE;AAED,wBAAgB,6BAA6B,CAC3C,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,SAAgB;;;;;;;;;;gBA3MU,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+MnE;AAgBD,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,MAAM,CAAC,CA+BjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.js b/packages/interfacectl-cli/dist/commands/prepare-generation.js index ae3ecb4..669d12a 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-generation.js +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.js @@ -178,6 +178,12 @@ export function buildPreparedGenerationPayload(bundle) { const runtimeDoc = bundle.surface.runtime ? asRecord(bundle.surface.runtime.value) : undefined; + const astDoc = bundle.surface.ast + ? asRecord(bundle.surface.ast.value) + : undefined; + const platformsDoc = bundle.surface.platforms + ? asRecord(bundle.surface.platforms.value) + : undefined; const authoringDoc = bundle.surface.authoring ? asRecord(bundle.surface.authoring.value) : undefined; @@ -192,7 +198,10 @@ export function buildPreparedGenerationPayload(bundle) { version: bundle.version, manifestPath: bundle.manifest.path, sourcePaths: { + ...(bundle.ast ? { ast: bundle.ast.path } : {}), contract: bundle.contract.path, + ...(bundle.surface.ast ? { astSlice: bundle.surface.ast.path } : {}), + ...(bundle.surface.platforms ? { platforms: bundle.surface.platforms.path } : {}), generation: bundle.surface.generation.path, sections: bundle.surface.sections.path, components: bundle.surface.components.path, @@ -207,8 +216,21 @@ export function buildPreparedGenerationPayload(bundle) { version: bundle.contractVersion, normalizedPath: bundle.contract.path, }, + ...(bundle.ast + ? { + ast: { + id: asString(asRecord(bundle.ast.value).astId) ?? bundle.contractId, + version: asString(asRecord(bundle.ast.value).version) ?? bundle.contractVersion, + normalizedPath: bundle.ast.path, + }, + } + : {}), summary: buildSummary(bundle), generation: { + ...(astDoc && isRecord(astDoc.ast) ? { ast: astDoc.ast } : {}), + ...(platformsDoc && Array.isArray(platformsDoc.platforms) + ? { platforms: platformsDoc.platforms } + : {}), boundary: asRecord(generation.boundary), structure: asRecord(generation.structure), layout: asRecord(generation.layout), diff --git a/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts b/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts index 7e422df..31cf707 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts +++ b/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts @@ -5,6 +5,29 @@ export interface PrepareRuntimeCommandOptions { outPath?: string; } export declare function buildPreparedRuntimePayload(bundle: LoadedCompiledSurfaceBundle): { + summary: { + text: string; + requiredSectionIds: string[]; + mutationMode: string; + strictCategories: string[]; + contextIds: string[]; + checklist: { + id: string; + label: string; + detail: string; + }[]; + }; + governance: JsonRecord; + runtime: { + platforms?: any[] | undefined; + ast?: JsonRecord | undefined; + }; + evidenceRefs: any[]; + ast?: { + id: string; + version: string; + normalizedPath: string; + } | undefined; surface: { surfaceId: string; displayName: string; @@ -15,13 +38,16 @@ export declare function buildPreparedRuntimePayload(bundle: LoadedCompiledSurfac version: string; manifestPath: string; sourcePaths: { - contract: string; runtime: string; generation: string; sections: string; components: string; constraints: string; repairMap: string; + platforms?: string | undefined; + astSlice?: string | undefined; + contract: string; + ast?: string | undefined; }; }; contract: { @@ -29,6 +55,8 @@ export declare function buildPreparedRuntimePayload(bundle: LoadedCompiledSurfac version: string; normalizedPath: string; }; +}; +export declare function loadPreparedRuntimePayload(bundleRoot: string, surfaceId: string, cwd?: string): { summary: { text: string; requiredSectionIds: string[]; @@ -42,10 +70,16 @@ export declare function buildPreparedRuntimePayload(bundle: LoadedCompiledSurfac }[]; }; governance: JsonRecord; - runtime: JsonRecord; + runtime: { + platforms?: any[] | undefined; + ast?: JsonRecord | undefined; + }; evidenceRefs: any[]; -}; -export declare function loadPreparedRuntimePayload(bundleRoot: string, surfaceId: string, cwd?: string): { + ast?: { + id: string; + version: string; + normalizedPath: string; + } | undefined; surface: { surfaceId: string; displayName: string; @@ -56,13 +90,16 @@ export declare function loadPreparedRuntimePayload(bundleRoot: string, surfaceId version: string; manifestPath: string; sourcePaths: { - contract: string; runtime: string; generation: string; sections: string; components: string; constraints: string; repairMap: string; + platforms?: string | undefined; + astSlice?: string | undefined; + contract: string; + ast?: string | undefined; }; }; contract: { @@ -70,21 +107,6 @@ export declare function loadPreparedRuntimePayload(bundleRoot: string, surfaceId version: string; normalizedPath: string; }; - summary: { - text: string; - requiredSectionIds: string[]; - mutationMode: string; - strictCategories: string[]; - contextIds: string[]; - checklist: { - id: string; - label: string; - detail: string; - }[]; - }; - governance: JsonRecord; - runtime: JsonRecord; - evidenceRefs: any[]; }; export declare function runPrepareRuntimeCommand(options: PrepareRuntimeCommandOptions): Promise; //# sourceMappingURL=prepare-runtime.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts.map b/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts.map index 1eceac5..0aaafd0 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"prepare-runtime.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-runtime.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,UAAU,EACf,KAAK,2BAA2B,EACjC,MAAM,sBAAsB,CAAC;AAG9B,MAAM,WAAW,4BAA4B;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAgID,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBAvFhD,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;EAkInE;AAED,wBAAgB,0BAA0B,CACxC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,SAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBAvIU,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;EA2InE;AAgBD,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,4BAA4B,GACpC,OAAO,CAAC,MAAM,CAAC,CA+BjB"} \ No newline at end of file +{"version":3,"file":"prepare-runtime.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-runtime.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,UAAU,EACf,KAAK,2BAA2B,EACjC,MAAM,sBAAsB,CAAC;AAG9B,MAAM,WAAW,4BAA4B;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAgID,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,2BAA2B;;;;;;;;gBAvFhD,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0JnE;AAED,wBAAgB,0BAA0B,CACxC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,SAAgB;;;;;;;;gBA/JU,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAmKnE;AAgBD,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,4BAA4B,GACpC,OAAO,CAAC,MAAM,CAAC,CA+BjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/prepare-runtime.js b/packages/interfacectl-cli/dist/commands/prepare-runtime.js index 274f9a9..b1f08e0 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-runtime.js +++ b/packages/interfacectl-cli/dist/commands/prepare-runtime.js @@ -123,6 +123,12 @@ export function buildPreparedRuntimePayload(bundle) { const identity = asRecord(runtimeDoc.identity); const generation = asRecord(bundle.surface.generation.value); const generationRefs = asRecord(generation.refs); + const astDoc = bundle.surface.ast + ? asRecord(bundle.surface.ast.value) + : undefined; + const platformsDoc = bundle.surface.platforms + ? asRecord(bundle.surface.platforms.value) + : undefined; return { surface: { surfaceId: asString(identity.surfaceId) ?? bundle.surface.id, @@ -134,7 +140,10 @@ export function buildPreparedRuntimePayload(bundle) { version: bundle.version, manifestPath: bundle.manifest.path, sourcePaths: { + ...(bundle.ast ? { ast: bundle.ast.path } : {}), contract: bundle.contract.path, + ...(bundle.surface.ast ? { astSlice: bundle.surface.ast.path } : {}), + ...(bundle.surface.platforms ? { platforms: bundle.surface.platforms.path } : {}), runtime: bundle.surface.runtime.path, generation: bundle.surface.generation.path, sections: bundle.surface.sections.path, @@ -148,9 +157,24 @@ export function buildPreparedRuntimePayload(bundle) { version: bundle.contractVersion, normalizedPath: bundle.contract.path, }, + ...(bundle.ast + ? { + ast: { + id: asString(asRecord(bundle.ast.value).astId) ?? bundle.contractId, + version: asString(asRecord(bundle.ast.value).version) ?? bundle.contractVersion, + normalizedPath: bundle.ast.path, + }, + } + : {}), summary: buildSummary(bundle), governance: asRecord(runtimeDoc.governance), - runtime: asRecord(runtimeDoc.runtime), + runtime: { + ...(astDoc && isRecord(astDoc.ast) ? { ast: astDoc.ast } : {}), + ...(platformsDoc && Array.isArray(platformsDoc.platforms) + ? { platforms: platformsDoc.platforms } + : {}), + ...asRecord(runtimeDoc.runtime), + }, evidenceRefs: Array.isArray(generationRefs.evidence) ? generationRefs.evidence : [], }; } diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts b/packages/interfacectl-cli/dist/commands/validate.d.ts index 7ea649a..39c9ff3 100644 --- a/packages/interfacectl-cli/dist/commands/validate.d.ts +++ b/packages/interfacectl-cli/dist/commands/validate.d.ts @@ -2,6 +2,7 @@ import { type SurfaceDescriptor } from "@surfaces/interfacectl-validator"; import { type ExitCodeVersion } from "../utils/exit-codes.js"; type OutputFormat = "text" | "json"; export interface ValidateCommandOptions { + astPath?: string; contractPath?: string; schemaPath?: string; workspaceRoot?: string; diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts.map b/packages/interfacectl-cli/dist/commands/validate.d.ts.map index dd77947..5d29cea 100644 --- a/packages/interfacectl-cli/dist/commands/validate.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/validate.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAGA,OAAO,EAKL,KAAK,iBAAiB,EAIvB,MAAM,kCAAkC,CAAC;AAS1C,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAoCpC,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC1C,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAwWjB"} \ No newline at end of file +{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,iBAAiB,EAIvB,MAAM,kCAAkC,CAAC;AAS1C,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAQlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAoCpC,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC1C,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CA4SjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate.js b/packages/interfacectl-cli/dist/commands/validate.js index a76df39..f6c12e2 100644 --- a/packages/interfacectl-cli/dist/commands/validate.js +++ b/packages/interfacectl-cli/dist/commands/validate.js @@ -1,22 +1,14 @@ import path from "node:path"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import pc from "picocolors"; -import { validateContractStructure, evaluateContractCompliance, getBundledContractSchema, } from "@surfaces/interfacectl-validator"; +import { evaluateContractCompliance, } from "@surfaces/interfacectl-validator"; import { collectSurfaceDescriptors, } from "../descriptors/static-analysis.js"; import { observeRemotePage, } from "../utils/browser-session.js"; import { getExitCodeVersion } from "../utils/exit-codes.js"; import { classifyViolationType, getExitCodeForCategory, } from "../utils/violation-classifier.js"; +import { resolveUiAstInput } from "../utils/ui-ast.js"; export async function runValidateCommand(options) { const workspaceRoot = path.resolve(options.workspaceRoot ?? process.cwd()); - const contractInput = options.contractPath ?? "contracts/surfaces.web.contract.json"; - const contractPath = path.isAbsolute(contractInput) - ? contractInput - : path.resolve(workspaceRoot, contractInput); - const schemaPath = options.schemaPath - ? path.isAbsolute(options.schemaPath) - ? options.schemaPath - : path.resolve(workspaceRoot, options.schemaPath) - : undefined; const outputFormat = options.outputFormat ?? "text"; const isJson = outputFormat === "json"; const outputPath = options.outputPath @@ -33,6 +25,9 @@ export async function runValidateCommand(options) { capture: Boolean(outputPath) && !isJson, print: !isJson, }); + let resultContractPath = options.astPath ?? + options.contractPath ?? + path.resolve(workspaceRoot, "contracts/ui.surface.ast.json"); const findings = []; let surfaceRootMap = new Map(); let flowDescriptorPathMap = new Map(); @@ -40,7 +35,7 @@ export async function runValidateCommand(options) { const exitCodeVersion = getExitCodeVersion({ exitCodes: options.exitCodes }); const finalize = async (exitCode, contractVersion) => { if (isJson) { - const payload = buildJsonResult(contractPath, contractVersion ?? null, findings); + const payload = buildJsonResult(resultContractPath, contractVersion ?? null, findings); const serialized = `${JSON.stringify(payload, null, 2)}\n`; if (outputPath) { await writeFileWithParents(outputPath, serialized); @@ -58,24 +53,36 @@ export async function runValidateCommand(options) { } return exitCode; }; - const contractSource = await loadJson(contractPath, "contract"); - if (!contractSource.ok) { - const message = `Failed to read contract JSON: ${contractSource.error}`; + const resolvedInput = await resolveUiAstInput({ + workspaceRoot, + astPath: options.astPath, + contractPath: options.contractPath, + schemaPath: options.schemaPath, + }); + if ("error" in resolvedInput) { + const message = resolvedInput.error; if (!isJson) { - printHeader(pc.red("✖ Failed to read contract JSON"), textReporter); - textReporter.error(pc.red(contractSource.error)); + printHeader(pc.red("✖ Failed to resolve UI AST input"), textReporter); + textReporter.error(pc.red(message)); } findings.push({ - code: "contract.read-error", + code: resolvedInput.code, severity: "error", category: "E0", message, - location: contractPath, + location: options.astPath ?? options.contractPath, }); const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; return finalize(e0ExitCode, null); } - const initialContractVersion = extractContractVersion(contractSource.value); + for (const warning of resolvedInput.warnings) { + if (!isJson) { + textReporter.warn(pc.yellow(warning)); + } + } + const contractPath = resolvedInput.sourcePath; + resultContractPath = contractPath; + const initialContractVersion = resolvedInput.derivedContract.version; const configResult = await loadConfigFile(configPath); if (configResult.ok) { surfaceRootMap = new Map(Object.entries(configResult.config.surfaceRoots ?? {}).map(([surfaceId, surfaceRoot]) => [surfaceId, surfaceRoot])); @@ -97,61 +104,7 @@ export async function runValidateCommand(options) { const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; return finalize(e0ExitCode, initialContractVersion); } - const schemaSource = schemaPath - ? await loadJson(schemaPath, "schema", true) - : { - ok: true, - value: getBundledContractSchema(), - }; - const schema = schemaSource.ok === true ? schemaSource.value : undefined; - if (schemaSource.ok === false && !schemaSource.optional) { - const message = `Failed to read contract schema: ${schemaSource.error}`; - if (!isJson) { - printHeader(pc.red("✖ Failed to read contract schema"), textReporter); - textReporter.error(pc.red(schemaSource.error)); - } - findings.push({ - code: "contract.schema-load-error", - severity: "error", - category: "E0", - message, - location: schemaPath, - }); - const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; - return finalize(e0ExitCode, initialContractVersion); - } - const structureResult = schema - ? validateContractStructure(contractSource.value, schema) - : { - ok: true, - errors: [], - contract: contractSource.value, - }; - if (!structureResult.ok || !structureResult.contract) { - if (!isJson) { - printHeader(pc.red("✖ Contract schema validation failed (capability gap)"), textReporter); - textReporter.error(pc.dim("Schema validation errors indicate the contract structure is not supported by this version of interfacectl.")); - for (const error of structureResult.errors) { - textReporter.error(pc.red(` • ${error}`)); - } - } - else { - for (const error of structureResult.errors) { - // Check if this is an additionalProperties error (capability gap) - const isCapabilityGap = error.includes("Additional property") || - error.includes("is not allowed"); - findings.push({ - code: isCapabilityGap ? "contract.schema-unsupported-field" : "contract.schema-error", - severity: "error", - category: "E0", - message: error, - }); - } - } - const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; - return finalize(e0ExitCode, initialContractVersion); - } - const contract = structureResult.contract; + const contract = resolvedInput.derivedContract; const surfaceFilters = new Set((options.surfaceFilters ?? []).map((value) => value.trim())); let descriptorsWithFlowArtifacts; if (options.descriptorOverrides && options.descriptorOverrides.length > 0) { diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index a672d80..36cd96a 100755 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -7,6 +7,7 @@ import { runEnforceCommand } from "./commands/enforce.js"; import { runCompileCommand } from "./commands/compile.js"; import { runGenerateContractCommand } from "./commands/generate-contract.js"; import { runMigrateColorPolicyCommand } from "./commands/migrate-color-policy.js"; +import { runMigrateUiAstCommand } from "./commands/migrate-ui-ast.js"; import { runValidateExtractedCommand } from "./commands/validate-extracted.js"; import { runDescribeCommand } from "./commands/describe.js"; import { runPrepareGenerationCommand } from "./commands/prepare-generation.js"; @@ -22,12 +23,13 @@ import pkg from "../package.json" with { type: "json" }; const program = new Command(); program .name("interfacectl") - .description("Interface contract tooling for Surfaces") + .description("Governed UI AST tooling for Surfaces") .version(pkg.version ?? "0.0.0"); program .command("validate") - .description("Validate configured surfaces against the shared interface contract") - .option("--contract ", "Path to the contract JSON file") + .description("Validate configured surfaces against the canonical UI AST") + .option("--ast ", "Path to the UI AST JSON file") + .option("--contract ", "Deprecated migration-only legacy contract JSON path") .option("--schema ", "Optional path to the contract schema JSON file") .option("--config ", "Optional path to the interfacectl config JSON file (defaults to interfacectl.config.json)") .option("--root ", "Project root (defaults to current working directory)") @@ -44,10 +46,8 @@ program const workspaceRoot = typeof requestedRoot === "string" && requestedRoot.length > 0 ? requestedRoot : undefined; + const requestedAst = options.ast ?? env.SURFACES_AST ?? undefined; const requestedContract = options.contract ?? env.SURFACES_CONTRACT ?? undefined; - const contractPath = typeof requestedContract === "string" && requestedContract.length > 0 - ? requestedContract - : "contracts/surfaces.web.contract.json"; const requestedConfig = options.config ?? env.SURFACES_CONFIG ?? undefined; const formatInput = (options.format ?? (options.json ? "json" : undefined))?.toLowerCase(); const outputFormat = formatInput === "json" ? "json" : formatInput === "text" ? "text" : "text"; @@ -62,7 +62,8 @@ program ? options.exitCodes : undefined; const exitCode = await runValidateCommand({ - contractPath, + astPath: requestedAst, + contractPath: requestedContract, schemaPath: options.schema, workspaceRoot, surfaceFilters: options.surface ?? [], @@ -77,8 +78,9 @@ program }); program .command("diff") - .description("Compare contract against observed artifacts") - .option("--contract ", "Path to the contract JSON file") + .description("Compare the canonical UI AST against observed artifacts") + .option("--ast ", "Path to the UI AST JSON file") + .option("--contract ", "Deprecated migration-only legacy contract JSON path") .option("--schema ", "Optional path to the contract schema JSON file") .option("--config ", "Optional path to the interfacectl config JSON file (defaults to interfacectl.config.json)") .option("--root ", "Project root (defaults to current working directory)") @@ -97,10 +99,8 @@ program const workspaceRoot = typeof requestedRoot === "string" && requestedRoot.length > 0 ? requestedRoot : undefined; + const requestedAst = options.ast ?? env.SURFACES_AST ?? undefined; const requestedContract = options.contract ?? env.SURFACES_CONTRACT ?? undefined; - const contractPath = typeof requestedContract === "string" && requestedContract.length > 0 - ? requestedContract - : "contracts/surfaces.web.contract.json"; const requestedConfig = options.config ?? env.SURFACES_CONFIG ?? undefined; const formatInput = (options.format ?? (options.json ? "json" : undefined))?.toLowerCase(); const outputFormat = formatInput === "json" ? "json" : formatInput === "text" ? "text" : "text"; @@ -115,7 +115,8 @@ program ? options.exitCodes : undefined; const exitCode = await runDiffCommand({ - contractPath, + astPath: requestedAst, + contractPath: requestedContract, schemaPath: options.schema, workspaceRoot, surfaceFilters: options.surface ?? [], @@ -132,11 +133,12 @@ program }); program .command("enforce") - .description("Enforce policy on interface contract") + .description("Enforce policy on the canonical UI AST") .option("--mode ", "Enforcement mode (default: fail)") .option("--strict", "Alias for --mode fail (strict enforcement)") .option("--policy ", "Policy JSON path (optional, uses default if not provided)") - .option("--contract ", "Contract path") + .option("--ast ", "Path to the UI AST JSON file") + .option("--contract ", "Deprecated migration-only legacy contract JSON path") .option("--root ", "Workspace root") .option("--config ", "Config path") .option("--surface ", "Filter surfaces") @@ -151,6 +153,7 @@ program const workspaceRoot = typeof requestedRoot === "string" && requestedRoot.length > 0 ? requestedRoot : undefined; + const requestedAst = options.ast ?? env.SURFACES_AST ?? undefined; const requestedContract = options.contract ?? env.SURFACES_CONTRACT ?? undefined; const requestedConfig = options.config ?? env.SURFACES_CONFIG ?? undefined; const formatInput = (options.format ?? (options.json ? "json" : undefined))?.toLowerCase(); @@ -169,6 +172,7 @@ program mode: options.mode, strict: options.strict, policyPath: options.policy, + astPath: requestedAst, contractPath: requestedContract, workspaceRoot, surfaceFilters: options.surface ?? [], @@ -183,13 +187,15 @@ program }); program .command("compile") - .description("Produce a deterministic directory bundle for runtime consumption") - .requiredOption("--contract ", "Path to the contract JSON file") + .description("Produce a deterministic AST-first directory bundle for generation and runtime consumption") + .option("--ast ", "Path to the UI AST JSON file") + .option("--contract ", "Deprecated migration-only legacy contract JSON path") .requiredOption("--out ", "Output directory for the bundle") .option("--schema ", "Optional path to the contract schema JSON file") .option("--format ", "Output format (json)") .action(async (options) => { const exitCode = await runCompileCommand({ + astPath: options.ast, contractPath: options.contract, outDir: options.out, schemaPath: options.schema, @@ -197,6 +203,21 @@ program }, pkg.version ?? "0.0.0"); process.exitCode = exitCode; }); +program + .command("migrate-ui-ast") + .description("Import a legacy web surface contract into a UI AST draft") + .requiredOption("--contract ", "Path to the legacy contract JSON file") + .option("--out ", "Output path for the generated UI AST draft") + .option("--schema ", "Optional path to the legacy contract schema JSON file") + .option("--format ", "Output format (text|json)") + .action(async (options) => { + process.exitCode = await runMigrateUiAstCommand({ + contractPath: options.contract, + outPath: options.out, + schemaPath: options.schema, + format: options.format, + }); +}); program .command("prepare-generation") .description("Resolve a compiled generation bundle into one agent-ready JSON payload") diff --git a/packages/interfacectl-cli/dist/utils/ui-ast.d.ts b/packages/interfacectl-cli/dist/utils/ui-ast.d.ts new file mode 100644 index 0000000..a662ec3 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/ui-ast.d.ts @@ -0,0 +1,25 @@ +import { type InterfaceContract, type UiSurfaceAst } from "@surfaces/interfacectl-validator"; +export declare const DEFAULT_AST_PATH = "contracts/ui.surface.ast.json"; +export declare const DEFAULT_LEGACY_CONTRACT_PATH = "contracts/surfaces.web.contract.json"; +export interface ResolvedUiAstInput { + ast: UiSurfaceAst; + derivedContract: InterfaceContract; + sourceKind: "ast" | "legacy-contract"; + sourcePath: string; + warnings: string[]; +} +export interface ResolvedUiAstInputError { + error: string; + code: string; +} +interface ResolveUiAstInputOptions { + workspaceRoot: string; + astPath?: string; + contractPath?: string; + schemaPath?: string; +} +export declare function migrateLegacyContractToUiAst(contract: InterfaceContract): UiSurfaceAst; +export declare function deriveLegacyContractFromUiAst(ast: UiSurfaceAst): InterfaceContract; +export declare function resolveUiAstInput(options: ResolveUiAstInputOptions): Promise; +export {}; +//# sourceMappingURL=ui-ast.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/ui-ast.d.ts.map b/packages/interfacectl-cli/dist/utils/ui-ast.d.ts.map new file mode 100644 index 0000000..34a1c54 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/ui-ast.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ui-ast.d.ts","sourceRoot":"","sources":["../../src/utils/ui-ast.ts"],"names":[],"mappings":"AAEA,OAAO,EAQL,KAAK,iBAAiB,EAMtB,KAAK,YAAY,EAClB,MAAM,kCAAkC,CAAC;AAE1C,eAAO,MAAM,gBAAgB,kCAAkC,CAAC;AAChE,eAAO,MAAM,4BAA4B,yCAAyC,CAAC;AAGnF,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,YAAY,CAAC;IAClB,eAAe,EAAE,iBAAiB,CAAC;IACnC,UAAU,EAAE,KAAK,GAAG,iBAAiB,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,uBAAuB;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,wBAAwB;IAChC,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAmLD,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,iBAAiB,GAAG,YAAY,CAoBtF;AAwDD,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,YAAY,GAAG,iBAAiB,CAoDlF;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,kBAAkB,GAAG,uBAAuB,CAAC,CA4GvD"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/ui-ast.js b/packages/interfacectl-cli/dist/utils/ui-ast.js new file mode 100644 index 0000000..3ef9554 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/ui-ast.js @@ -0,0 +1,350 @@ +import path from "node:path"; +import { access, readFile } from "node:fs/promises"; +import { getBundledContractSchema, getBundledUiAstSchema, validateContractStructure, validateUiAstStructure, } from "@surfaces/interfacectl-validator"; +export const DEFAULT_AST_PATH = "contracts/ui.surface.ast.json"; +export const DEFAULT_LEGACY_CONTRACT_PATH = "contracts/surfaces.web.contract.json"; +const AST_SCHEMA_URL = "https://contracts.surfaces.local/ui.surface.ast.schema.json"; +async function fileExists(filePath) { + try { + await access(filePath); + return true; + } + catch { + return false; + } +} +async function loadJson(filePath, label) { + try { + const raw = await readFile(filePath, "utf8"); + return { + ok: true, + value: JSON.parse(raw), + }; + } + catch (error) { + if (error.code === "ENOENT") { + return { + ok: false, + error: `${label} file not found at ${filePath}`, + }; + } + return { + ok: false, + error: `Failed to read ${label} JSON at ${filePath}: ${error.message}`, + }; + } +} +function resolveCandidatePath(workspaceRoot, candidate) { + if (!candidate) + return undefined; + return path.isAbsolute(candidate) + ? candidate + : path.resolve(workspaceRoot, candidate); +} +function makeRootNodeId(surfaceId) { + return `${surfaceId}.root`; +} +function pickSectionOrder(surface) { + const landingPatternOrder = surface.layout.landingPattern?.sectionOrder ?? []; + const seen = new Set(); + const ordered = []; + for (const sectionId of [...landingPatternOrder, ...surface.requiredSections]) { + if (!sectionId || seen.has(sectionId)) { + continue; + } + seen.add(sectionId); + ordered.push(sectionId); + } + return ordered; +} +function buildSectionNode(section) { + return { + id: section.id, + kind: "section", + sectionId: section.id, + intent: section.intent, + label: section.intent, + description: section.description, + }; +} +function appendEscalation(escalations, surfaceId, code, message) { + escalations.push({ surfaceId, code, message }); +} +function migrateSurfaceToUiAst(surface, contract) { + const escalations = []; + const orderedSections = pickSectionOrder(surface); + const contractSections = new Map(contract.sections.map((section) => [section.id, section])); + const rootNodeId = makeRootNodeId(surface.id); + const nodes = [ + { + id: rootNodeId, + kind: "group", + label: surface.displayName, + description: `Root group for ${surface.displayName}.`, + children: orderedSections, + }, + ...orderedSections.map((sectionId) => buildSectionNode(contractSections.get(sectionId) ?? { + id: sectionId, + intent: "section", + description: `Migrated section ${sectionId}.`, + })), + ]; + if (surface.layout.landingPattern) { + appendEscalation(escalations, surface.id, "marketing.out-of-scope", "Legacy landingPattern metadata was preserved only in compatibility output. AST v1 is scoped to governed application surfaces."); + } + if (surface.marketingTypographyProfile || surface.marketingTypographyPolicy) { + appendEscalation(escalations, surface.id, "marketing.typography.out-of-scope", "Legacy marketing typography metadata does not map directly into the AST v1 application vocabulary."); + } + const states = surface.runtime?.contexts?.map((context) => ({ + id: context.id, + ...(context.kind ? { kind: context.kind } : {}), + ...(context.notes ? { description: context.notes } : {}), + })) ?? undefined; + const migratedSurface = { + id: surface.id, + displayName: surface.displayName, + kind: "application", + rootNodeId, + nodes, + platforms: [ + { + platform: "web", + ...(surface.domain ? { domain: surface.domain } : {}), + allowedFonts: surface.allowedFonts, + layout: { + maxContentWidth: surface.layout.maxContentWidth, + ...(surface.layout.requiredContainers + ? { requiredContainers: surface.layout.requiredContainers } + : {}), + ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), + ...(surface.layout.chromePolicy ? { chromePolicy: surface.layout.chromePolicy } : {}), + ...(surface.layout.targetAcquisition + ? { targetAcquisition: surface.layout.targetAcquisition } + : {}), + }, + ...(surface.mustNotEmit ? { mustNotEmit: surface.mustNotEmit } : {}), + ...(surface.shellOwnedPrimitiveAllowSources + ? { + shellOwnedPrimitiveAllowSources: surface.shellOwnedPrimitiveAllowSources, + } + : {}), + }, + ], + ...(states && states.length > 0 ? { states } : {}), + ...(surface.owner ? { owner: surface.owner } : {}), + ...(surface.phase0 ? { phase0: surface.phase0 } : {}), + ...(surface.governance ? { governance: surface.governance } : {}), + ...(surface.icons ? { icons: surface.icons } : {}), + ...(surface.flows ? { flows: surface.flows } : {}), + ...(surface.runtime ? { runtime: surface.runtime } : {}), + }; + return { + surface: migratedSurface, + escalations, + }; +} +export function migrateLegacyContractToUiAst(contract) { + const migratedSurfaces = contract.surfaces.map((surface) => migrateSurfaceToUiAst(surface, contract)); + return { + $schema: AST_SCHEMA_URL, + astId: contract.contractId, + version: contract.version, + ...(contract.description ? { description: contract.description } : {}), + constraints: contract.constraints, + color: contract.color, + ...(contract.tokens ? { tokens: contract.tokens } : {}), + ...(contract.shell ? { shell: contract.shell } : {}), + surfaces: migratedSurfaces.map((entry) => entry.surface), + migration: { + sourceFormat: "web.surface.contract@1", + escalations: migratedSurfaces.flatMap((entry) => entry.escalations), + }, + }; +} +function traverseSectionOrder(surface) { + const byId = new Map(surface.nodes.map((node) => [node.id, node])); + const ordered = []; + const seen = new Set(); + function visit(nodeId) { + if (seen.has(nodeId)) { + return; + } + seen.add(nodeId); + const node = byId.get(nodeId); + if (!node) { + return; + } + if (node.kind === "section") { + ordered.push(node); + } + for (const childId of node.children ?? []) { + visit(childId); + } + } + visit(surface.rootNodeId); + for (const node of surface.nodes) { + if (node.kind === "section" && !seen.has(node.id)) { + ordered.push(node); + } + } + return ordered; +} +function getWebProjection(surface) { + return surface.platforms.find((projection) => projection.platform === "web"); +} +function buildLegacySectionsFromAst(ast) { + const sections = new Map(); + for (const surface of ast.surfaces) { + for (const node of traverseSectionOrder(surface)) { + const sectionId = node.sectionId ?? node.id; + if (!sections.has(sectionId)) { + sections.set(sectionId, { + id: sectionId, + intent: node.intent ?? node.label ?? "section", + description: node.description ?? `AST section ${sectionId}.`, + }); + } + } + } + return [...sections.values()]; +} +export function deriveLegacyContractFromUiAst(ast) { + const sections = buildLegacySectionsFromAst(ast); + const surfaces = []; + for (const surface of ast.surfaces) { + const web = getWebProjection(surface); + if (!web?.layout) { + continue; + } + surfaces.push({ + id: surface.id, + displayName: surface.displayName, + type: "web", + requiredSections: traverseSectionOrder(surface).map((node) => node.sectionId ?? node.id), + allowedFonts: web.allowedFonts ?? [], + layout: { + maxContentWidth: web.layout.maxContentWidth, + ...(web.layout.requiredContainers + ? { requiredContainers: web.layout.requiredContainers } + : {}), + ...(web.layout.pageFrame ? { pageFrame: web.layout.pageFrame } : {}), + ...(web.layout.chromePolicy ? { chromePolicy: web.layout.chromePolicy } : {}), + ...(web.layout.targetAcquisition + ? { targetAcquisition: web.layout.targetAcquisition } + : {}), + }, + ...(surface.owner ? { owner: surface.owner } : {}), + ...(web.domain ? { domain: web.domain } : {}), + ...(surface.phase0 ? { phase0: surface.phase0 } : {}), + ...(surface.governance ? { governance: surface.governance } : {}), + ...(surface.icons ? { icons: surface.icons } : {}), + ...(surface.flows ? { flows: surface.flows } : {}), + ...(surface.runtime ? { runtime: surface.runtime } : {}), + ...(web.mustNotEmit ? { mustNotEmit: web.mustNotEmit } : {}), + ...(web.shellOwnedPrimitiveAllowSources + ? { shellOwnedPrimitiveAllowSources: web.shellOwnedPrimitiveAllowSources } + : {}), + }); + } + return { + contractId: ast.astId, + version: ast.version, + ...(ast.description ? { description: ast.description } : {}), + surfaces, + sections, + constraints: ast.constraints, + color: ast.color, + ...(ast.tokens ? { tokens: ast.tokens } : {}), + ...(ast.shell ? { shell: ast.shell } : {}), + }; +} +export async function resolveUiAstInput(options) { + const explicitAstPath = resolveCandidatePath(options.workspaceRoot, options.astPath); + const explicitContractPath = resolveCandidatePath(options.workspaceRoot, options.contractPath); + const defaultAstPath = path.resolve(options.workspaceRoot, DEFAULT_AST_PATH); + const defaultLegacyPath = path.resolve(options.workspaceRoot, DEFAULT_LEGACY_CONTRACT_PATH); + let sourcePath; + let sourceKind; + const warnings = []; + if (explicitAstPath) { + sourcePath = explicitAstPath; + sourceKind = "ast"; + } + else if (explicitContractPath) { + sourcePath = explicitContractPath; + sourceKind = "legacy-contract"; + warnings.push(`--contract is deprecated for UI AST v2. Prefer --ast ${DEFAULT_AST_PATH}.`); + } + else if (await fileExists(defaultAstPath)) { + sourcePath = defaultAstPath; + sourceKind = "ast"; + } + else { + sourcePath = defaultLegacyPath; + sourceKind = "legacy-contract"; + warnings.push(`Falling back to legacy contract path ${DEFAULT_LEGACY_CONTRACT_PATH}. Migrate to ${DEFAULT_AST_PATH}.`); + } + const source = await loadJson(sourcePath, sourceKind === "ast" ? "UI AST" : "contract"); + if (!source.ok) { + return { + error: source.error ?? "Unknown AST input error.", + code: sourceKind === "ast" ? "ui-ast.load-error" : "contract.load-error", + }; + } + if (sourceKind === "ast") { + const schemaSource = options.schemaPath + ? await loadJson(resolveCandidatePath(options.workspaceRoot, options.schemaPath) ?? options.schemaPath, "UI AST schema") + : { ok: true, value: getBundledUiAstSchema() }; + if (!schemaSource.ok) { + return { + error: schemaSource.error ?? "Failed to load UI AST schema.", + code: "ui-ast.schema-load-error", + }; + } + const validated = validateUiAstStructure(source.value, schemaSource.value); + if (!validated.ok || !validated.ast) { + return { + error: `UI AST schema validation failed:\n${validated.errors.map((error) => ` • ${error}`).join("\n")}`, + code: "ui-ast.schema.invalid", + }; + } + return { + ast: validated.ast, + derivedContract: deriveLegacyContractFromUiAst(validated.ast), + sourceKind, + sourcePath, + warnings, + }; + } + const schemaSource = options.schemaPath + ? await loadJson(resolveCandidatePath(options.workspaceRoot, options.schemaPath) ?? options.schemaPath, "contract schema") + : { ok: true, value: getBundledContractSchema() }; + if (!schemaSource.ok) { + return { + error: schemaSource.error ?? "Failed to load contract schema.", + code: "contract.schema-load-error", + }; + } + const validated = validateContractStructure(source.value, schemaSource.value); + if (!validated.ok || !validated.contract) { + return { + error: `Contract schema validation failed:\n${validated.errors.map((error) => ` • ${error}`).join("\n")}`, + code: "contract.schema.invalid", + }; + } + const ast = migrateLegacyContractToUiAst(validated.contract); + const astValidation = validateUiAstStructure(ast); + if (!astValidation.ok || !astValidation.ast) { + return { + error: `Generated UI AST draft failed validation:\n${astValidation.errors.map((error) => ` • ${error}`).join("\n")}`, + code: "ui-ast.migration.invalid", + }; + } + return { + ast: astValidation.ast, + derivedContract: validated.contract, + sourceKind, + sourcePath, + warnings, + }; +} diff --git a/packages/interfacectl-cli/schemas/prepare-generation-output.schema.json b/packages/interfacectl-cli/schemas/prepare-generation-output.schema.json index c09213b..5273678 100644 --- a/packages/interfacectl-cli/schemas/prepare-generation-output.schema.json +++ b/packages/interfacectl-cli/schemas/prepare-generation-output.schema.json @@ -65,10 +65,22 @@ "repairMap" ], "properties": { + "ast": { + "type": "string", + "minLength": 1 + }, "contract": { "type": "string", "minLength": 1 }, + "astSlice": { + "type": "string", + "minLength": 1 + }, + "platforms": { + "type": "string", + "minLength": 1 + }, "generation": { "type": "string", "minLength": 1 @@ -120,6 +132,25 @@ } } }, + "ast": { + "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, @@ -212,6 +243,15 @@ "additionalProperties": false, "required": ["boundary", "structure", "layout", "visual", "governance", "adaptation", "guidance"], "properties": { + "ast": { + "type": "object" + }, + "platforms": { + "type": "array", + "items": { + "type": "object" + } + }, "boundary": { "type": "object" }, diff --git a/packages/interfacectl-cli/schemas/prepare-runtime-output.schema.json b/packages/interfacectl-cli/schemas/prepare-runtime-output.schema.json index 530e9e3..92e2e67 100644 --- a/packages/interfacectl-cli/schemas/prepare-runtime-output.schema.json +++ b/packages/interfacectl-cli/schemas/prepare-runtime-output.schema.json @@ -63,10 +63,22 @@ "repairMap" ], "properties": { + "ast": { + "type": "string", + "minLength": 1 + }, "contract": { "type": "string", "minLength": 1 }, + "astSlice": { + "type": "string", + "minLength": 1 + }, + "platforms": { + "type": "string", + "minLength": 1 + }, "runtime": { "type": "string", "minLength": 1 @@ -114,6 +126,25 @@ } } }, + "ast": { + "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, diff --git a/packages/interfacectl-cli/src/adapter/bundle.ts b/packages/interfacectl-cli/src/adapter/bundle.ts index 6782304..97621bd 100644 --- a/packages/interfacectl-cli/src/adapter/bundle.ts +++ b/packages/interfacectl-cli/src/adapter/bundle.ts @@ -1,7 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -export const SUPPORTED_BUNDLE_VERSION = "2.0"; +export const SUPPORTED_BUNDLE_VERSION = "3.0"; +const SUPPORTED_BUNDLE_VERSIONS = new Set(["2.0", "3.0"]); export interface JsonRecord { [key: string]: unknown; @@ -24,10 +25,13 @@ export interface LoadedCompiledSurfaceBundle { contractId: string; contractVersion: string; manifest: LoadedJsonFile; + ast?: LoadedJsonFile; contract: LoadedJsonFile; surface: { id: string; dir: string; + ast?: LoadedJsonFile; + platforms?: LoadedJsonFile; generation: LoadedJsonFile; sections: LoadedJsonFile; components: LoadedJsonFile; @@ -108,9 +112,9 @@ export function loadCompiledSurfaceBundle( const manifestPath = path.join(bundleRoot, "manifest.json"); ensureReadableFile(manifestPath, "Bundle manifest"); const manifest = readJsonFile(manifestPath, "bundle manifest"); - if (manifest.bundleVersion !== SUPPORTED_BUNDLE_VERSION) { + if (!SUPPORTED_BUNDLE_VERSIONS.has(manifest.bundleVersion ?? "")) { throw new AdapterInputError( - `Unsupported bundle version "${manifest.bundleVersion ?? "unknown"}". Expected ${SUPPORTED_BUNDLE_VERSION}.`, + `Unsupported bundle version "${manifest.bundleVersion ?? "unknown"}". Expected one of ${[...SUPPORTED_BUNDLE_VERSIONS].join(", ")}.`, { code: "adapter.bundle.version-unsupported" }, ); } @@ -156,10 +160,25 @@ export function loadCompiledSurfaceBundle( }; const refs = isRecord(generation.value.refs) ? generation.value.refs : {}; + let ast: LoadedJsonFile | undefined; + if (manifest.bundleVersion === "3.0") { + const astRef = + typeof refs.ast === "string" && refs.ast.trim().length > 0 + ? refs.ast + : "../../ast/normalized.json"; + const astPath = path.resolve(path.dirname(generationPath), astRef); + ensureReadableFile(astPath, "Compiled UI AST"); + ast = { + path: astPath, + value: readJsonFile(astPath, "Compiled UI AST"), + }; + } const contractRef = typeof refs.contract === "string" && refs.contract.trim().length > 0 ? refs.contract - : "../../contract/normalized.json"; + : manifest.bundleVersion === "3.0" + ? "../../derived/contract.normalized.json" + : "../../contract/normalized.json"; const contractPath = path.resolve(path.dirname(generationPath), contractRef); ensureReadableFile(contractPath, "Compiled contract"); const contract = { @@ -190,6 +209,34 @@ export function loadCompiledSurfaceBundle( }; } + let surfaceAst: LoadedJsonFile | undefined; + if (manifest.bundleVersion === "3.0") { + const astSliceRef = + typeof refs.astSlice === "string" && refs.astSlice.trim().length > 0 + ? refs.astSlice + : "./ast.json"; + const astSlicePath = path.resolve(path.dirname(generationPath), astSliceRef); + ensureReadableFile(astSlicePath, "Surface AST bundle"); + surfaceAst = { + path: astSlicePath, + value: readJsonFile(astSlicePath, "Surface AST bundle"), + }; + } + + let platforms: LoadedJsonFile | undefined; + if (manifest.bundleVersion === "3.0") { + const platformsRef = + typeof refs.platforms === "string" && refs.platforms.trim().length > 0 + ? refs.platforms + : "./platforms.json"; + const platformsPath = path.resolve(path.dirname(generationPath), platformsRef); + ensureReadableFile(platformsPath, "Surface platform bundle"); + platforms = { + path: platformsPath, + value: readJsonFile(platformsPath, "Surface platform bundle"), + }; + } + const generationProvenance = isRecord(generation.value.provenance) ? generation.value.provenance : undefined; @@ -208,17 +255,20 @@ export function loadCompiledSurfaceBundle( return { root: bundleRoot, - version: SUPPORTED_BUNDLE_VERSION, + version: manifest.bundleVersion ?? SUPPORTED_BUNDLE_VERSION, contractId, contractVersion, manifest: { path: manifestPath, value: manifest, }, + ...(ast ? { ast } : {}), contract, surface: { id: surfaceId, dir: surfaceDir, + ...(surfaceAst ? { ast: surfaceAst } : {}), + ...(platforms ? { platforms } : {}), generation, sections, components, diff --git a/packages/interfacectl-cli/src/commands/compile.ts b/packages/interfacectl-cli/src/commands/compile.ts index dd74467..1c59e4a 100644 --- a/packages/interfacectl-cli/src/commands/compile.ts +++ b/packages/interfacectl-cli/src/commands/compile.ts @@ -11,16 +11,15 @@ import type { InterfaceContract, SurfaceRuntimeContextRule, TargetAcquisitionPolicy, -} from "@surfaces/interfacectl-validator"; -import { - validateContractStructure, - getBundledContractSchema, + UiAstSurface, + UiSurfaceAst, } from "@surfaces/interfacectl-validator"; import { normalizeContract } from "../utils/normalize.js"; +import { resolveUiAstInput } from "../utils/ui-ast.js"; -const BUNDLE_VERSION = "2.0"; +const BUNDLE_VERSION = "3.0"; -const SCHEMA_VERSION = "surfaces.web.contract@1"; +const SCHEMA_VERSION = "surfaces.ui.ast@2"; const DEFAULT_TARGET_ACQUISITION_MODALITY = "touch-mouse"; const DEFAULT_MIN_HIT_AREA_PX = 44; const DEFAULT_MIN_GAP_PX = 8; @@ -29,7 +28,8 @@ const DEFAULT_DESTRUCTIVE_GAP_PX = 16; const DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS = ["loading", "empty", "error"]; export interface CompileCommandOptions { - contractPath: string; + astPath?: string; + contractPath?: string; outDir: string; schemaPath?: string; format?: "json"; @@ -47,15 +47,20 @@ interface ManifestFileEntry { interface Manifest { bundleVersion: string; + astId: string; + astVersion: string; contractId: string; contractVersion: string; schemaVersion: string; + sourceFormat: "ui-ast"; tool: { name: string; version: string }; inputs: ManifestInputs; files: ManifestFileEntry[]; } interface BundleProvenance { + astId: string; + astVersion: string; contractId: string; contractVersion: string; bundleVersion: string; @@ -123,10 +128,13 @@ async function writeAtomic( } function makeBundleProvenance( + ast: UiSurfaceAst, contract: InterfaceContract, surfaceId?: string, ): BundleProvenance { return { + astId: ast.astId, + astVersion: ast.version, contractId: contract.contractId, contractVersion: contract.version, bundleVersion: BUNDLE_VERSION, @@ -461,6 +469,7 @@ function buildSectionOrderHints( } function buildSectionsPayload( + ast: UiSurfaceAst, contract: InterfaceContract, surface: ContractSurface, sections: ContractSection[], @@ -468,7 +477,7 @@ function buildSectionsPayload( const orderHints = buildSectionOrderHints(surface); return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), sections: sections.map((section) => { const hint = orderHints.get(section.id); return { @@ -524,6 +533,7 @@ function buildSectionsPayload( } function buildComponentsPayload( + ast: UiSurfaceAst, contract: InterfaceContract, surface: ContractSurface, components: ContractComponent[], @@ -541,7 +551,7 @@ function buildComponentsPayload( })); return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), components: catalog, }; } @@ -555,6 +565,7 @@ function resolveProfileById( } function buildConstraintsPayload( + ast: UiSurfaceAst, contract: InterfaceContract, surface: ContractSurface, ) { @@ -568,7 +579,7 @@ function buildConstraintsPayload( ); return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), constraints: { motion: contract.constraints.motion, color: contract.color, @@ -654,6 +665,37 @@ function buildGuidance( }; } +function buildAstPayload( + ast: UiSurfaceAst, + contract: InterfaceContract, + astSurface: UiAstSurface, + surface: ContractSurface, +) { + return { + provenance: makeBundleProvenance(ast, contract, surface.id), + ast: { + kind: astSurface.kind, + rootNodeId: astSurface.rootNodeId, + nodes: astSurface.nodes, + states: astSurface.states ?? [], + migrationEscalations: + ast.migration?.escalations.filter((entry) => entry.surfaceId === surface.id) ?? [], + }, + }; +} + +function buildPlatformsPayload( + ast: UiSurfaceAst, + contract: InterfaceContract, + astSurface: UiAstSurface, + surface: ContractSurface, +) { + return { + provenance: makeBundleProvenance(ast, contract, surface.id), + platforms: astSurface.platforms, + }; +} + function buildObservationRefs(contract: InterfaceContract): Array> { const refs: Array> = []; if (contract.x_extracted) { @@ -666,9 +708,11 @@ function buildObservationRefs(contract: InterfaceContract): Array platform.platform), + }, boundary: { shellOwns, contentSlot: contract.shell?.contentSlot ?? null, @@ -751,7 +801,10 @@ function buildGenerationPayload( adaptation, guidance: buildGuidance(contract, surface, sections), refs: { - contract: "../../contract/normalized.json", + ast: "../../ast/normalized.json", + contract: "../../derived/contract.normalized.json", + astSlice: "./ast.json", + platforms: "./platforms.json", sections: "./sections.json", components: "./components.json", constraints: "./constraints.json", @@ -764,13 +817,14 @@ function buildGenerationPayload( } function buildAuthoringPayload( + ast: UiSurfaceAst, contract: InterfaceContract, surface: ContractSurface, ) { if (!surface.authoring) return null; return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), authoring: { ...surface.authoring, sourcePriority: (surface.authoring.sourcePriority ?? []).map( @@ -791,6 +845,7 @@ function addRepair( } function buildRepairMapPayload( + ast: UiSurfaceAst, contract: InterfaceContract, surface: ContractSurface, sections: ContractSection[], @@ -1015,16 +1070,18 @@ function buildRepairMapPayload( } return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), repairs, }; } function buildRuntimePayload( + ast: UiSurfaceAst, contract: InterfaceContract, surface: ContractSurface, sections: ContractSection[], components: ContractComponent[], + astSurface: UiAstSurface, ) { const policySeverities = buildPolicySeverities(contract, surface); const mutationEnvelope = buildMutationEnvelope(surface, sections); @@ -1037,12 +1094,18 @@ function buildRuntimePayload( ); return { - provenance: makeBundleProvenance(contract, surface.id), + provenance: makeBundleProvenance(ast, contract, surface.id), identity: { surfaceId: surface.id, displayName: surface.displayName, type: surface.type, }, + ast: { + rootNodeId: astSurface.rootNodeId, + nodeCount: astSurface.nodes.length, + stateCount: astSurface.states?.length ?? 0, + platformIds: astSurface.platforms.map((platform) => platform.platform), + }, governance: buildGovernancePayload(surface), runtime: { policy: surface.runtime?.policy ?? policySeverities.runtime, @@ -1095,7 +1158,10 @@ function buildRuntimePayload( : {}), }, refs: { - contract: "../../contract/normalized.json", + ast: "../../ast/normalized.json", + contract: "../../derived/contract.normalized.json", + astSlice: "./ast.json", + platforms: "./platforms.json", sections: "./sections.json", components: "./components.json", constraints: "./constraints.json", @@ -1105,21 +1171,33 @@ function buildRuntimePayload( } function buildSurfaceBundleFiles( + ast: UiSurfaceAst, contract: InterfaceContract, surface: ContractSurface, + astSurface: UiAstSurface, ): 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 runtimePayload = buildRuntimePayload(contract, surface, sections, components); + const astPayload = buildAstPayload(ast, contract, astSurface, surface); + const platformsPayload = buildPlatformsPayload(ast, contract, astSurface, surface); + const constraintsPayload = buildConstraintsPayload(ast, contract, surface); + const generationPayload = buildGenerationPayload(ast, contract, surface, sections, astSurface); + const sectionsPayload = buildSectionsPayload(ast, contract, surface, sections); + const componentsPayload = buildComponentsPayload(ast, contract, surface, components); + const repairMapPayload = buildRepairMapPayload(ast, contract, surface, sections); + const authoringPayload = buildAuthoringPayload(ast, contract, surface); + const runtimePayload = buildRuntimePayload(ast, contract, surface, sections, components, astSurface); const files: BundleFile[] = [ + { + path: `${surfaceDir}/ast.json`, + content: stringifyDeterministic(astPayload), + }, + { + path: `${surfaceDir}/platforms.json`, + content: stringifyDeterministic(platformsPayload), + }, { path: `${surfaceDir}/generation.json`, content: stringifyDeterministic(generationPayload), @@ -1161,65 +1239,84 @@ export async function runCompileCommand( toolVersion: string, ): Promise { const outDir = path.resolve(options.outDir); - const contractInput = path.resolve(options.contractPath); - const schemaPath = options.schemaPath - ? path.resolve(options.schemaPath) - : undefined; - - let contractRaw: string; - try { - contractRaw = await readFile(contractInput, "utf8"); - } catch (err) { - const message = (err as NodeJS.ErrnoException).code === "ENOENT" - ? `Contract file not found: ${contractInput}` - : `Failed to read contract: ${(err as Error).message}`; - console.error(message); - return 1; - } + const workspaceRoot = process.cwd(); + const resolvedInput = await resolveUiAstInput({ + workspaceRoot, + astPath: options.astPath, + contractPath: options.contractPath, + schemaPath: options.schemaPath, + }); - let contractData: unknown; - try { - contractData = JSON.parse(contractRaw); - } catch (err) { - console.error(`Invalid contract JSON: ${(err as Error).message}`); + if ("error" in resolvedInput) { + console.error(resolvedInput.error); return 1; } - let schema: object; - if (schemaPath) { - try { - const raw = await readFile(schemaPath, "utf8"); - schema = JSON.parse(raw) as object; - } catch (err) { - const message = (err as NodeJS.ErrnoException).code === "ENOENT" - ? `Schema file not found: ${schemaPath}` - : `Failed to read schema: ${(err as Error).message}`; - console.error(message); - return 1; - } - } else { - schema = getBundledContractSchema(); + for (const warning of resolvedInput.warnings) { + console.error(`Warning: ${warning}`); } - const structureResult = validateContractStructure(contractData, schema); - if (!structureResult.ok || !structureResult.contract) { - console.error("Contract schema validation failed:"); - for (const error of structureResult.errors) { - console.error(` • ${error}`); - } - return 1; - } - - const contract = structureResult.contract; - const { contract: normalizedContract } = normalizeContract(contract); + const ast = resolvedInput.ast; + const { contract: normalizedContract } = normalizeContract( + resolvedInput.derivedContract, + ); + const surfaceMap = new Map(ast.surfaces.map((surface) => [surface.id, surface])); const bundleFiles: BundleFile[] = [ { - path: "contract/normalized.json", + path: "ast/normalized.json", + content: stringifyDeterministic(ast), + }, + { + path: "derived/contract.normalized.json", content: stringifyDeterministic(normalizedContract), }, ...normalizedContract.surfaces.flatMap((surface) => - buildSurfaceBundleFiles(normalizedContract, surface), + buildSurfaceBundleFiles( + ast, + normalizedContract, + surface, + surfaceMap.get(surface.id) ?? { + id: surface.id, + displayName: surface.displayName, + kind: "application", + rootNodeId: `${surface.id}.root`, + nodes: [ + { + id: `${surface.id}.root`, + kind: "group", + label: surface.displayName, + children: surface.requiredSections, + }, + ...surface.requiredSections.map((sectionId) => ({ + id: sectionId, + kind: "section" as const, + sectionId, + intent: "section", + label: sectionId, + })), + ], + platforms: [ + { + platform: "web", + allowedFonts: surface.allowedFonts, + layout: { + maxContentWidth: surface.layout.maxContentWidth, + ...(surface.layout.requiredContainers + ? { requiredContainers: surface.layout.requiredContainers } + : {}), + ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), + ...(surface.layout.chromePolicy + ? { chromePolicy: surface.layout.chromePolicy } + : {}), + ...(surface.layout.targetAcquisition + ? { targetAcquisition: surface.layout.targetAcquisition } + : {}), + }, + }, + ], + }, + ), ), ]; @@ -1233,13 +1330,16 @@ export async function runCompileCommand( const manifest: Manifest = { bundleVersion: BUNDLE_VERSION, + astId: ast.astId, + astVersion: ast.version, contractId: normalizedContract.contractId, contractVersion: normalizedContract.version, schemaVersion: SCHEMA_VERSION, + sourceFormat: "ui-ast", tool: { name: "interfacectl", version: toolVersion }, inputs: { - contractPath: options.contractPath, - schemaPath: schemaPath ?? null, + contractPath: resolvedInput.sourcePath, + schemaPath: options.schemaPath ?? null, }, files: fileEntries, }; diff --git a/packages/interfacectl-cli/src/commands/diff.ts b/packages/interfacectl-cli/src/commands/diff.ts index 88c772b..a2ba2b7 100644 --- a/packages/interfacectl-cli/src/commands/diff.ts +++ b/packages/interfacectl-cli/src/commands/diff.ts @@ -29,6 +29,7 @@ import { getExitCodeVersion, type ExitCodeVersion } from "../utils/exit-codes.js import { getMaxSeverity } from "../utils/violation-classifier.js"; import { applyPolicySeverityOverrides } from "../utils/apply-policy-severity.js"; import { enrichDiffEntry } from "../utils/traceability.js"; +import { resolveUiAstInput } from "../utils/ui-ast.js"; type OutputFormat = "text" | "json"; @@ -37,6 +38,7 @@ interface InterfacectlConfig { } export interface DiffCommandOptions { + astPath?: string; contractPath?: string; schemaPath?: string; workspaceRoot?: string; @@ -328,16 +330,6 @@ export async function runDiffCommand( const workspaceRoot = path.resolve( options.workspaceRoot ?? process.cwd(), ); - const contractInput = - options.contractPath ?? "contracts/surfaces.web.contract.json"; - const contractPath = path.isAbsolute(contractInput) - ? contractInput - : path.resolve(workspaceRoot, contractInput); - const schemaPath = options.schemaPath - ? path.isAbsolute(options.schemaPath) - ? options.schemaPath - : path.resolve(workspaceRoot, options.schemaPath) - : undefined; const outputFormat: OutputFormat = options.outputFormat ?? "text"; const isJson = outputFormat === "json"; const outputPath = options.outputPath @@ -381,15 +373,19 @@ export async function runDiffCommand( return exitCode; }; - // Load contract - const contractSource = await loadJson(contractPath, "contract"); - if (!contractSource.ok) { + const resolvedInput = await resolveUiAstInput({ + workspaceRoot, + astPath: options.astPath, + contractPath: options.contractPath, + schemaPath: options.schemaPath, + }); + if ("error" in resolvedInput) { const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; if (isJson) { const errorOutput: DiffOutput = { schemaVersion: "1.0.0", tool: { name: "interfacectl", version: pkg.version ?? "0.0.0" }, - contract: { path: contractPath, version: "unknown" }, + contract: { path: options.astPath ?? options.contractPath ?? "unknown", version: "unknown" }, observed: { root: workspaceRoot }, normalization: { enabled: normalizeEnabled, reorderedPaths: [], strippedPaths: [] }, summary: { @@ -401,53 +397,18 @@ export async function runDiffCommand( }; await finalize(e0ExitCode, errorOutput); } else { - console.error(`Failed to read contract: ${contractSource.error}`); + console.error(resolvedInput.error); } return e0ExitCode; } - const initialContractVersion = extractContractVersion(contractSource.value); - - // Validate contract structure - const schemaResult = schemaPath - ? await loadJson(schemaPath, "schema") - : { ok: false as const, error: "" }; - const schema = schemaResult.ok - ? schemaResult.value - : getBundledContractSchema(); - - const structureResult = validateContractStructure( - contractSource.value, - schema as object, - ); - - if (!structureResult.ok || !structureResult.contract) { - const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; - if (isJson) { - const errorOutput: DiffOutput = { - schemaVersion: "1.0.0", - tool: { name: "interfacectl", version: pkg.version ?? "0.0.0" }, - contract: { path: contractPath, version: initialContractVersion ?? "unknown" }, - observed: { root: workspaceRoot }, - normalization: { enabled: normalizeEnabled, reorderedPaths: [], strippedPaths: [] }, - summary: { - totalChanges: 0, - byType: { added: 0, removed: 0, modified: 0, renamed: 0 }, - bySeverity: { error: 0, warning: 0, info: 0 }, - }, - entries: [], - }; - await finalize(e0ExitCode, errorOutput); - } else { - console.error("Contract structure validation failed:"); - for (const error of structureResult.errors) { - console.error(` • ${error}`); - } + for (const warning of resolvedInput.warnings) { + if (!isJson) { + console.error(`Warning: ${warning}`); } - return e0ExitCode; } - - const contract = structureResult.contract; + const contractPath = resolvedInput.sourcePath; + const contract = resolvedInput.derivedContract; // Load config const configResult = await loadConfigFile(configPath); diff --git a/packages/interfacectl-cli/src/commands/enforce.ts b/packages/interfacectl-cli/src/commands/enforce.ts index 70ac16f..5f6b767 100644 --- a/packages/interfacectl-cli/src/commands/enforce.ts +++ b/packages/interfacectl-cli/src/commands/enforce.ts @@ -28,6 +28,7 @@ export interface EnforceCommandOptions { mode?: EnforcementMode; strict?: boolean; policyPath?: string; + astPath?: string; contractPath?: string; workspaceRoot?: string; surfaceFilters?: string[]; @@ -160,6 +161,7 @@ export async function runEnforceCommand( ); const diffResult = await runDiffCommand({ + astPath: options.astPath, contractPath: options.contractPath, workspaceRoot, surfaceFilters: options.surfaceFilters, diff --git a/packages/interfacectl-cli/src/commands/migrate-ui-ast.ts b/packages/interfacectl-cli/src/commands/migrate-ui-ast.ts new file mode 100644 index 0000000..cd294c4 --- /dev/null +++ b/packages/interfacectl-cli/src/commands/migrate-ui-ast.ts @@ -0,0 +1,73 @@ +import path from "node:path"; +import { writeDeterministicJson } from "../utils/deterministic-json.js"; +import { DEFAULT_AST_PATH, resolveUiAstInput } from "../utils/ui-ast.js"; + +export interface MigrateUiAstCommandOptions { + contractPath?: string; + outPath?: string; + schemaPath?: string; + format?: "text" | "json"; +} + +export async function runMigrateUiAstCommand( + options: MigrateUiAstCommandOptions, +): Promise { + if (!options.contractPath) { + console.error("--contract is required."); + return 1; + } + + const workspaceRoot = process.cwd(); + const resolved = await resolveUiAstInput({ + workspaceRoot, + contractPath: options.contractPath, + schemaPath: options.schemaPath, + }); + + if ("error" in resolved) { + console.error(resolved.error); + return 1; + } + + const outPath = path.resolve(options.outPath ?? DEFAULT_AST_PATH); + await writeDeterministicJson(outPath, resolved.ast); + + if (options.format === "json") { + process.stdout.write( + `${JSON.stringify( + { + status: "ok", + sourceKind: resolved.sourceKind, + sourcePath: resolved.sourcePath, + outPath, + astId: resolved.ast.astId, + version: resolved.ast.version, + surfaceIds: resolved.ast.surfaces.map((surface) => surface.id), + escalations: resolved.ast.migration?.escalations ?? [], + warnings: resolved.warnings, + }, + null, + 2, + )}\n`, + ); + return 0; + } + + process.stdout.write(`Wrote UI AST draft to ${outPath}\n`); + if (resolved.warnings.length > 0) { + for (const warning of resolved.warnings) { + process.stdout.write(`Warning: ${warning}\n`); + } + } + const escalations = resolved.ast.migration?.escalations ?? []; + if (escalations.length > 0) { + process.stdout.write("Escalations:\n"); + for (const escalation of escalations) { + process.stdout.write( + `- [${escalation.surfaceId ?? "global"}] ${escalation.code}: ${escalation.message}\n`, + ); + } + } + + return 0; +} diff --git a/packages/interfacectl-cli/src/commands/prepare-generation.ts b/packages/interfacectl-cli/src/commands/prepare-generation.ts index b9af696..a42e088 100644 --- a/packages/interfacectl-cli/src/commands/prepare-generation.ts +++ b/packages/interfacectl-cli/src/commands/prepare-generation.ts @@ -221,6 +221,12 @@ export function buildPreparedGenerationPayload(bundle: LoadedCompiledSurfaceBund const runtimeDoc = bundle.surface.runtime ? asRecord(bundle.surface.runtime.value) : undefined; + const astDoc = bundle.surface.ast + ? asRecord(bundle.surface.ast.value) + : undefined; + const platformsDoc = bundle.surface.platforms + ? asRecord(bundle.surface.platforms.value) + : undefined; const authoringDoc = bundle.surface.authoring ? asRecord(bundle.surface.authoring.value) : undefined; @@ -236,7 +242,10 @@ export function buildPreparedGenerationPayload(bundle: LoadedCompiledSurfaceBund version: bundle.version, manifestPath: bundle.manifest.path, sourcePaths: { + ...(bundle.ast ? { ast: bundle.ast.path } : {}), contract: bundle.contract.path, + ...(bundle.surface.ast ? { astSlice: bundle.surface.ast.path } : {}), + ...(bundle.surface.platforms ? { platforms: bundle.surface.platforms.path } : {}), generation: bundle.surface.generation.path, sections: bundle.surface.sections.path, components: bundle.surface.components.path, @@ -251,8 +260,21 @@ export function buildPreparedGenerationPayload(bundle: LoadedCompiledSurfaceBund version: bundle.contractVersion, normalizedPath: bundle.contract.path, }, + ...(bundle.ast + ? { + ast: { + id: asString(asRecord(bundle.ast.value).astId) ?? bundle.contractId, + version: asString(asRecord(bundle.ast.value).version) ?? bundle.contractVersion, + normalizedPath: bundle.ast.path, + }, + } + : {}), summary: buildSummary(bundle), generation: { + ...(astDoc && isRecord(astDoc.ast) ? { ast: astDoc.ast } : {}), + ...(platformsDoc && Array.isArray(platformsDoc.platforms) + ? { platforms: platformsDoc.platforms } + : {}), boundary: asRecord(generation.boundary), structure: asRecord(generation.structure), layout: asRecord(generation.layout), diff --git a/packages/interfacectl-cli/src/commands/prepare-runtime.ts b/packages/interfacectl-cli/src/commands/prepare-runtime.ts index 8b8f1bc..a003e18 100644 --- a/packages/interfacectl-cli/src/commands/prepare-runtime.ts +++ b/packages/interfacectl-cli/src/commands/prepare-runtime.ts @@ -153,6 +153,12 @@ export function buildPreparedRuntimePayload(bundle: LoadedCompiledSurfaceBundle) const identity = asRecord(runtimeDoc.identity); const generation = asRecord(bundle.surface.generation.value); const generationRefs = asRecord(generation.refs); + const astDoc = bundle.surface.ast + ? asRecord(bundle.surface.ast.value) + : undefined; + const platformsDoc = bundle.surface.platforms + ? asRecord(bundle.surface.platforms.value) + : undefined; return { surface: { @@ -165,7 +171,10 @@ export function buildPreparedRuntimePayload(bundle: LoadedCompiledSurfaceBundle) version: bundle.version, manifestPath: bundle.manifest.path, sourcePaths: { + ...(bundle.ast ? { ast: bundle.ast.path } : {}), contract: bundle.contract.path, + ...(bundle.surface.ast ? { astSlice: bundle.surface.ast.path } : {}), + ...(bundle.surface.platforms ? { platforms: bundle.surface.platforms.path } : {}), runtime: bundle.surface.runtime.path, generation: bundle.surface.generation.path, sections: bundle.surface.sections.path, @@ -179,9 +188,24 @@ export function buildPreparedRuntimePayload(bundle: LoadedCompiledSurfaceBundle) version: bundle.contractVersion, normalizedPath: bundle.contract.path, }, + ...(bundle.ast + ? { + ast: { + id: asString(asRecord(bundle.ast.value).astId) ?? bundle.contractId, + version: asString(asRecord(bundle.ast.value).version) ?? bundle.contractVersion, + normalizedPath: bundle.ast.path, + }, + } + : {}), summary: buildSummary(bundle), governance: asRecord(runtimeDoc.governance), - runtime: asRecord(runtimeDoc.runtime), + runtime: { + ...(astDoc && isRecord(astDoc.ast) ? { ast: astDoc.ast } : {}), + ...(platformsDoc && Array.isArray(platformsDoc.platforms) + ? { platforms: platformsDoc.platforms } + : {}), + ...asRecord(runtimeDoc.runtime), + }, evidenceRefs: Array.isArray(generationRefs.evidence) ? generationRefs.evidence : [], }; } diff --git a/packages/interfacectl-cli/src/commands/validate.ts b/packages/interfacectl-cli/src/commands/validate.ts index 8d14a62..a98a5cf 100644 --- a/packages/interfacectl-cli/src/commands/validate.ts +++ b/packages/interfacectl-cli/src/commands/validate.ts @@ -2,9 +2,7 @@ import path from "node:path"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import pc from "picocolors"; import { - validateContractStructure, evaluateContractCompliance, - getBundledContractSchema, type InterfaceContract, type SurfaceDescriptor, type ValidationSummary, @@ -25,6 +23,7 @@ import { getExitCodeForCategory, type ViolationCategory, } from "../utils/violation-classifier.js"; +import { resolveUiAstInput } from "../utils/ui-ast.js"; type OutputFormat = "text" | "json"; type FindingSeverity = "error" | "warning"; @@ -63,6 +62,7 @@ interface InterfacectlConfig { } export interface ValidateCommandOptions { + astPath?: string; contractPath?: string; schemaPath?: string; workspaceRoot?: string; @@ -82,16 +82,6 @@ export async function runValidateCommand( const workspaceRoot = path.resolve( options.workspaceRoot ?? process.cwd(), ); - const contractInput = - options.contractPath ?? "contracts/surfaces.web.contract.json"; - const contractPath = path.isAbsolute(contractInput) - ? contractInput - : path.resolve(workspaceRoot, contractInput); - const schemaPath = options.schemaPath - ? path.isAbsolute(options.schemaPath) - ? options.schemaPath - : path.resolve(workspaceRoot, options.schemaPath) - : undefined; const outputFormat: OutputFormat = options.outputFormat ?? "text"; const isJson = outputFormat === "json"; const outputPath = options.outputPath @@ -109,6 +99,10 @@ export async function runValidateCommand( capture: Boolean(outputPath) && !isJson, print: !isJson, }); + let resultContractPath = + options.astPath ?? + options.contractPath ?? + path.resolve(workspaceRoot, "contracts/ui.surface.ast.json"); const findings: JsonFinding[] = []; let surfaceRootMap = new Map(); @@ -123,7 +117,7 @@ export async function runValidateCommand( ) => { if (isJson) { const payload = buildJsonResult( - contractPath, + resultContractPath, contractVersion ?? null, findings, ); @@ -146,25 +140,37 @@ export async function runValidateCommand( return exitCode; }; - const contractSource = await loadJson(contractPath, "contract"); - if (!contractSource.ok) { - const message = `Failed to read contract JSON: ${contractSource.error}`; + const resolvedInput = await resolveUiAstInput({ + workspaceRoot, + astPath: options.astPath, + contractPath: options.contractPath, + schemaPath: options.schemaPath, + }); + if ("error" in resolvedInput) { + const message = resolvedInput.error; if (!isJson) { - printHeader(pc.red("✖ Failed to read contract JSON"), textReporter); - textReporter.error(pc.red(contractSource.error)); + printHeader(pc.red("✖ Failed to resolve UI AST input"), textReporter); + textReporter.error(pc.red(message)); } findings.push({ - code: "contract.read-error", + code: resolvedInput.code, severity: "error", category: "E0", message, - location: contractPath, + location: options.astPath ?? options.contractPath, }); const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; return finalize(e0ExitCode, null); } - const initialContractVersion = extractContractVersion(contractSource.value); + for (const warning of resolvedInput.warnings) { + if (!isJson) { + textReporter.warn(pc.yellow(warning)); + } + } + const contractPath = resolvedInput.sourcePath; + resultContractPath = contractPath; + const initialContractVersion = resolvedInput.derivedContract.version; const configResult = await loadConfigFile(configPath); if (configResult.ok) { @@ -197,73 +203,7 @@ export async function runValidateCommand( return finalize(e0ExitCode, initialContractVersion); } - const schemaSource = schemaPath - ? await loadJson(schemaPath, "schema", true) - : ({ - ok: true as const, - value: getBundledContractSchema(), - } satisfies { ok: true; value: object }); - - const schema = - schemaSource.ok === true ? (schemaSource.value as object) : undefined; - - if (schemaSource.ok === false && !schemaSource.optional) { - const message = `Failed to read contract schema: ${schemaSource.error}`; - if (!isJson) { - printHeader(pc.red("✖ Failed to read contract schema"), textReporter); - textReporter.error(pc.red(schemaSource.error)); - } - findings.push({ - code: "contract.schema-load-error", - severity: "error", - category: "E0", - message, - location: schemaPath, - }); - const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; - return finalize(e0ExitCode, initialContractVersion); - } - - const structureResult = schema - ? validateContractStructure(contractSource.value, schema) - : { - ok: true, - errors: [], - contract: contractSource.value as InterfaceContract, - }; - - if (!structureResult.ok || !structureResult.contract) { - if (!isJson) { - printHeader( - pc.red("✖ Contract schema validation failed (capability gap)"), - textReporter, - ); - textReporter.error( - pc.dim( - "Schema validation errors indicate the contract structure is not supported by this version of interfacectl.", - ), - ); - for (const error of structureResult.errors) { - textReporter.error(pc.red(` • ${error}`)); - } - } else { - for (const error of structureResult.errors) { - // Check if this is an additionalProperties error (capability gap) - const isCapabilityGap = error.includes("Additional property") || - error.includes("is not allowed"); - findings.push({ - code: isCapabilityGap ? "contract.schema-unsupported-field" : "contract.schema-error", - severity: "error", - category: "E0", - message: error, - }); - } - } - const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; - return finalize(e0ExitCode, initialContractVersion); - } - - const contract = structureResult.contract; + const contract = resolvedInput.derivedContract; const surfaceFilters = new Set( (options.surfaceFilters ?? []).map((value) => value.trim()), diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index 72bec07..bc580ef 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -8,6 +8,7 @@ import { runEnforceCommand } from "./commands/enforce.js"; import { runCompileCommand } from "./commands/compile.js"; import { runGenerateContractCommand } from "./commands/generate-contract.js"; import { runMigrateColorPolicyCommand } from "./commands/migrate-color-policy.js"; +import { runMigrateUiAstCommand } from "./commands/migrate-ui-ast.js"; import { runValidateExtractedCommand } from "./commands/validate-extracted.js"; import { runDescribeCommand } from "./commands/describe.js"; import { runPrepareGenerationCommand } from "./commands/prepare-generation.js"; @@ -41,15 +42,19 @@ const program = new Command(); program .name("interfacectl") - .description("Interface contract tooling for Surfaces") + .description("Governed UI AST tooling for Surfaces") .version(pkg.version ?? "0.0.0"); program .command("validate") - .description("Validate configured surfaces against the shared interface contract") + .description("Validate configured surfaces against the canonical UI AST") + .option( + "--ast ", + "Path to the UI AST JSON file", + ) .option( "--contract ", - "Path to the contract JSON file", + "Deprecated migration-only legacy contract JSON path", ) .option( "--schema ", @@ -87,12 +92,10 @@ program typeof requestedRoot === "string" && requestedRoot.length > 0 ? requestedRoot : undefined; + const requestedAst = + options.ast ?? env.SURFACES_AST ?? undefined; const requestedContract = options.contract ?? env.SURFACES_CONTRACT ?? undefined; - const contractPath = - typeof requestedContract === "string" && requestedContract.length > 0 - ? requestedContract - : "contracts/surfaces.web.contract.json"; const requestedConfig = options.config ?? env.SURFACES_CONFIG ?? undefined; const formatInput = ( @@ -119,7 +122,8 @@ program : undefined; const exitCode = await runValidateCommand({ - contractPath, + astPath: requestedAst, + contractPath: requestedContract, schemaPath: options.schema, workspaceRoot, surfaceFilters: options.surface ?? [], @@ -136,8 +140,9 @@ program program .command("diff") - .description("Compare contract against observed artifacts") - .option("--contract ", "Path to the contract JSON file") + .description("Compare the canonical UI AST against observed artifacts") + .option("--ast ", "Path to the UI AST JSON file") + .option("--contract ", "Deprecated migration-only legacy contract JSON path") .option("--schema ", "Optional path to the contract schema JSON file") .option( "--config ", @@ -170,12 +175,10 @@ program typeof requestedRoot === "string" && requestedRoot.length > 0 ? requestedRoot : undefined; + const requestedAst = + options.ast ?? env.SURFACES_AST ?? undefined; const requestedContract = options.contract ?? env.SURFACES_CONTRACT ?? undefined; - const contractPath = - typeof requestedContract === "string" && requestedContract.length > 0 - ? requestedContract - : "contracts/surfaces.web.contract.json"; const requestedConfig = options.config ?? env.SURFACES_CONFIG ?? undefined; const formatInput = ( @@ -202,7 +205,8 @@ program : undefined; const exitCode = await runDiffCommand({ - contractPath, + astPath: requestedAst, + contractPath: requestedContract, schemaPath: options.schema, workspaceRoot, surfaceFilters: options.surface ?? [], @@ -221,11 +225,12 @@ program program .command("enforce") - .description("Enforce policy on interface contract") + .description("Enforce policy on the canonical UI AST") .option("--mode ", "Enforcement mode (default: fail)") .option("--strict", "Alias for --mode fail (strict enforcement)") .option("--policy ", "Policy JSON path (optional, uses default if not provided)") - .option("--contract ", "Contract path") + .option("--ast ", "Path to the UI AST JSON file") + .option("--contract ", "Deprecated migration-only legacy contract JSON path") .option("--root ", "Workspace root") .option("--config ", "Config path") .option("--surface ", "Filter surfaces") @@ -242,6 +247,8 @@ program typeof requestedRoot === "string" && requestedRoot.length > 0 ? requestedRoot : undefined; + const requestedAst = + options.ast ?? env.SURFACES_AST ?? undefined; const requestedContract = options.contract ?? env.SURFACES_CONTRACT ?? undefined; const requestedConfig = @@ -273,6 +280,7 @@ program mode: options.mode, strict: options.strict, policyPath: options.policy, + astPath: requestedAst, contractPath: requestedContract, workspaceRoot, surfaceFilters: options.surface ?? [], @@ -289,14 +297,16 @@ program program .command("compile") - .description("Produce a deterministic directory bundle for runtime consumption") - .requiredOption("--contract ", "Path to the contract JSON file") + .description("Produce a deterministic AST-first directory bundle for generation and runtime consumption") + .option("--ast ", "Path to the UI AST JSON file") + .option("--contract ", "Deprecated migration-only legacy contract JSON path") .requiredOption("--out ", "Output directory for the bundle") .option("--schema ", "Optional path to the contract schema JSON file") .option("--format ", "Output format (json)") .action(async (options) => { const exitCode = await runCompileCommand( { + astPath: options.ast, contractPath: options.contract, outDir: options.out, schemaPath: options.schema, @@ -307,6 +317,22 @@ program process.exitCode = exitCode; }); +program + .command("migrate-ui-ast") + .description("Import a legacy web surface contract into a UI AST draft") + .requiredOption("--contract ", "Path to the legacy contract JSON file") + .option("--out ", "Output path for the generated UI AST draft") + .option("--schema ", "Optional path to the legacy contract schema JSON file") + .option("--format ", "Output format (text|json)") + .action(async (options) => { + process.exitCode = await runMigrateUiAstCommand({ + contractPath: options.contract, + outPath: options.out, + schemaPath: options.schema, + format: options.format, + }); + }); + program .command("prepare-generation") .description("Resolve a compiled generation bundle into one agent-ready JSON payload") diff --git a/packages/interfacectl-cli/src/utils/ui-ast.ts b/packages/interfacectl-cli/src/utils/ui-ast.ts new file mode 100644 index 0000000..bfedce4 --- /dev/null +++ b/packages/interfacectl-cli/src/utils/ui-ast.ts @@ -0,0 +1,461 @@ +import path from "node:path"; +import { access, readFile } from "node:fs/promises"; +import { + getBundledContractSchema, + getBundledUiAstSchema, + validateContractStructure, + validateUiAstStructure, + type ContractSection, + type ContractSurface, + type FlowPolicy, + type InterfaceContract, + type UiAstMigrationEscalation, + type UiAstNode, + type UiAstPlatformProjection, + type UiAstStateRef, + type UiAstSurface, + type UiSurfaceAst, +} from "@surfaces/interfacectl-validator"; + +export const DEFAULT_AST_PATH = "contracts/ui.surface.ast.json"; +export const DEFAULT_LEGACY_CONTRACT_PATH = "contracts/surfaces.web.contract.json"; +const AST_SCHEMA_URL = "https://contracts.surfaces.local/ui.surface.ast.schema.json"; + +export interface ResolvedUiAstInput { + ast: UiSurfaceAst; + derivedContract: InterfaceContract; + sourceKind: "ast" | "legacy-contract"; + sourcePath: string; + warnings: string[]; +} + +export interface ResolvedUiAstInputError { + error: string; + code: string; +} + +interface ResolveUiAstInputOptions { + workspaceRoot: string; + astPath?: string; + contractPath?: string; + schemaPath?: string; +} + +interface JsonLoadResult { + ok: boolean; + value?: unknown; + error?: string; +} + +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function loadJson(filePath: string, label: string): Promise { + try { + const raw = await readFile(filePath, "utf8"); + return { + ok: true, + value: JSON.parse(raw), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { + ok: false, + error: `${label} file not found at ${filePath}`, + }; + } + return { + ok: false, + error: `Failed to read ${label} JSON at ${filePath}: ${(error as Error).message}`, + }; + } +} + +function resolveCandidatePath( + workspaceRoot: string, + candidate: string | undefined, +): string | undefined { + if (!candidate) return undefined; + return path.isAbsolute(candidate) + ? candidate + : path.resolve(workspaceRoot, candidate); +} + +function makeRootNodeId(surfaceId: string): string { + return `${surfaceId}.root`; +} + +function pickSectionOrder(surface: ContractSurface): string[] { + const landingPatternOrder = surface.layout.landingPattern?.sectionOrder ?? []; + const seen = new Set(); + const ordered: string[] = []; + for (const sectionId of [...landingPatternOrder, ...surface.requiredSections]) { + if (!sectionId || seen.has(sectionId)) { + continue; + } + seen.add(sectionId); + ordered.push(sectionId); + } + return ordered; +} + +function buildSectionNode(section: ContractSection): UiAstNode { + return { + id: section.id, + kind: "section", + sectionId: section.id, + intent: section.intent, + label: section.intent, + description: section.description, + }; +} + +function appendEscalation( + escalations: UiAstMigrationEscalation[], + surfaceId: string, + code: string, + message: string, +) { + escalations.push({ surfaceId, code, message }); +} + +function migrateSurfaceToUiAst(surface: ContractSurface, contract: InterfaceContract) { + const escalations: UiAstMigrationEscalation[] = []; + const orderedSections = pickSectionOrder(surface); + const contractSections = new Map(contract.sections.map((section) => [section.id, section])); + const rootNodeId = makeRootNodeId(surface.id); + const nodes: UiAstNode[] = [ + { + id: rootNodeId, + kind: "group", + label: surface.displayName, + description: `Root group for ${surface.displayName}.`, + children: orderedSections, + }, + ...orderedSections.map((sectionId) => + buildSectionNode( + contractSections.get(sectionId) ?? { + id: sectionId, + intent: "section", + description: `Migrated section ${sectionId}.`, + }, + ), + ), + ]; + + if (surface.layout.landingPattern) { + appendEscalation( + escalations, + surface.id, + "marketing.out-of-scope", + "Legacy landingPattern metadata was preserved only in compatibility output. AST v1 is scoped to governed application surfaces.", + ); + } + if (surface.marketingTypographyProfile || surface.marketingTypographyPolicy) { + appendEscalation( + escalations, + surface.id, + "marketing.typography.out-of-scope", + "Legacy marketing typography metadata does not map directly into the AST v1 application vocabulary.", + ); + } + + const states: UiAstStateRef[] | undefined = + surface.runtime?.contexts?.map((context) => ({ + id: context.id, + ...(context.kind ? { kind: context.kind } : {}), + ...(context.notes ? { description: context.notes } : {}), + })) ?? undefined; + + const migratedSurface: UiAstSurface = { + id: surface.id, + displayName: surface.displayName, + kind: "application", + rootNodeId, + nodes, + platforms: [ + { + platform: "web", + ...(surface.domain ? { domain: surface.domain } : {}), + allowedFonts: surface.allowedFonts, + layout: { + maxContentWidth: surface.layout.maxContentWidth, + ...(surface.layout.requiredContainers + ? { requiredContainers: surface.layout.requiredContainers } + : {}), + ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), + ...(surface.layout.chromePolicy ? { chromePolicy: surface.layout.chromePolicy } : {}), + ...(surface.layout.targetAcquisition + ? { targetAcquisition: surface.layout.targetAcquisition } + : {}), + }, + ...(surface.mustNotEmit ? { mustNotEmit: surface.mustNotEmit } : {}), + ...(surface.shellOwnedPrimitiveAllowSources + ? { + shellOwnedPrimitiveAllowSources: surface.shellOwnedPrimitiveAllowSources, + } + : {}), + }, + ], + ...(states && states.length > 0 ? { states } : {}), + ...(surface.owner ? { owner: surface.owner } : {}), + ...(surface.phase0 ? { phase0: surface.phase0 } : {}), + ...(surface.governance ? { governance: surface.governance } : {}), + ...(surface.icons ? { icons: surface.icons } : {}), + ...(surface.flows ? { flows: surface.flows as FlowPolicy } : {}), + ...(surface.runtime ? { runtime: surface.runtime } : {}), + }; + + return { + surface: migratedSurface, + escalations, + }; +} + +export function migrateLegacyContractToUiAst(contract: InterfaceContract): UiSurfaceAst { + const migratedSurfaces = contract.surfaces.map((surface) => + migrateSurfaceToUiAst(surface, contract), + ); + + return { + $schema: AST_SCHEMA_URL, + astId: contract.contractId, + version: contract.version, + ...(contract.description ? { description: contract.description } : {}), + constraints: contract.constraints, + color: contract.color, + ...(contract.tokens ? { tokens: contract.tokens } : {}), + ...(contract.shell ? { shell: contract.shell } : {}), + surfaces: migratedSurfaces.map((entry) => entry.surface), + migration: { + sourceFormat: "web.surface.contract@1", + escalations: migratedSurfaces.flatMap((entry) => entry.escalations), + }, + }; +} + +function traverseSectionOrder(surface: UiAstSurface): UiAstNode[] { + const byId = new Map(surface.nodes.map((node) => [node.id, node])); + const ordered: UiAstNode[] = []; + const seen = new Set(); + + function visit(nodeId: string) { + if (seen.has(nodeId)) { + return; + } + seen.add(nodeId); + const node = byId.get(nodeId); + if (!node) { + return; + } + if (node.kind === "section") { + ordered.push(node); + } + for (const childId of node.children ?? []) { + visit(childId); + } + } + + visit(surface.rootNodeId); + + for (const node of surface.nodes) { + if (node.kind === "section" && !seen.has(node.id)) { + ordered.push(node); + } + } + + return ordered; +} + +function getWebProjection(surface: UiAstSurface): UiAstPlatformProjection | undefined { + return surface.platforms.find((projection) => projection.platform === "web"); +} + +function buildLegacySectionsFromAst(ast: UiSurfaceAst): ContractSection[] { + const sections = new Map(); + for (const surface of ast.surfaces) { + for (const node of traverseSectionOrder(surface)) { + const sectionId = node.sectionId ?? node.id; + if (!sections.has(sectionId)) { + sections.set(sectionId, { + id: sectionId, + intent: node.intent ?? node.label ?? "section", + description: node.description ?? `AST section ${sectionId}.`, + }); + } + } + } + return [...sections.values()]; +} + +export function deriveLegacyContractFromUiAst(ast: UiSurfaceAst): InterfaceContract { + const sections = buildLegacySectionsFromAst(ast); + const surfaces: ContractSurface[] = []; + for (const surface of ast.surfaces) { + const web = getWebProjection(surface); + if (!web?.layout) { + continue; + } + surfaces.push({ + id: surface.id, + displayName: surface.displayName, + type: "web", + requiredSections: traverseSectionOrder(surface).map( + (node) => node.sectionId ?? node.id, + ), + allowedFonts: web.allowedFonts ?? [], + layout: { + maxContentWidth: web.layout.maxContentWidth, + ...(web.layout.requiredContainers + ? { requiredContainers: web.layout.requiredContainers } + : {}), + ...(web.layout.pageFrame ? { pageFrame: web.layout.pageFrame } : {}), + ...(web.layout.chromePolicy ? { chromePolicy: web.layout.chromePolicy } : {}), + ...(web.layout.targetAcquisition + ? { targetAcquisition: web.layout.targetAcquisition } + : {}), + }, + ...(surface.owner ? { owner: surface.owner } : {}), + ...(web.domain ? { domain: web.domain } : {}), + ...(surface.phase0 ? { phase0: surface.phase0 } : {}), + ...(surface.governance ? { governance: surface.governance } : {}), + ...(surface.icons ? { icons: surface.icons } : {}), + ...(surface.flows ? { flows: surface.flows } : {}), + ...(surface.runtime ? { runtime: surface.runtime } : {}), + ...(web.mustNotEmit ? { mustNotEmit: web.mustNotEmit } : {}), + ...(web.shellOwnedPrimitiveAllowSources + ? { shellOwnedPrimitiveAllowSources: web.shellOwnedPrimitiveAllowSources } + : {}), + }); + } + + return { + contractId: ast.astId, + version: ast.version, + ...(ast.description ? { description: ast.description } : {}), + surfaces, + sections, + constraints: ast.constraints, + color: ast.color, + ...(ast.tokens ? { tokens: ast.tokens } : {}), + ...(ast.shell ? { shell: ast.shell } : {}), + }; +} + +export async function resolveUiAstInput( + options: ResolveUiAstInputOptions, +): Promise { + const explicitAstPath = resolveCandidatePath(options.workspaceRoot, options.astPath); + const explicitContractPath = resolveCandidatePath(options.workspaceRoot, options.contractPath); + const defaultAstPath = path.resolve(options.workspaceRoot, DEFAULT_AST_PATH); + const defaultLegacyPath = path.resolve( + options.workspaceRoot, + DEFAULT_LEGACY_CONTRACT_PATH, + ); + + let sourcePath: string; + let sourceKind: "ast" | "legacy-contract"; + const warnings: string[] = []; + + if (explicitAstPath) { + sourcePath = explicitAstPath; + sourceKind = "ast"; + } else if (explicitContractPath) { + sourcePath = explicitContractPath; + sourceKind = "legacy-contract"; + warnings.push( + `--contract is deprecated for UI AST v2. Prefer --ast ${DEFAULT_AST_PATH}.`, + ); + } else if (await fileExists(defaultAstPath)) { + sourcePath = defaultAstPath; + sourceKind = "ast"; + } else { + sourcePath = defaultLegacyPath; + sourceKind = "legacy-contract"; + warnings.push( + `Falling back to legacy contract path ${DEFAULT_LEGACY_CONTRACT_PATH}. Migrate to ${DEFAULT_AST_PATH}.`, + ); + } + + const source = await loadJson( + sourcePath, + sourceKind === "ast" ? "UI AST" : "contract", + ); + if (!source.ok) { + return { + error: source.error ?? "Unknown AST input error.", + code: sourceKind === "ast" ? "ui-ast.load-error" : "contract.load-error", + }; + } + + if (sourceKind === "ast") { + const schemaSource = options.schemaPath + ? await loadJson( + resolveCandidatePath(options.workspaceRoot, options.schemaPath) ?? options.schemaPath, + "UI AST schema", + ) + : { ok: true, value: getBundledUiAstSchema() }; + if (!schemaSource.ok) { + return { + error: schemaSource.error ?? "Failed to load UI AST schema.", + code: "ui-ast.schema-load-error", + }; + } + const validated = validateUiAstStructure(source.value, schemaSource.value as object); + if (!validated.ok || !validated.ast) { + return { + error: `UI AST schema validation failed:\n${validated.errors.map((error) => ` • ${error}`).join("\n")}`, + code: "ui-ast.schema.invalid", + }; + } + return { + ast: validated.ast, + derivedContract: deriveLegacyContractFromUiAst(validated.ast), + sourceKind, + sourcePath, + warnings, + }; + } + + const schemaSource = options.schemaPath + ? await loadJson( + resolveCandidatePath(options.workspaceRoot, options.schemaPath) ?? options.schemaPath, + "contract schema", + ) + : { ok: true, value: getBundledContractSchema() }; + if (!schemaSource.ok) { + return { + error: schemaSource.error ?? "Failed to load contract schema.", + code: "contract.schema-load-error", + }; + } + const validated = validateContractStructure(source.value, schemaSource.value as object); + if (!validated.ok || !validated.contract) { + return { + error: `Contract schema validation failed:\n${validated.errors.map((error) => ` • ${error}`).join("\n")}`, + code: "contract.schema.invalid", + }; + } + const ast = migrateLegacyContractToUiAst(validated.contract); + const astValidation = validateUiAstStructure(ast); + if (!astValidation.ok || !astValidation.ast) { + return { + error: `Generated UI AST draft failed validation:\n${astValidation.errors.map((error) => ` • ${error}`).join("\n")}`, + code: "ui-ast.migration.invalid", + }; + } + + return { + ast: astValidation.ast, + derivedContract: validated.contract, + sourceKind, + sourcePath, + warnings, + }; +} diff --git a/packages/interfacectl-cli/test/compile.test.mjs b/packages/interfacectl-cli/test/compile.test.mjs index b394aa1..39d5aac 100644 --- a/packages/interfacectl-cli/test/compile.test.mjs +++ b/packages/interfacectl-cli/test/compile.test.mjs @@ -22,11 +22,11 @@ const fixtureDir = path.resolve(__dirname, "fixtures", "compile"); const contractPath = path.join(fixtureDir, "contract", "ui.contract.json"); const expectedDir = path.join(fixtureDir, "expected"); -async function runCompile(contract, outDir, schemaPath = undefined) { +async function runCompile(inputPath, outDir, schemaPath = undefined, inputFlag = "--contract") { const args = [ "compile", - "--contract", - contract, + inputFlag, + inputPath, "--out", outDir, ]; @@ -35,7 +35,7 @@ async function runCompile(contract, outDir, schemaPath = undefined) { } const child = spawn("node", [cliPath, ...args], { env: process.env, - cwd: path.dirname(contract), + cwd: path.dirname(inputPath), }); let stdout = ""; @@ -67,20 +67,31 @@ 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, "2.0"); + assert.equal(manifest.bundleVersion, "3.0"); + assert.equal(manifest.astId, "demo-ui"); + assert.equal(manifest.astVersion, "1.0.0"); assert.equal(manifest.contractId, "demo-ui"); assert.equal(manifest.contractVersion, "1.0.0"); + assert.equal(manifest.schemaVersion, "surfaces.ui.ast@2"); + assert.equal(manifest.sourceFormat, "ui-ast"); assert.ok(Array.isArray(manifest.files)); - assert.ok(manifest.files.length >= 7); + assert.ok(manifest.files.length >= 10); 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("ast/normalized.json"), "bundle must include ast/normalized.json"); + assert.ok( + paths.includes("derived/contract.normalized.json"), + "bundle must include derived/contract.normalized.json", + ); + assert.ok(paths.includes("surfaces/demo-surface/ast.json"), "bundle must include ast.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/platforms.json"), "bundle must include platforms.json"); assert.ok(paths.includes("surfaces/demo-surface/repair-map.json"), "bundle must include repair-map.json"); assert.ok(paths.includes("surfaces/demo-surface/runtime.json"), "bundle must include runtime.json"); + assert.ok(!paths.includes("contract/normalized.json"), "legacy contract path must not be canonical"); assert.ok(!paths.includes("surfaces/demo-surface/authoring.json"), "authoring.json should be omitted when authoring is absent"); for (const entry of manifest.files) { @@ -91,9 +102,9 @@ 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, "derived", "contract.normalized.json"); const contractStat = await stat(contractNorm); - assert.ok(contractStat.isFile(), "contract/normalized.json must be a file"); + assert.ok(contractStat.isFile(), "derived/contract.normalized.json must be a file"); const surfaceDir = path.join(outDir, "surfaces", "demo-surface"); const surfaceDirStat = await stat(surfaceDir); @@ -101,7 +112,16 @@ test("compile: structure - required files exist and no extra files", async () => const surfaceFiles = await readdir(surfaceDir); assert.deepEqual( surfaceFiles.sort(), - ["components.json", "constraints.json", "generation.json", "repair-map.json", "runtime.json", "sections.json"], + [ + "ast.json", + "components.json", + "constraints.json", + "generation.json", + "platforms.json", + "repair-map.json", + "runtime.json", + "sections.json", + ], "surface bundle should only include the expected generation files for the base fixture", ); } finally { @@ -138,11 +158,28 @@ test("compile: golden - generated generation bundle files match expected", async 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 expectedAst = await readJson(path.join(expectedDir, "ast", "normalized.json")); + const generatedAst = await readJson(path.join(outDir, "ast", "normalized.json")); + assert.deepEqual(generatedAst, expectedAst, "ast/normalized.json must match expected"); + + const expectedContract = await readJson(path.join(expectedDir, "derived", "contract.normalized.json")); + const generatedContract = await readJson(path.join(outDir, "derived", "contract.normalized.json")); + assert.deepEqual( + generatedContract, + expectedContract, + "derived/contract.normalized.json must match expected", + ); - for (const filename of ["generation.json", "sections.json", "components.json", "constraints.json", "repair-map.json", "runtime.json"]) { + for (const filename of [ + "ast.json", + "generation.json", + "sections.json", + "components.json", + "constraints.json", + "platforms.json", + "repair-map.json", + "runtime.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`); @@ -476,6 +513,92 @@ test("compile: includes component catalog refs, authoring hints, and observation } }); +test("compile: AST input preserves multi-platform projections in bundle output", async () => { + const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-ast-")); + const astPath = path.join(outDir, "ui.surface.ast.json"); + try { + await writeFile( + astPath, + JSON.stringify( + { + astId: "multi-platform-demo", + version: "1.0.0", + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "off", + allowedValues: [], + }, + surfaces: [ + { + id: "demo-surface", + displayName: "Demo Surface", + kind: "application", + rootNodeId: "demo-surface.root", + nodes: [ + { + id: "demo-surface.root", + kind: "group", + label: "Demo Surface", + children: ["main.hero"], + }, + { + id: "main.hero", + kind: "section", + sectionId: "main.hero", + label: "Primary Intro", + }, + ], + platforms: [ + { + platform: "web", + allowedFonts: ["Demo Sans", "sans-serif"], + layout: { + maxContentWidth: 960, + }, + }, + { + platform: "ios", + path: "/demo", + layout: { + maxContentWidth: 960, + }, + }, + ], + }, + ], + }, + null, + 2, + ), + "utf8", + ); + + const result = await runCompile(astPath, outDir, undefined, "--ast"); + assert.equal(result.exitCode, 0, `compile should exit 0: ${result.stderr}`); + + const manifest = await readJson(path.join(outDir, "manifest.json")); + assert.equal(manifest.bundleVersion, "3.0"); + + const generation = await readJson(path.join(outDir, "surfaces", "demo-surface", "generation.json")); + assert.deepEqual(generation.ast.platformIds, ["web", "ios"]); + assert.equal(generation.refs.platforms, "./platforms.json"); + + const platforms = await readJson(path.join(outDir, "surfaces", "demo-surface", "platforms.json")); + assert.deepEqual( + platforms.platforms.map((projection) => projection.platform), + ["web", "ios"], + "platform bundle must preserve both projections deterministically", + ); + } finally { + await rm(outDir, { recursive: true, force: true }); + } +}); + 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"); diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/ast/normalized.json b/packages/interfacectl-cli/test/fixtures/compile/expected/ast/normalized.json new file mode 100644 index 0000000..fa71a97 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/ast/normalized.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://contracts.surfaces.local/ui.surface.ast.schema.json", + "astId": "demo-ui", + "color": { + "allowedValues": [], + "policy": "off" + }, + "constraints": { + "motion": { + "allowedDurationsMs": [ + 120 + ], + "allowedTimingFunctions": [ + "linear" + ] + } + }, + "migration": { + "escalations": [], + "sourceFormat": "web.surface.contract@1" + }, + "surfaces": [ + { + "displayName": "Demo Surface", + "id": "demo-surface", + "kind": "application", + "nodes": [ + { + "children": [ + "main.hero" + ], + "description": "Root group for Demo Surface.", + "id": "demo-surface.root", + "kind": "group", + "label": "Demo Surface" + }, + { + "description": "Demo hero section", + "id": "main.hero", + "intent": "primary-intro", + "kind": "section", + "label": "primary-intro", + "sectionId": "main.hero" + } + ], + "platforms": [ + { + "allowedFonts": [ + "var(--font-demo)", + "Demo Sans", + "sans-serif" + ], + "layout": { + "maxContentWidth": 960 + }, + "platform": "web" + } + ], + "rootNodeId": "demo-surface.root" + } + ], + "version": "1.0.0" +} diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/contract/normalized.json b/packages/interfacectl-cli/test/fixtures/compile/expected/derived/contract.normalized.json similarity index 100% rename from packages/interfacectl-cli/test/fixtures/compile/expected/contract/normalized.json rename to packages/interfacectl-cli/test/fixtures/compile/expected/derived/contract.normalized.json diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/ast.json b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/ast.json new file mode 100644 index 0000000..b5e13bd --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/ast.json @@ -0,0 +1,35 @@ +{ + "ast": { + "kind": "application", + "migrationEscalations": [], + "nodes": [ + { + "children": [ + "main.hero" + ], + "description": "Root group for Demo Surface.", + "id": "demo-surface.root", + "kind": "group", + "label": "Demo Surface" + }, + { + "description": "Demo hero section", + "id": "main.hero", + "intent": "primary-intro", + "kind": "section", + "label": "primary-intro", + "sectionId": "main.hero" + } + ], + "rootNodeId": "demo-surface.root", + "states": [] + }, + "provenance": { + "astId": "demo-ui", + "astVersion": "1.0.0", + "bundleVersion": "3.0", + "contractId": "demo-ui", + "contractVersion": "1.0.0", + "surfaceId": "demo-surface" + } +} 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 index 9c4304f..c77dfc8 100644 --- 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 @@ -1,7 +1,9 @@ { "components": [], "provenance": { - "bundleVersion": "2.0", + "astId": "demo-ui", + "astVersion": "1.0.0", + "bundleVersion": "3.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 index e326e32..019ba8c 100644 --- 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 @@ -19,7 +19,9 @@ } }, "provenance": { - "bundleVersion": "2.0", + "astId": "demo-ui", + "astVersion": "1.0.0", + "bundleVersion": "3.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 index 7d633ab..cc0dbb9 100644 --- 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 @@ -12,6 +12,14 @@ }, "policy": "strict" }, + "ast": { + "nodeCount": 2, + "platformIds": [ + "web" + ], + "rootNodeId": "demo-surface.root", + "stateCount": 0 + }, "boundary": { "allowSources": [], "contentSlot": null, @@ -85,15 +93,20 @@ "viewportIds": [] }, "provenance": { - "bundleVersion": "2.0", + "astId": "demo-ui", + "astVersion": "1.0.0", + "bundleVersion": "3.0", "contractId": "demo-ui", "contractVersion": "1.0.0", "surfaceId": "demo-surface" }, "refs": { + "ast": "../../ast/normalized.json", + "astSlice": "./ast.json", "components": "./components.json", "constraints": "./constraints.json", - "contract": "../../contract/normalized.json", + "contract": "../../derived/contract.normalized.json", + "platforms": "./platforms.json", "repairMap": "./repair-map.json", "runtime": "./runtime.json", "sections": "./sections.json" diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/platforms.json b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/platforms.json new file mode 100644 index 0000000..0c9bdb6 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/platforms.json @@ -0,0 +1,23 @@ +{ + "platforms": [ + { + "allowedFonts": [ + "var(--font-demo)", + "Demo Sans", + "sans-serif" + ], + "layout": { + "maxContentWidth": 960 + }, + "platform": "web" + } + ], + "provenance": { + "astId": "demo-ui", + "astVersion": "1.0.0", + "bundleVersion": "3.0", + "contractId": "demo-ui", + "contractVersion": "1.0.0", + "surfaceId": "demo-surface" + } +} 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 index 1dc8a04..99c1346 100644 --- 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 @@ -1,6 +1,8 @@ { "provenance": { - "bundleVersion": "2.0", + "astId": "demo-ui", + "astVersion": "1.0.0", + "bundleVersion": "3.0", "contractId": "demo-ui", "contractVersion": "1.0.0", "surfaceId": "demo-surface" diff --git a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/runtime.json b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/runtime.json index f349c59..9807929 100644 --- a/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/runtime.json +++ b/packages/interfacectl-cli/test/fixtures/compile/expected/surfaces/demo-surface/runtime.json @@ -1,4 +1,12 @@ { + "ast": { + "nodeCount": 2, + "platformIds": [ + "web" + ], + "rootNodeId": "demo-surface.root", + "stateCount": 0 + }, "governance": { "approvals": [], "domain": null, @@ -13,15 +21,20 @@ "type": "web" }, "provenance": { - "bundleVersion": "2.0", + "astId": "demo-ui", + "astVersion": "1.0.0", + "bundleVersion": "3.0", "contractId": "demo-ui", "contractVersion": "1.0.0", "surfaceId": "demo-surface" }, "refs": { + "ast": "../../ast/normalized.json", + "astSlice": "./ast.json", "components": "./components.json", "constraints": "./constraints.json", - "contract": "../../contract/normalized.json", + "contract": "../../derived/contract.normalized.json", + "platforms": "./platforms.json", "repairMap": "./repair-map.json", "sections": "./sections.json" }, 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 index d7421a0..d5dd8ff 100644 --- 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 @@ -1,6 +1,8 @@ { "provenance": { - "bundleVersion": "2.0", + "astId": "demo-ui", + "astVersion": "1.0.0", + "bundleVersion": "3.0", "contractId": "demo-ui", "contractVersion": "1.0.0", "surfaceId": "demo-surface" diff --git a/packages/interfacectl-cli/test/generation-adapter.test.mjs b/packages/interfacectl-cli/test/generation-adapter.test.mjs index 2acff37..acc23ee 100644 --- a/packages/interfacectl-cli/test/generation-adapter.test.mjs +++ b/packages/interfacectl-cli/test/generation-adapter.test.mjs @@ -113,7 +113,7 @@ function buildDescriptor(overrides = {}) { ]; } -test("generation adapter: workspace mode uses contract/normalized.json from the bundle", async () => { +test("generation adapter: workspace mode uses derived contract output 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"); @@ -134,7 +134,7 @@ test("generation adapter: workspace mode uses contract/normalized.json from the assert.equal(response.status, "pass"); assert.equal(response.contract.id, "portable.fixture"); - assert.equal(response.bundle.version, "2.0"); + assert.equal(response.bundle.version, "3.0"); assert.equal( response.bundle.manifestPath, path.join(bundleRoot, "manifest.json"), @@ -169,7 +169,7 @@ test("generation adapter: descriptor mode loads a valid bundle and returns bundl 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.bundle.version, "3.0"); assert.equal(response.findings.length, 0); } finally { await fsp.rm(tempDir, { recursive: true, force: true }); @@ -365,7 +365,7 @@ test("generation adapter CLI maps block findings to exit code 30", async () => { assert.equal(result.exitCode, 30); const payload = JSON.parse(result.stdout); assert.equal(payload.status, "block"); - assert.equal(payload.bundle.version, "2.0"); + assert.equal(payload.bundle.version, "3.0"); } finally { await fsp.rm(tempDir, { recursive: true, force: true }); } diff --git a/packages/interfacectl-cli/test/migrate-ui-ast.test.mjs b/packages/interfacectl-cli/test/migrate-ui-ast.test.mjs new file mode 100644 index 0000000..8fe117c --- /dev/null +++ b/packages/interfacectl-cli/test/migrate-ui-ast.test.mjs @@ -0,0 +1,144 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +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 { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; + +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, + }; +} + +test("migrate-ui-ast imports legacy contracts into AST drafts and emits deterministic escalations", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-migrate-ui-ast-")); + const contractPath = path.join(tempDir, "contract.json"); + const astPath = path.join(tempDir, "contracts", "ui.surface.ast.json"); + + try { + await writeFile( + contractPath, + `${JSON.stringify( + { + contractId: "migrate-demo", + version: "1.0.0", + surfaces: [ + { + id: "demo-surface", + displayName: "Demo Surface", + type: "web", + requiredSections: ["main.hero", "main.cta"], + allowedFonts: ["Demo Sans", "sans-serif"], + layout: { + maxContentWidth: 960, + landingPattern: { + policy: "warn", + sectionOrder: ["main.hero", "main.cta"], + pageBackgroundMode: "solid", + }, + }, + governance: { + status: "review", + roles: { + designers: ["designers@example.com"], + engineers: ["eng@example.com"], + }, + }, + }, + ], + sections: [ + { + id: "main.hero", + intent: "primary-intro", + description: "Hero section", + }, + { + id: "main.cta", + intent: "conversion", + description: "Call to action", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: ["#ffffff"], + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = await runCli( + [ + "migrate-ui-ast", + "--contract", + contractPath, + "--out", + astPath, + "--format", + "json", + ], + tempDir, + ); + + assert.equal(result.exitCode, 0, result.stderr); + const payload = JSON.parse(result.stdout); + const ast = JSON.parse(await readFile(astPath, "utf8")); + + assert.equal(payload.status, "ok"); + assert.equal(payload.sourceKind, "legacy-contract"); + assert.equal(payload.astId, "migrate-demo"); + assert.deepEqual(payload.surfaceIds, ["demo-surface"]); + assert.ok( + payload.warnings.some((warning) => warning.includes("--contract is deprecated")), + "migration should surface the legacy contract deprecation warning", + ); + assert.ok( + payload.escalations.some((entry) => entry.code === "marketing.out-of-scope"), + "migration should flag landing-pattern metadata as an escalation", + ); + + assert.equal(ast.astId, "migrate-demo"); + assert.equal(ast.surfaces[0].rootNodeId, "demo-surface.root"); + assert.deepEqual( + ast.surfaces[0].nodes.map((node) => node.id), + ["demo-surface.root", "main.hero", "main.cta"], + ); + assert.equal(ast.surfaces[0].platforms[0].platform, "web"); + assert.equal(ast.color.policy, "warn"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/interfacectl-cli/test/prepare-generation.test.mjs b/packages/interfacectl-cli/test/prepare-generation.test.mjs index 5645fdb..5650d7c 100644 --- a/packages/interfacectl-cli/test/prepare-generation.test.mjs +++ b/packages/interfacectl-cli/test/prepare-generation.test.mjs @@ -232,13 +232,19 @@ test("prepare-generation: emits resolved payload with summary, provenance, autho const payload = JSON.parse(result.stdout); assert.equal(payload.surface.surfaceId, "demo-surface"); - assert.equal(payload.bundle.version, "2.0"); + assert.equal(payload.bundle.version, "3.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.bundle.sourcePaths.ast, path.join(bundleRoot, "ast", "normalized.json")); + assert.equal(payload.bundle.sourcePaths.contract, path.join(bundleRoot, "derived", "contract.normalized.json")); + assert.equal(payload.bundle.sourcePaths.astSlice, path.join(bundleRoot, "surfaces", "demo-surface", "ast.json")); + assert.equal(payload.bundle.sourcePaths.platforms, path.join(bundleRoot, "surfaces", "demo-surface", "platforms.json")); assert.equal(payload.bundle.sourcePaths.runtime, path.join(bundleRoot, "surfaces", "demo-surface", "runtime.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.contract.normalizedPath, path.join(bundleRoot, "derived", "contract.normalized.json")); + assert.equal(payload.ast.id, "prepare-demo"); + assert.equal(payload.ast.version, "1.0.0"); + assert.equal(payload.ast.normalizedPath, path.join(bundleRoot, "ast", "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)); @@ -313,6 +319,7 @@ test("prepare-generation: --out writes the payload file and suppresses stdout", 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.ast, path.join(bundleRoot, "ast", "normalized.json")); assert.equal(written.bundle.sourcePaths.generation, path.join(bundleRoot, "surfaces", "demo-surface", "generation.json")); assert.equal(written.bundle.sourcePaths.runtime, path.join(bundleRoot, "surfaces", "demo-surface", "runtime.json")); const schemaValidation = validatePreparedGenerationOutput(written); @@ -402,7 +409,7 @@ test("prepare-generation: rejects missing manifest, unsupported bundle versions, 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"); + await fsp.writeFile(path.join(bundleRoot, "derived", "contract.normalized.json"), "{invalid json", "utf8"); const unreadableContract = await runCli( ["prepare-generation", "--bundle-root", bundleRoot, "--surface", "demo-surface"], tempDir, diff --git a/packages/interfacectl-cli/test/prepare-runtime.test.mjs b/packages/interfacectl-cli/test/prepare-runtime.test.mjs index 7f64261..62ddb81 100644 --- a/packages/interfacectl-cli/test/prepare-runtime.test.mjs +++ b/packages/interfacectl-cli/test/prepare-runtime.test.mjs @@ -236,7 +236,14 @@ test("prepare-runtime: emits resolved runtime payload with governance and enforc const payload = parseJsonFromOutput(result.stdout); assert.equal(payload.surface.surfaceId, "demo-surface"); + assert.equal(payload.bundle.version, "3.0"); + assert.equal(payload.bundle.sourcePaths.ast, path.join(bundleRoot, "ast", "normalized.json")); + assert.equal(payload.bundle.sourcePaths.contract, path.join(bundleRoot, "derived", "contract.normalized.json")); + assert.equal(payload.bundle.sourcePaths.astSlice, path.join(bundleRoot, "surfaces", "demo-surface", "ast.json")); + assert.equal(payload.bundle.sourcePaths.platforms, path.join(bundleRoot, "surfaces", "demo-surface", "platforms.json")); assert.equal(payload.bundle.sourcePaths.runtime, path.join(bundleRoot, "surfaces", "demo-surface", "runtime.json")); + assert.equal(payload.contract.normalizedPath, path.join(bundleRoot, "derived", "contract.normalized.json")); + assert.equal(payload.ast.normalizedPath, path.join(bundleRoot, "ast", "normalized.json")); assert.equal(payload.summary.mutationMode, "slot-bound"); assert.deepEqual(payload.summary.strictCategories, ["boundary", "runtime", "structure"]); assert.equal(payload.governance.owner, "designers@example.com"); diff --git a/packages/interfacectl-validator/dist/index.d.ts b/packages/interfacectl-validator/dist/index.d.ts index 4837f95..35bfb6e 100644 --- a/packages/interfacectl-validator/dist/index.d.ts +++ b/packages/interfacectl-validator/dist/index.d.ts @@ -1,4 +1,5 @@ import { InterfaceContract, SurfaceDescriptor, SurfaceReport, ValidationSummary } from "./types.js"; +export { getBundledUiAstSchema, validateUiAstStructure, type UiAstStructureValidation, type UiAstActionIntent, type UiAstAlertSeverity, type UiAstFieldType, type UiAstMigrationEscalation, type UiAstMigrationMetadata, type UiAstNode, type UiAstNodeKind, type UiAstPlatform, type UiAstPlatformProjection, type UiAstSelectionMode, type UiAstStateRef, type UiAstSurface, type UiAstSurfaceKind, type UiAstTextRole, type UiSurfaceAst, } from "./ui-ast.js"; export declare function getBundledContractSchema(): object; export interface ContractStructureValidation { ok: boolean; diff --git a/packages/interfacectl-validator/dist/index.d.ts.map b/packages/interfacectl-validator/dist/index.d.ts.map index b390a34..c39baa6 100644 --- a/packages/interfacectl-validator/dist/index.d.ts.map +++ b/packages/interfacectl-validator/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAOL,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EASlB,MAAM,YAAY,CAAC;AAmBpB,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,OAAO,EACrB,MAAM,EAAE,MAAM,GACb,2BAA2B,CA8C7B;AAkpDD,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CAkbf;AAED,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,iBAAiB,EAAE,GAC/B,iBAAiB,CA6CnB;AAyCD,YAAY,EACV,iBAAiB,EACjB,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,EAChB,wBAAwB,EACxB,wBAAwB,EACxB,aAAa,EACb,mBAAmB,EACnB,yBAAyB,EACzB,+BAA+B,EAC/B,iBAAiB,EACjB,uBAAuB,EACvB,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,eAAe,EACf,uBAAuB,EACvB,iBAAiB,EACjB,qBAAqB,EACrB,sBAAsB,EACtB,sBAAsB,EACtB,0BAA0B,EAC1B,eAAe,EACf,gBAAgB,EAChB,uBAAuB,EACvB,yBAAyB,EACzB,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,yBAAyB,EACzB,oBAAoB,EACpB,sBAAsB,EACtB,2BAA2B,EAC3B,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,qBAAqB,EACrB,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EACtB,qBAAqB,EACrB,2BAA2B,EAC3B,6BAA6B,EAC7B,2BAA2B,EAC3B,4BAA4B,EAC5B,+BAA+B,EAC/B,sBAAsB,EACtB,iBAAiB,EACjB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,sBAAsB,EACtB,kBAAkB,EAClB,gBAAgB,EAChB,wBAAwB,EACxB,0BAA0B,EAC1B,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,WAAW,EACX,OAAO,EACP,UAAU,EACV,SAAS,EACT,cAAc,EACd,SAAS,EACT,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,UAAU,EACV,aAAa,EACb,aAAa,EACb,WAAW,EACX,yBAAyB,EACzB,sBAAsB,EACtB,0BAA0B,EAC1B,8BAA8B,EAC9B,0BAA0B,EAC1B,4BAA4B,EAC5B,2BAA2B,EAC3B,8BAA8B,EAC9B,uBAAuB,EACvB,UAAU,EACV,eAAe,EACf,yBAAyB,EACzB,oBAAoB,EACpB,cAAc,EACd,kBAAkB,EAClB,uBAAuB,EACvB,uBAAuB,EACvB,4BAA4B,EAC5B,yBAAyB,EACzB,iCAAiC,EACjC,gCAAgC,EAChC,4CAA4C,EAC5C,oCAAoC,EACpC,wCAAwC,EACxC,qBAAqB,EACrB,yBAAyB,EACzB,+BAA+B,EAC/B,uBAAuB,EACvB,qBAAqB,EACrB,WAAW,EACX,UAAU,EACV,QAAQ,EACR,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,0BAA0B,EAC1B,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,KAAK,gBAAgB,GACtB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EACL,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EACL,gBAAgB,EAChB,0BAA0B,EAC1B,KAAK,gBAAgB,GACtB,MAAM,mBAAmB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAOL,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EASlB,MAAM,YAAY,CAAC;AAIpB,OAAO,EACL,qBAAqB,EACrB,sBAAsB,EACtB,KAAK,wBAAwB,EAC7B,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,wBAAwB,EAC7B,KAAK,sBAAsB,EAC3B,KAAK,SAAS,EACd,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACvB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,aAAa,CAAC;AAgBrB,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,OAAO,EACrB,MAAM,EAAE,MAAM,GACb,2BAA2B,CA8C7B;AAkpDD,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CAkbf;AAED,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,iBAAiB,EAAE,GAC/B,iBAAiB,CA6CnB;AAyCD,YAAY,EACV,iBAAiB,EACjB,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,EAChB,wBAAwB,EACxB,wBAAwB,EACxB,aAAa,EACb,mBAAmB,EACnB,yBAAyB,EACzB,+BAA+B,EAC/B,iBAAiB,EACjB,uBAAuB,EACvB,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,eAAe,EACf,uBAAuB,EACvB,iBAAiB,EACjB,qBAAqB,EACrB,sBAAsB,EACtB,sBAAsB,EACtB,0BAA0B,EAC1B,eAAe,EACf,gBAAgB,EAChB,uBAAuB,EACvB,yBAAyB,EACzB,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,yBAAyB,EACzB,oBAAoB,EACpB,sBAAsB,EACtB,2BAA2B,EAC3B,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,qBAAqB,EACrB,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EACtB,qBAAqB,EACrB,2BAA2B,EAC3B,6BAA6B,EAC7B,2BAA2B,EAC3B,4BAA4B,EAC5B,+BAA+B,EAC/B,sBAAsB,EACtB,iBAAiB,EACjB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,sBAAsB,EACtB,kBAAkB,EAClB,gBAAgB,EAChB,wBAAwB,EACxB,0BAA0B,EAC1B,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,WAAW,EACX,OAAO,EACP,UAAU,EACV,SAAS,EACT,cAAc,EACd,SAAS,EACT,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,UAAU,EACV,aAAa,EACb,aAAa,EACb,WAAW,EACX,yBAAyB,EACzB,sBAAsB,EACtB,0BAA0B,EAC1B,8BAA8B,EAC9B,0BAA0B,EAC1B,4BAA4B,EAC5B,2BAA2B,EAC3B,8BAA8B,EAC9B,uBAAuB,EACvB,UAAU,EACV,eAAe,EACf,yBAAyB,EACzB,oBAAoB,EACpB,cAAc,EACd,kBAAkB,EAClB,uBAAuB,EACvB,uBAAuB,EACvB,4BAA4B,EAC5B,yBAAyB,EACzB,iCAAiC,EACjC,gCAAgC,EAChC,4CAA4C,EAC5C,oCAAoC,EACpC,wCAAwC,EACxC,qBAAqB,EACrB,yBAAyB,EACzB,+BAA+B,EAC/B,uBAAuB,EACvB,qBAAqB,EACrB,WAAW,EACX,UAAU,EACV,QAAQ,EACR,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,0BAA0B,EAC1B,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,KAAK,gBAAgB,GACtB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EACL,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EACL,gBAAgB,EAChB,0BAA0B,EAC1B,KAAK,gBAAgB,GACtB,MAAM,mBAAmB,CAAC"} \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/index.js b/packages/interfacectl-validator/dist/index.js index adf4d55..7d41d33 100644 --- a/packages/interfacectl-validator/dist/index.js +++ b/packages/interfacectl-validator/dist/index.js @@ -3,6 +3,7 @@ import addFormats from "ajv-formats"; import bundledSchema from "./schema/web.surface.contract.schema.json" with { type: "json" }; +export { getBundledUiAstSchema, validateUiAstStructure, } from "./ui-ast.js"; import { normalizeColorValue } from "./color-policy.js"; import { matchTokenPolicy } from "./token-policy.js"; const frozenBundledSchema = Object.freeze(bundledSchema); diff --git a/packages/interfacectl-validator/dist/schema/ui.surface.ast.schema.json b/packages/interfacectl-validator/dist/schema/ui.surface.ast.schema.json new file mode 100644 index 0000000..8bf60e7 --- /dev/null +++ b/packages/interfacectl-validator/dist/schema/ui.surface.ast.schema.json @@ -0,0 +1,707 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.surfaces.local/ui.surface.ast.schema.json", + "title": "UI Surface AST", + "type": "object", + "additionalProperties": false, + "required": ["astId", "version", "surfaces", "constraints", "color"], + "properties": { + "$schema": { + "type": "string" + }, + "astId": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "description": { + "type": "string" + }, + "shell": { + "type": "object", + "additionalProperties": false, + "properties": { + "owns": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "contentSlot": { + "type": "string", + "minLength": 1 + } + } + }, + "constraints": { + "type": "object", + "additionalProperties": false, + "required": ["motion"], + "properties": { + "motion": { + "type": "object", + "additionalProperties": false, + "required": ["allowedDurationsMs", "allowedTimingFunctions"], + "properties": { + "allowedDurationsMs": { + "type": "array", + "minItems": 1, + "items": { "type": "number" } + }, + "allowedTimingFunctions": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + } + } + }, + "color": { + "type": "object", + "additionalProperties": false, + "required": ["policy", "allowedValues"], + "properties": { + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "allowedValues": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "tokens": { + "type": "object", + "additionalProperties": false, + "properties": { + "typography": { "$ref": "#/$defs/tokenPolicy" }, + "layout": { "$ref": "#/$defs/tokenPolicy" }, + "motion": { "$ref": "#/$defs/tokenPolicy" } + } + }, + "migration": { + "type": "object", + "additionalProperties": false, + "required": ["sourceFormat", "escalations"], + "properties": { + "sourceFormat": { + "type": "string", + "minLength": 1 + }, + "escalations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "surfaces": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/surface" } + } + }, + "$defs": { + "tokenPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["policy", "allowedTokens"], + "properties": { + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "allowedTokens": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "tokenMetadata": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["token", "normalizedValue", "attributes", "aliases"], + "properties": { + "token": { "type": "string", "minLength": 1 }, + "normalizedValue": { "type": "string", "minLength": 1 }, + "attributes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "aliases": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + } + } + } + }, + "layout": { + "type": "object", + "additionalProperties": false, + "required": ["maxContentWidth"], + "properties": { + "maxContentWidth": { + "type": "number", + "minimum": 1 + }, + "requiredContainers": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "pageFrame": { + "type": "object", + "additionalProperties": false, + "required": ["containerSelector", "containerMaxWidthPx", "paddingXpx"], + "properties": { + "containerSelector": { "type": "string", "minLength": 1 }, + "containerMaxWidthPx": { "type": "number", "minimum": 1 }, + "containerMinWidthPx": { "type": "number", "minimum": 1 }, + "paddingXpx": { "type": "number", "minimum": 0 }, + "alignment": { "type": "string", "enum": ["center", "left"] }, + "enforcement": { "type": "string", "enum": ["strict", "warn"] } + } + }, + "chromePolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "policy", + "targets", + "maxBorderRadiusPx", + "allowOuterShadow", + "allowInsetShadow" + ], + "properties": { + "policy": { "type": "string", "enum": ["off", "warn", "strict"] }, + "targets": { + "type": "array", + "items": { + "type": "string", + "enum": ["page-container", "top-level-section", "layout-container"] + } + }, + "maxBorderRadiusPx": { "type": "number", "minimum": 0 }, + "allowOuterShadow": { "type": "boolean" }, + "allowInsetShadow": { "type": "boolean" } + } + }, + "targetAcquisition": { + "type": "object", + "additionalProperties": false, + "required": ["policy"], + "properties": { + "policy": { "type": "string", "enum": ["off", "warn", "strict"] }, + "modality": { "type": "string", "enum": ["touch-mouse", "touch", "mouse"] }, + "minHitAreaPx": { "type": "number", "minimum": 1 }, + "minGapPx": { "type": "number", "minimum": 0 }, + "minEdgeInsetPx": { "type": "number", "minimum": 0 }, + "destructiveGapPx": { "type": "number", "minimum": 0 }, + "viewportOverrides": { + "type": "array", + "items": { "$ref": "#/$defs/targetAcquisitionViewportOverride" } + }, + "contextOverrides": { + "type": "array", + "items": { "$ref": "#/$defs/targetAcquisitionContextOverride" } + } + } + } + } + }, + "targetAcquisitionBudget": { + "type": "object", + "additionalProperties": false, + "properties": { + "minHitAreaPx": { "type": "number", "minimum": 1 }, + "minGapPx": { "type": "number", "minimum": 0 }, + "minEdgeInsetPx": { "type": "number", "minimum": 0 }, + "destructiveGapPx": { "type": "number", "minimum": 0 } + } + }, + "targetAcquisitionViewportOverride": { + "type": "object", + "additionalProperties": false, + "required": ["viewport"], + "properties": { + "viewport": { + "type": "string", + "minLength": 1 + }, + "minHitAreaPx": { "type": "number", "minimum": 1 }, + "minGapPx": { "type": "number", "minimum": 0 }, + "minEdgeInsetPx": { "type": "number", "minimum": 0 }, + "destructiveGapPx": { "type": "number", "minimum": 0 } + } + }, + "targetAcquisitionContextOverride": { + "type": "object", + "additionalProperties": false, + "required": ["context"], + "properties": { + "context": { + "type": "string", + "minLength": 1 + }, + "minHitAreaPx": { "type": "number", "minimum": 1 }, + "minGapPx": { "type": "number", "minimum": 0 }, + "minEdgeInsetPx": { "type": "number", "minimum": 0 }, + "destructiveGapPx": { "type": "number", "minimum": 0 } + } + }, + "platformProjection": { + "type": "object", + "additionalProperties": false, + "required": ["platform"], + "properties": { + "platform": { + "type": "string", + "enum": ["web", "ios", "android"] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "domain": { + "type": "string", + "minLength": 1 + }, + "allowedFonts": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "layout": { "$ref": "#/$defs/layout" }, + "mustNotEmit": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "shellOwnedPrimitiveAllowSources": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "notes": { + "type": "string" + } + } + }, + "state": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": ["loading", "empty", "partial", "error", "success"] + }, + "description": { + "type": "string" + } + } + }, + "node": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "section", + "group", + "heading", + "body", + "field", + "toggle", + "selection", + "action", + "alert", + "confirmation", + "empty-state", + "list", + "table", + "detail", + "progress-steps" + ] + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "children": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "sectionId": { + "type": "string", + "minLength": 1 + }, + "intent": { + "type": "string", + "minLength": 1 + }, + "textRole": { + "type": "string", + "enum": ["title", "subtitle", "body", "label", "helper", "caption", "error"] + }, + "headingLevel": { + "type": "integer", + "minimum": 1, + "maximum": 6 + }, + "fieldType": { + "type": "string", + "enum": ["text", "email", "password", "number", "date", "textarea"] + }, + "selectionMode": { + "type": "string", + "enum": ["single", "multiple"] + }, + "actionId": { + "type": "string", + "minLength": 1 + }, + "actionIntent": { + "type": "string", + "enum": [ + "submit", + "save", + "continue", + "cancel", + "confirm", + "dismiss", + "retry", + "navigate", + "filter", + "select" + ] + }, + "severity": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "stateRefs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "platformVisibility": { + "type": "array", + "items": { + "type": "string", + "enum": ["web", "ios", "android"] + } + } + } + }, + "surface": { + "type": "object", + "additionalProperties": false, + "required": ["id", "displayName", "kind", "rootNodeId", "nodes", "platforms"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "displayName": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": ["application"] + }, + "rootNodeId": { + "type": "string", + "minLength": 1 + }, + "nodes": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/node" } + }, + "platforms": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/platformProjection" } + }, + "states": { + "type": "array", + "items": { "$ref": "#/$defs/state" } + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "phase0": { + "type": "object", + "additionalProperties": false, + "properties": { + "authPosture": { + "type": "string", + "enum": ["public", "auth-aware", "auth-first"] + }, + "requiresShell": { "type": "boolean" }, + "expectsAuthRoutes": { "type": "boolean" }, + "expectsDesignSystem": { "type": "boolean" } + } + }, + "icons": { + "type": "object", + "additionalProperties": false, + "required": ["policy", "allowedSources"], + "properties": { + "policy": { "type": "string", "enum": ["off", "warn", "strict"] }, + "allowedSources": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "flows": { + "type": "object", + "additionalProperties": false, + "required": ["policy", "requirements"], + "properties": { + "policy": { "type": "string", "enum": ["off", "warn", "strict"] }, + "requirements": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["flowId"], + "properties": { + "flowId": { "type": "string", "minLength": 1 }, + "minSteps": { "type": "integer", "minimum": 1 }, + "requiredSteps": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "requiredTransitions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["from", "to"], + "properties": { + "from": { "type": "string", "minLength": 1 }, + "to": { "type": "string", "minLength": 1 } + } + } + }, + "terminalSteps": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + } + } + } + }, + "governance": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": ["draft", "review", "approved", "published"] + }, + "roles": { + "type": "object", + "additionalProperties": false, + "properties": { + "designers": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "engineers": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "approvers": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "approvals": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["role", "owner", "status"], + "properties": { + "role": { + "type": "string", + "enum": ["designer", "engineering", "product", "qa", "operations", "other"] + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": ["pending", "approved", "rejected"] + }, + "note": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "properties": { + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "mutationEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { + "type": "string", + "enum": [ + "locked", + "content-only", + "slot-bound", + "layout-tuning", + "section-assembly", + "freeform" + ] + }, + "scopes": { + "type": "array", + "items": { + "type": "string", + "enum": ["content", "components", "layout", "sections", "interactions"] + } + }, + "allowedActions": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "allowedSections": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "prohibitedSections": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "contexts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "when"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "when": { "type": "string", "minLength": 1 }, + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "kind": { + "type": "string", + "enum": ["loading", "empty", "partial", "error", "success"] + }, + "requiredSections": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "prohibitedSections": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "requiredRecoveryActions": { + "type": "array", + "items": { + "type": "string", + "enum": ["retry", "refresh", "dismiss", "contact-support", "navigate-home", "go-back"] + } + }, + "preserveSections": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "preserveLastGoodContent": { "type": "boolean" }, + "blockedActionsWhilePending": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "allowedLayoutIntents": { + "type": "array", + "items": { + "type": "string", + "enum": ["stack", "columns", "auto-fit-grid", "sidebar-main", "single-column-form"] + } + }, + "notes": { "type": "string" } + } + } + }, + "feedbackRecovery": { + "type": "object", + "additionalProperties": false, + "required": ["policy"], + "properties": { + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "requiredStateKinds": { + "type": "array", + "items": { + "type": "string", + "enum": ["loading", "empty", "partial", "error", "success"] + } + } + } + } + } + } + } + } + } +} diff --git a/packages/interfacectl-validator/dist/ui-ast.d.ts b/packages/interfacectl-validator/dist/ui-ast.d.ts new file mode 100644 index 0000000..fe34633 --- /dev/null +++ b/packages/interfacectl-validator/dist/ui-ast.d.ts @@ -0,0 +1,93 @@ +import type { AsyncStateKind, ChromePolicy, ColorPolicy, ContractConstraints, ContractTokenPolicies, FlowPolicy, IconPolicy, PageFrameLayout, ShellSpec, SurfaceGovernance, SurfacePhase0, SurfaceRuntimePolicy, TargetAcquisitionPolicy } from "./types.js"; +export type UiAstSurfaceKind = "application"; +export type UiAstPlatform = "web" | "ios" | "android"; +export type UiAstNodeKind = "section" | "group" | "heading" | "body" | "field" | "toggle" | "selection" | "action" | "alert" | "confirmation" | "empty-state" | "list" | "table" | "detail" | "progress-steps"; +export type UiAstActionIntent = "submit" | "save" | "continue" | "cancel" | "confirm" | "dismiss" | "retry" | "navigate" | "filter" | "select"; +export type UiAstTextRole = "title" | "subtitle" | "body" | "label" | "helper" | "caption" | "error"; +export type UiAstFieldType = "text" | "email" | "password" | "number" | "date" | "textarea"; +export type UiAstSelectionMode = "single" | "multiple"; +export type UiAstAlertSeverity = "info" | "success" | "warning" | "error"; +export interface UiAstLayoutPolicy { + maxContentWidth: number; + requiredContainers?: string[]; + pageFrame?: PageFrameLayout; + chromePolicy?: ChromePolicy; + targetAcquisition?: TargetAcquisitionPolicy; +} +export interface UiAstPlatformProjection { + platform: UiAstPlatform; + path?: string; + domain?: string; + allowedFonts?: string[]; + layout?: UiAstLayoutPolicy; + mustNotEmit?: string[]; + shellOwnedPrimitiveAllowSources?: string[]; + notes?: string; +} +export interface UiAstStateRef { + id: string; + kind?: AsyncStateKind; + description?: string; +} +export interface UiAstNode { + id: string; + kind: UiAstNodeKind; + label?: string; + description?: string; + children?: string[]; + sectionId?: string; + intent?: string; + textRole?: UiAstTextRole; + headingLevel?: number; + fieldType?: UiAstFieldType; + selectionMode?: UiAstSelectionMode; + actionId?: string; + actionIntent?: UiAstActionIntent; + severity?: UiAstAlertSeverity; + stateRefs?: string[]; + platformVisibility?: UiAstPlatform[]; +} +export interface UiAstMigrationEscalation { + surfaceId?: string; + code: string; + message: string; +} +export interface UiAstMigrationMetadata { + sourceFormat: string; + escalations: UiAstMigrationEscalation[]; +} +export interface UiAstSurface { + id: string; + displayName: string; + kind: UiAstSurfaceKind; + rootNodeId: string; + nodes: UiAstNode[]; + platforms: UiAstPlatformProjection[]; + states?: UiAstStateRef[]; + owner?: string; + phase0?: SurfacePhase0; + governance?: SurfaceGovernance; + icons?: IconPolicy; + flows?: FlowPolicy; + runtime?: SurfaceRuntimePolicy; +} +export interface UiSurfaceAst { + $schema?: string; + astId: string; + version: string; + description?: string; + constraints: ContractConstraints; + color: ColorPolicy; + tokens?: ContractTokenPolicies; + shell?: ShellSpec; + surfaces: UiAstSurface[]; + migration?: UiAstMigrationMetadata; +} +export interface UiAstStructureValidation { + ok: boolean; + errors: string[]; + ast?: UiSurfaceAst; +} +export declare function getBundledUiAstSchema(): object; +export declare function validateUiAstStructure(astData: unknown, schema?: object): UiAstStructureValidation; +//# sourceMappingURL=ui-ast.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/ui-ast.d.ts.map b/packages/interfacectl-validator/dist/ui-ast.d.ts.map new file mode 100644 index 0000000..36dc6e3 --- /dev/null +++ b/packages/interfacectl-validator/dist/ui-ast.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ui-ast.d.ts","sourceRoot":"","sources":["../src/ui-ast.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,cAAc,EACd,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,qBAAqB,EACrB,UAAU,EACV,UAAU,EACV,eAAe,EACf,SAAS,EACT,iBAAiB,EACjB,aAAa,EACb,oBAAoB,EACpB,uBAAuB,EACxB,MAAM,YAAY,CAAC;AAIpB,MAAM,MAAM,gBAAgB,GAAG,aAAa,CAAC;AAC7C,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,KAAK,GAAG,SAAS,CAAC;AACtD,MAAM,MAAM,aAAa,GACrB,SAAS,GACT,OAAO,GACP,SAAS,GACT,MAAM,GACN,OAAO,GACP,QAAQ,GACR,WAAW,GACX,QAAQ,GACR,OAAO,GACP,cAAc,GACd,aAAa,GACb,MAAM,GACN,OAAO,GACP,QAAQ,GACR,gBAAgB,CAAC;AACrB,MAAM,MAAM,iBAAiB,GACzB,QAAQ,GACR,MAAM,GACN,UAAU,GACV,QAAQ,GACR,SAAS,GACT,SAAS,GACT,OAAO,GACP,UAAU,GACV,QAAQ,GACR,QAAQ,CAAC;AACb,MAAM,MAAM,aAAa,GACrB,OAAO,GACP,UAAU,GACV,MAAM,GACN,OAAO,GACP,QAAQ,GACR,SAAS,GACT,OAAO,CAAC;AACZ,MAAM,MAAM,cAAc,GACtB,MAAM,GACN,OAAO,GACP,UAAU,GACV,QAAQ,GACR,MAAM,GACN,UAAU,CAAC;AACf,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,UAAU,CAAC;AACvD,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;AAE1E,MAAM,WAAW,iBAAiB;IAChC,eAAe,EAAE,MAAM,CAAC;IACxB,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC9B,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,iBAAiB,CAAC,EAAE,uBAAuB,CAAC;CAC7C;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,aAAa,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,+BAA+B,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,aAAa,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,aAAa,CAAC,EAAE,kBAAkB,CAAC;IACnC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,kBAAkB,CAAC,EAAE,aAAa,EAAE,CAAC;CACtC;AAED,MAAM,WAAW,wBAAwB;IACvC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,wBAAwB,EAAE,CAAC;CACzC;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,SAAS,EAAE,uBAAuB,EAAE,CAAC;IACrC,MAAM,CAAC,EAAE,aAAa,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,OAAO,CAAC,EAAE,oBAAoB,CAAC;CAChC;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,mBAAmB,CAAC;IACjC,KAAK,EAAE,WAAW,CAAC;IACnB,MAAM,CAAC,EAAE,qBAAqB,CAAC;IAC/B,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,SAAS,CAAC,EAAE,sBAAsB,CAAC;CACpC;AAED,MAAM,WAAW,wBAAwB;IACvC,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,GAAG,CAAC,EAAE,YAAY,CAAC;CACpB;AA4BD,wBAAgB,qBAAqB,IAAI,MAAM,CAE9C;AA0FD,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,OAAO,EAChB,MAAM,GAAE,MAAiC,GACxC,wBAAwB,CAmC1B"} \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/ui-ast.js b/packages/interfacectl-validator/dist/ui-ast.js new file mode 100644 index 0000000..f602a04 --- /dev/null +++ b/packages/interfacectl-validator/dist/ui-ast.js @@ -0,0 +1,132 @@ +import AjvModule from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import astSchema from "./schema/ui.surface.ast.schema.json" with { + type: "json" +}; +const frozenBundledUiAstSchema = Object.freeze(astSchema); +function createAjvValidator() { + const ajv = new AjvModule({ + allErrors: true, + strict: false, + }); + addFormats(ajv); + return ajv; +} +function formatAjvErrors(errors) { + if (!errors) { + return []; + } + return errors.map((error) => { + const dataPath = error.instancePath || error.schemaPath; + const baseMessage = error.message ?? "Validation error"; + if (error.params && Object.keys(error.params).length > 0) { + return `${dataPath}: ${baseMessage} (${JSON.stringify(error.params)})`; + } + return `${dataPath}: ${baseMessage}`; + }); +} +export function getBundledUiAstSchema() { + return frozenBundledUiAstSchema; +} +function findDuplicate(values) { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) { + return value; + } + seen.add(value); + } + return null; +} +function validateSurfaceAst(surface) { + const errors = []; + const nodeIds = surface.nodes.map((node) => node.id); + const duplicateNodeId = findDuplicate(nodeIds); + if (duplicateNodeId) { + errors.push(`/surfaces/${surface.id}/nodes must use unique node ids (${duplicateNodeId})`); + } + const stateIds = (surface.states ?? []).map((state) => state.id); + const duplicateStateId = findDuplicate(stateIds); + if (duplicateStateId) { + errors.push(`/surfaces/${surface.id}/states must use unique ids (${duplicateStateId})`); + } + const platformIds = surface.platforms.map((platform) => platform.platform); + const duplicatePlatformId = findDuplicate(platformIds); + if (duplicatePlatformId) { + errors.push(`/surfaces/${surface.id}/platforms must use unique platform entries (${duplicatePlatformId})`); + } + const nodeIdSet = new Set(nodeIds); + const stateIdSet = new Set(stateIds); + if (!nodeIdSet.has(surface.rootNodeId)) { + errors.push(`/surfaces/${surface.id}/rootNodeId must reference a declared node`); + } + for (const node of surface.nodes) { + for (const childId of node.children ?? []) { + if (!nodeIdSet.has(childId)) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id}/children references missing node "${childId}"`); + } + } + for (const stateRef of node.stateRefs ?? []) { + if (!stateIdSet.has(stateRef)) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id}/stateRefs references missing state "${stateRef}"`); + } + } + if (node.kind === "section" && !node.sectionId) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare sectionId when kind=section`); + } + if (node.kind === "action" && !node.actionIntent) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare actionIntent when kind=action`); + } + if (node.kind === "field" && !node.fieldType) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare fieldType when kind=field`); + } + if (node.kind === "selection" && !node.selectionMode) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare selectionMode when kind=selection`); + } + if (node.kind === "alert" && !node.severity) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare severity when kind=alert`); + } + if (node.kind === "heading") { + if (!node.textRole) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare textRole when kind=heading`); + } + if (node.headingLevel !== undefined && + (node.headingLevel < 1 || node.headingLevel > 6)) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} headingLevel must be between 1 and 6`); + } + } + } + return errors; +} +export function validateUiAstStructure(astData, schema = frozenBundledUiAstSchema) { + const ajv = createAjvValidator(); + const validate = ajv.compile(schema); + const valid = validate(astData); + if (!valid) { + return { + ok: false, + errors: formatAjvErrors(validate.errors), + }; + } + const ast = astData; + const surfaceIds = ast.surfaces.map((surface) => surface.id); + const duplicateSurfaceId = findDuplicate(surfaceIds); + if (duplicateSurfaceId) { + return { + ok: false, + errors: [`/surfaces must use unique surface ids (${duplicateSurfaceId})`], + }; + } + const errors = ast.surfaces.flatMap((surface) => validateSurfaceAst(surface)); + if (errors.length > 0) { + return { + ok: false, + errors, + }; + } + return { + ok: true, + errors: [], + ast, + }; +} diff --git a/packages/interfacectl-validator/scripts/copy-schema.mjs b/packages/interfacectl-validator/scripts/copy-schema.mjs index ee0d9b1..ee664bf 100644 --- a/packages/interfacectl-validator/scripts/copy-schema.mjs +++ b/packages/interfacectl-validator/scripts/copy-schema.mjs @@ -9,6 +9,7 @@ const __dirname = path.dirname(__filename); const schemas = [ "web.surface.contract.schema.json", + "ui.surface.ast.schema.json", "interfacectl.diff.schema.json", "interfacectl.policy.schema.json", "interfacectl.fix-summary.schema.json", @@ -22,4 +23,3 @@ for (const schema of schemas) { const destination = path.join(destinationDir, schema); await cp(source, destination); } - diff --git a/packages/interfacectl-validator/src/index.d.ts b/packages/interfacectl-validator/src/index.d.ts index e83644d..e0f7029 100644 --- a/packages/interfacectl-validator/src/index.d.ts +++ b/packages/interfacectl-validator/src/index.d.ts @@ -9,4 +9,5 @@ export declare function validateContractStructure(contractData: unknown, schema: export declare function evaluateSurfaceCompliance(contract: InterfaceContract, descriptor: SurfaceDescriptor): SurfaceReport; export declare function evaluateContractCompliance(contract: InterfaceContract, descriptors: SurfaceDescriptor[]): ValidationSummary; export type { InterfaceContract, ContractSurface, ContractSection, ContractConstraints, SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, SurfaceReport, DriftViolation, ValidationSummary, DriftViolationType, } from "./types.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file +export { getBundledUiAstSchema, validateUiAstStructure, type UiAstStructureValidation, type UiAstActionIntent, type UiAstAlertSeverity, type UiAstFieldType, type UiAstMigrationEscalation, type UiAstMigrationMetadata, type UiAstNode, type UiAstNodeKind, type UiAstPlatform, type UiAstPlatformProjection, type UiAstSelectionMode, type UiAstStateRef, type UiAstSurface, type UiAstSurfaceKind, type UiAstTextRole, type UiSurfaceAst } from "./ui-ast.js"; +//# sourceMappingURL=index.d.ts.map diff --git a/packages/interfacectl-validator/src/index.ts b/packages/interfacectl-validator/src/index.ts index 87702b2..248152a 100644 --- a/packages/interfacectl-validator/src/index.ts +++ b/packages/interfacectl-validator/src/index.ts @@ -23,6 +23,26 @@ import { import bundledSchema from "./schema/web.surface.contract.schema.json" with { type: "json", }; +export { + getBundledUiAstSchema, + validateUiAstStructure, + type UiAstStructureValidation, + type UiAstActionIntent, + type UiAstAlertSeverity, + type UiAstFieldType, + type UiAstMigrationEscalation, + type UiAstMigrationMetadata, + type UiAstNode, + type UiAstNodeKind, + type UiAstPlatform, + type UiAstPlatformProjection, + type UiAstSelectionMode, + type UiAstStateRef, + type UiAstSurface, + type UiAstSurfaceKind, + type UiAstTextRole, + type UiSurfaceAst, +} from "./ui-ast.js"; import { normalizeColorValue } from "./color-policy.js"; import { matchTokenPolicy, normalizeTokenLiteralValue } from "./token-policy.js"; diff --git a/packages/interfacectl-validator/src/schema/ui.surface.ast.schema.json b/packages/interfacectl-validator/src/schema/ui.surface.ast.schema.json new file mode 100644 index 0000000..8bf60e7 --- /dev/null +++ b/packages/interfacectl-validator/src/schema/ui.surface.ast.schema.json @@ -0,0 +1,707 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.surfaces.local/ui.surface.ast.schema.json", + "title": "UI Surface AST", + "type": "object", + "additionalProperties": false, + "required": ["astId", "version", "surfaces", "constraints", "color"], + "properties": { + "$schema": { + "type": "string" + }, + "astId": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "description": { + "type": "string" + }, + "shell": { + "type": "object", + "additionalProperties": false, + "properties": { + "owns": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "contentSlot": { + "type": "string", + "minLength": 1 + } + } + }, + "constraints": { + "type": "object", + "additionalProperties": false, + "required": ["motion"], + "properties": { + "motion": { + "type": "object", + "additionalProperties": false, + "required": ["allowedDurationsMs", "allowedTimingFunctions"], + "properties": { + "allowedDurationsMs": { + "type": "array", + "minItems": 1, + "items": { "type": "number" } + }, + "allowedTimingFunctions": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + } + } + }, + "color": { + "type": "object", + "additionalProperties": false, + "required": ["policy", "allowedValues"], + "properties": { + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "allowedValues": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "tokens": { + "type": "object", + "additionalProperties": false, + "properties": { + "typography": { "$ref": "#/$defs/tokenPolicy" }, + "layout": { "$ref": "#/$defs/tokenPolicy" }, + "motion": { "$ref": "#/$defs/tokenPolicy" } + } + }, + "migration": { + "type": "object", + "additionalProperties": false, + "required": ["sourceFormat", "escalations"], + "properties": { + "sourceFormat": { + "type": "string", + "minLength": 1 + }, + "escalations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "surfaces": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/surface" } + } + }, + "$defs": { + "tokenPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["policy", "allowedTokens"], + "properties": { + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "allowedTokens": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "tokenMetadata": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["token", "normalizedValue", "attributes", "aliases"], + "properties": { + "token": { "type": "string", "minLength": 1 }, + "normalizedValue": { "type": "string", "minLength": 1 }, + "attributes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "aliases": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + } + } + } + }, + "layout": { + "type": "object", + "additionalProperties": false, + "required": ["maxContentWidth"], + "properties": { + "maxContentWidth": { + "type": "number", + "minimum": 1 + }, + "requiredContainers": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "pageFrame": { + "type": "object", + "additionalProperties": false, + "required": ["containerSelector", "containerMaxWidthPx", "paddingXpx"], + "properties": { + "containerSelector": { "type": "string", "minLength": 1 }, + "containerMaxWidthPx": { "type": "number", "minimum": 1 }, + "containerMinWidthPx": { "type": "number", "minimum": 1 }, + "paddingXpx": { "type": "number", "minimum": 0 }, + "alignment": { "type": "string", "enum": ["center", "left"] }, + "enforcement": { "type": "string", "enum": ["strict", "warn"] } + } + }, + "chromePolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "policy", + "targets", + "maxBorderRadiusPx", + "allowOuterShadow", + "allowInsetShadow" + ], + "properties": { + "policy": { "type": "string", "enum": ["off", "warn", "strict"] }, + "targets": { + "type": "array", + "items": { + "type": "string", + "enum": ["page-container", "top-level-section", "layout-container"] + } + }, + "maxBorderRadiusPx": { "type": "number", "minimum": 0 }, + "allowOuterShadow": { "type": "boolean" }, + "allowInsetShadow": { "type": "boolean" } + } + }, + "targetAcquisition": { + "type": "object", + "additionalProperties": false, + "required": ["policy"], + "properties": { + "policy": { "type": "string", "enum": ["off", "warn", "strict"] }, + "modality": { "type": "string", "enum": ["touch-mouse", "touch", "mouse"] }, + "minHitAreaPx": { "type": "number", "minimum": 1 }, + "minGapPx": { "type": "number", "minimum": 0 }, + "minEdgeInsetPx": { "type": "number", "minimum": 0 }, + "destructiveGapPx": { "type": "number", "minimum": 0 }, + "viewportOverrides": { + "type": "array", + "items": { "$ref": "#/$defs/targetAcquisitionViewportOverride" } + }, + "contextOverrides": { + "type": "array", + "items": { "$ref": "#/$defs/targetAcquisitionContextOverride" } + } + } + } + } + }, + "targetAcquisitionBudget": { + "type": "object", + "additionalProperties": false, + "properties": { + "minHitAreaPx": { "type": "number", "minimum": 1 }, + "minGapPx": { "type": "number", "minimum": 0 }, + "minEdgeInsetPx": { "type": "number", "minimum": 0 }, + "destructiveGapPx": { "type": "number", "minimum": 0 } + } + }, + "targetAcquisitionViewportOverride": { + "type": "object", + "additionalProperties": false, + "required": ["viewport"], + "properties": { + "viewport": { + "type": "string", + "minLength": 1 + }, + "minHitAreaPx": { "type": "number", "minimum": 1 }, + "minGapPx": { "type": "number", "minimum": 0 }, + "minEdgeInsetPx": { "type": "number", "minimum": 0 }, + "destructiveGapPx": { "type": "number", "minimum": 0 } + } + }, + "targetAcquisitionContextOverride": { + "type": "object", + "additionalProperties": false, + "required": ["context"], + "properties": { + "context": { + "type": "string", + "minLength": 1 + }, + "minHitAreaPx": { "type": "number", "minimum": 1 }, + "minGapPx": { "type": "number", "minimum": 0 }, + "minEdgeInsetPx": { "type": "number", "minimum": 0 }, + "destructiveGapPx": { "type": "number", "minimum": 0 } + } + }, + "platformProjection": { + "type": "object", + "additionalProperties": false, + "required": ["platform"], + "properties": { + "platform": { + "type": "string", + "enum": ["web", "ios", "android"] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "domain": { + "type": "string", + "minLength": 1 + }, + "allowedFonts": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "layout": { "$ref": "#/$defs/layout" }, + "mustNotEmit": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "shellOwnedPrimitiveAllowSources": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "notes": { + "type": "string" + } + } + }, + "state": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": ["loading", "empty", "partial", "error", "success"] + }, + "description": { + "type": "string" + } + } + }, + "node": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "section", + "group", + "heading", + "body", + "field", + "toggle", + "selection", + "action", + "alert", + "confirmation", + "empty-state", + "list", + "table", + "detail", + "progress-steps" + ] + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "children": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "sectionId": { + "type": "string", + "minLength": 1 + }, + "intent": { + "type": "string", + "minLength": 1 + }, + "textRole": { + "type": "string", + "enum": ["title", "subtitle", "body", "label", "helper", "caption", "error"] + }, + "headingLevel": { + "type": "integer", + "minimum": 1, + "maximum": 6 + }, + "fieldType": { + "type": "string", + "enum": ["text", "email", "password", "number", "date", "textarea"] + }, + "selectionMode": { + "type": "string", + "enum": ["single", "multiple"] + }, + "actionId": { + "type": "string", + "minLength": 1 + }, + "actionIntent": { + "type": "string", + "enum": [ + "submit", + "save", + "continue", + "cancel", + "confirm", + "dismiss", + "retry", + "navigate", + "filter", + "select" + ] + }, + "severity": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "stateRefs": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "platformVisibility": { + "type": "array", + "items": { + "type": "string", + "enum": ["web", "ios", "android"] + } + } + } + }, + "surface": { + "type": "object", + "additionalProperties": false, + "required": ["id", "displayName", "kind", "rootNodeId", "nodes", "platforms"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "displayName": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": ["application"] + }, + "rootNodeId": { + "type": "string", + "minLength": 1 + }, + "nodes": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/node" } + }, + "platforms": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/platformProjection" } + }, + "states": { + "type": "array", + "items": { "$ref": "#/$defs/state" } + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "phase0": { + "type": "object", + "additionalProperties": false, + "properties": { + "authPosture": { + "type": "string", + "enum": ["public", "auth-aware", "auth-first"] + }, + "requiresShell": { "type": "boolean" }, + "expectsAuthRoutes": { "type": "boolean" }, + "expectsDesignSystem": { "type": "boolean" } + } + }, + "icons": { + "type": "object", + "additionalProperties": false, + "required": ["policy", "allowedSources"], + "properties": { + "policy": { "type": "string", "enum": ["off", "warn", "strict"] }, + "allowedSources": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "flows": { + "type": "object", + "additionalProperties": false, + "required": ["policy", "requirements"], + "properties": { + "policy": { "type": "string", "enum": ["off", "warn", "strict"] }, + "requirements": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["flowId"], + "properties": { + "flowId": { "type": "string", "minLength": 1 }, + "minSteps": { "type": "integer", "minimum": 1 }, + "requiredSteps": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "requiredTransitions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["from", "to"], + "properties": { + "from": { "type": "string", "minLength": 1 }, + "to": { "type": "string", "minLength": 1 } + } + } + }, + "terminalSteps": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + } + } + } + }, + "governance": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": ["draft", "review", "approved", "published"] + }, + "roles": { + "type": "object", + "additionalProperties": false, + "properties": { + "designers": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "engineers": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "approvers": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "approvals": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["role", "owner", "status"], + "properties": { + "role": { + "type": "string", + "enum": ["designer", "engineering", "product", "qa", "operations", "other"] + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": ["pending", "approved", "rejected"] + }, + "note": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "properties": { + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "mutationEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { + "type": "string", + "enum": [ + "locked", + "content-only", + "slot-bound", + "layout-tuning", + "section-assembly", + "freeform" + ] + }, + "scopes": { + "type": "array", + "items": { + "type": "string", + "enum": ["content", "components", "layout", "sections", "interactions"] + } + }, + "allowedActions": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "allowedSections": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "prohibitedSections": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "contexts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "when"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "when": { "type": "string", "minLength": 1 }, + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "kind": { + "type": "string", + "enum": ["loading", "empty", "partial", "error", "success"] + }, + "requiredSections": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "prohibitedSections": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "requiredRecoveryActions": { + "type": "array", + "items": { + "type": "string", + "enum": ["retry", "refresh", "dismiss", "contact-support", "navigate-home", "go-back"] + } + }, + "preserveSections": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "preserveLastGoodContent": { "type": "boolean" }, + "blockedActionsWhilePending": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "allowedLayoutIntents": { + "type": "array", + "items": { + "type": "string", + "enum": ["stack", "columns", "auto-fit-grid", "sidebar-main", "single-column-form"] + } + }, + "notes": { "type": "string" } + } + } + }, + "feedbackRecovery": { + "type": "object", + "additionalProperties": false, + "required": ["policy"], + "properties": { + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "requiredStateKinds": { + "type": "array", + "items": { + "type": "string", + "enum": ["loading", "empty", "partial", "error", "success"] + } + } + } + } + } + } + } + } + } +} diff --git a/packages/interfacectl-validator/src/ui-ast.ts b/packages/interfacectl-validator/src/ui-ast.ts new file mode 100644 index 0000000..c455f92 --- /dev/null +++ b/packages/interfacectl-validator/src/ui-ast.ts @@ -0,0 +1,317 @@ +import AjvModule, { type ErrorObject } from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import astSchema from "./schema/ui.surface.ast.schema.json" with { + type: "json", +}; +import type { + AsyncStateKind, + ChromePolicy, + ColorPolicy, + ContractConstraints, + ContractTokenPolicies, + FlowPolicy, + IconPolicy, + PageFrameLayout, + ShellSpec, + SurfaceGovernance, + SurfacePhase0, + SurfaceRuntimePolicy, + TargetAcquisitionPolicy, +} from "./types.js"; + +const frozenBundledUiAstSchema = Object.freeze(astSchema) as object; + +export type UiAstSurfaceKind = "application"; +export type UiAstPlatform = "web" | "ios" | "android"; +export type UiAstNodeKind = + | "section" + | "group" + | "heading" + | "body" + | "field" + | "toggle" + | "selection" + | "action" + | "alert" + | "confirmation" + | "empty-state" + | "list" + | "table" + | "detail" + | "progress-steps"; +export type UiAstActionIntent = + | "submit" + | "save" + | "continue" + | "cancel" + | "confirm" + | "dismiss" + | "retry" + | "navigate" + | "filter" + | "select"; +export type UiAstTextRole = + | "title" + | "subtitle" + | "body" + | "label" + | "helper" + | "caption" + | "error"; +export type UiAstFieldType = + | "text" + | "email" + | "password" + | "number" + | "date" + | "textarea"; +export type UiAstSelectionMode = "single" | "multiple"; +export type UiAstAlertSeverity = "info" | "success" | "warning" | "error"; + +export interface UiAstLayoutPolicy { + maxContentWidth: number; + requiredContainers?: string[]; + pageFrame?: PageFrameLayout; + chromePolicy?: ChromePolicy; + targetAcquisition?: TargetAcquisitionPolicy; +} + +export interface UiAstPlatformProjection { + platform: UiAstPlatform; + path?: string; + domain?: string; + allowedFonts?: string[]; + layout?: UiAstLayoutPolicy; + mustNotEmit?: string[]; + shellOwnedPrimitiveAllowSources?: string[]; + notes?: string; +} + +export interface UiAstStateRef { + id: string; + kind?: AsyncStateKind; + description?: string; +} + +export interface UiAstNode { + id: string; + kind: UiAstNodeKind; + label?: string; + description?: string; + children?: string[]; + sectionId?: string; + intent?: string; + textRole?: UiAstTextRole; + headingLevel?: number; + fieldType?: UiAstFieldType; + selectionMode?: UiAstSelectionMode; + actionId?: string; + actionIntent?: UiAstActionIntent; + severity?: UiAstAlertSeverity; + stateRefs?: string[]; + platformVisibility?: UiAstPlatform[]; +} + +export interface UiAstMigrationEscalation { + surfaceId?: string; + code: string; + message: string; +} + +export interface UiAstMigrationMetadata { + sourceFormat: string; + escalations: UiAstMigrationEscalation[]; +} + +export interface UiAstSurface { + id: string; + displayName: string; + kind: UiAstSurfaceKind; + rootNodeId: string; + nodes: UiAstNode[]; + platforms: UiAstPlatformProjection[]; + states?: UiAstStateRef[]; + owner?: string; + phase0?: SurfacePhase0; + governance?: SurfaceGovernance; + icons?: IconPolicy; + flows?: FlowPolicy; + runtime?: SurfaceRuntimePolicy; +} + +export interface UiSurfaceAst { + $schema?: string; + astId: string; + version: string; + description?: string; + constraints: ContractConstraints; + color: ColorPolicy; + tokens?: ContractTokenPolicies; + shell?: ShellSpec; + surfaces: UiAstSurface[]; + migration?: UiAstMigrationMetadata; +} + +export interface UiAstStructureValidation { + ok: boolean; + errors: string[]; + ast?: UiSurfaceAst; +} + +function createAjvValidator() { + const ajv = new (AjvModule as unknown as new ( + options?: Record, + ) => import("ajv").default)({ + allErrors: true, + strict: false, + }); + (addFormats as unknown as (ajv: import("ajv").default) => void)(ajv); + return ajv; +} + +function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] { + if (!errors) { + return []; + } + + return errors.map((error) => { + const dataPath = error.instancePath || error.schemaPath; + const baseMessage = error.message ?? "Validation error"; + if (error.params && Object.keys(error.params).length > 0) { + return `${dataPath}: ${baseMessage} (${JSON.stringify(error.params)})`; + } + return `${dataPath}: ${baseMessage}`; + }); +} + +export function getBundledUiAstSchema(): object { + return frozenBundledUiAstSchema; +} + +function findDuplicate(values: string[]): string | null { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) { + return value; + } + seen.add(value); + } + return null; +} + +function validateSurfaceAst(surface: UiAstSurface): string[] { + const errors: string[] = []; + const nodeIds = surface.nodes.map((node) => node.id); + const duplicateNodeId = findDuplicate(nodeIds); + if (duplicateNodeId) { + errors.push(`/surfaces/${surface.id}/nodes must use unique node ids (${duplicateNodeId})`); + } + + const stateIds = (surface.states ?? []).map((state) => state.id); + const duplicateStateId = findDuplicate(stateIds); + if (duplicateStateId) { + errors.push(`/surfaces/${surface.id}/states must use unique ids (${duplicateStateId})`); + } + + const platformIds = surface.platforms.map((platform) => platform.platform); + const duplicatePlatformId = findDuplicate(platformIds); + if (duplicatePlatformId) { + errors.push( + `/surfaces/${surface.id}/platforms must use unique platform entries (${duplicatePlatformId})`, + ); + } + + const nodeIdSet = new Set(nodeIds); + const stateIdSet = new Set(stateIds); + if (!nodeIdSet.has(surface.rootNodeId)) { + errors.push(`/surfaces/${surface.id}/rootNodeId must reference a declared node`); + } + + for (const node of surface.nodes) { + for (const childId of node.children ?? []) { + if (!nodeIdSet.has(childId)) { + errors.push( + `/surfaces/${surface.id}/nodes/${node.id}/children references missing node "${childId}"`, + ); + } + } + for (const stateRef of node.stateRefs ?? []) { + if (!stateIdSet.has(stateRef)) { + errors.push( + `/surfaces/${surface.id}/nodes/${node.id}/stateRefs references missing state "${stateRef}"`, + ); + } + } + + if (node.kind === "section" && !node.sectionId) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare sectionId when kind=section`); + } + if (node.kind === "action" && !node.actionIntent) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare actionIntent when kind=action`); + } + if (node.kind === "field" && !node.fieldType) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare fieldType when kind=field`); + } + if (node.kind === "selection" && !node.selectionMode) { + errors.push( + `/surfaces/${surface.id}/nodes/${node.id} must declare selectionMode when kind=selection`, + ); + } + if (node.kind === "alert" && !node.severity) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare severity when kind=alert`); + } + if (node.kind === "heading") { + if (!node.textRole) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} must declare textRole when kind=heading`); + } + if ( + node.headingLevel !== undefined && + (node.headingLevel < 1 || node.headingLevel > 6) + ) { + errors.push(`/surfaces/${surface.id}/nodes/${node.id} headingLevel must be between 1 and 6`); + } + } + } + + return errors; +} + +export function validateUiAstStructure( + astData: unknown, + schema: object = frozenBundledUiAstSchema, +): UiAstStructureValidation { + const ajv = createAjvValidator(); + const validate = ajv.compile(schema); + const valid = validate(astData); + + if (!valid) { + return { + ok: false, + errors: formatAjvErrors(validate.errors), + }; + } + + const ast = astData as UiSurfaceAst; + const surfaceIds = ast.surfaces.map((surface) => surface.id); + const duplicateSurfaceId = findDuplicate(surfaceIds); + if (duplicateSurfaceId) { + return { + ok: false, + errors: [`/surfaces must use unique surface ids (${duplicateSurfaceId})`], + }; + } + + const errors = ast.surfaces.flatMap((surface) => validateSurfaceAst(surface)); + if (errors.length > 0) { + return { + ok: false, + errors, + }; + } + + return { + ok: true, + errors: [], + ast, + }; +} diff --git a/packages/interfacectl-validator/test/ui-ast.test.mjs b/packages/interfacectl-validator/test/ui-ast.test.mjs new file mode 100644 index 0000000..a0805aa --- /dev/null +++ b/packages/interfacectl-validator/test/ui-ast.test.mjs @@ -0,0 +1,123 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + getBundledUiAstSchema, + validateUiAstStructure, +} from "../dist/index.js"; + +function buildAst() { + return { + astId: "validator-demo", + version: "1.0.0", + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: ["#ffffff"], + }, + surfaces: [ + { + id: "demo-surface", + displayName: "Demo Surface", + kind: "application", + rootNodeId: "demo-surface.root", + nodes: [ + { + id: "demo-surface.root", + kind: "group", + label: "Demo Surface", + children: ["main.hero", "primary.submit"], + }, + { + id: "main.hero", + kind: "section", + sectionId: "main.hero", + label: "Primary Intro", + }, + { + id: "primary.submit", + kind: "action", + label: "Continue", + actionIntent: "continue", + }, + ], + platforms: [ + { + platform: "web", + layout: { + maxContentWidth: 960, + targetAcquisition: { + policy: "warn", + minHitAreaPx: 44, + viewportOverrides: [ + { + viewport: "mobile", + minHitAreaPx: 48, + }, + ], + contextOverrides: [ + { + context: "checkout", + destructiveGapPx: 24, + }, + ], + }, + }, + }, + ], + states: [ + { + id: "checkout", + description: "Checkout flow", + }, + ], + }, + ], + }; +} + +test("validateUiAstStructure accepts a bounded semantic AST", () => { + const schema = getBundledUiAstSchema(); + const result = validateUiAstStructure(buildAst(), schema); + assert.equal(result.ok, true, JSON.stringify(result.errors)); +}); + +test("validateUiAstStructure rejects free-form styling fields", () => { + const schema = getBundledUiAstSchema(); + const ast = buildAst(); + ast.surfaces[0].nodes[1].style = { color: "red" }; + const result = validateUiAstStructure(ast, schema); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => error.includes("style"))); +}); + +test("validateUiAstStructure rejects embedded business logic fields", () => { + const schema = getBundledUiAstSchema(); + const ast = buildAst(); + ast.surfaces[0].nodes[2].handler = "if (valid) submit()"; + const result = validateUiAstStructure(ast, schema); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => error.includes("handler"))); +}); + +test("validateUiAstStructure rejects nodes without stable ids", () => { + const schema = getBundledUiAstSchema(); + const ast = buildAst(); + delete ast.surfaces[0].nodes[1].id; + const result = validateUiAstStructure(ast, schema); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => error.includes("/surfaces/0/nodes/1"))); +}); + +test("validateUiAstStructure rejects unsupported vocabulary", () => { + const schema = getBundledUiAstSchema(); + const ast = buildAst(); + ast.surfaces[0].nodes[1].kind = "card"; + const result = validateUiAstStructure(ast, schema); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => error.includes("/surfaces/0/nodes/1/kind"))); +});