From b83ec779711613e39127a00e0d42052b39d84d9d Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sat, 28 Mar 2026 19:03:00 -0700 Subject: [PATCH 1/3] 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"))); +}); From 31ccdba725f92f77b798c7bf14d9cd41feb0bf34 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sat, 28 Mar 2026 21:38:02 -0700 Subject: [PATCH 2/3] feat: add generic ui ast authoring helpers --- .../interfacectl-cli/dist/utils/ui-ast.d.ts | 2 - .../dist/utils/ui-ast.d.ts.map | 2 +- .../interfacectl-cli/dist/utils/ui-ast.js | 220 +--- packages/interfacectl-cli/src/utils/ui-ast.ts | 272 +---- .../interfacectl-validator/dist/index.d.ts | 1 + .../dist/index.d.ts.map | 2 +- packages/interfacectl-validator/dist/index.js | 1 + .../dist/ui-ast-authoring.d.ts | 46 + .../dist/ui-ast-authoring.d.ts.map | 1 + .../dist/ui-ast-authoring.js | 888 ++++++++++++++ .../interfacectl-validator/src/index.d.ts | 1 + packages/interfacectl-validator/src/index.ts | 13 + .../src/ui-ast-authoring.ts | 1081 +++++++++++++++++ .../test/ui-ast-authoring.test.mjs | 269 ++++ 14 files changed, 2306 insertions(+), 493 deletions(-) create mode 100644 packages/interfacectl-validator/dist/ui-ast-authoring.d.ts create mode 100644 packages/interfacectl-validator/dist/ui-ast-authoring.d.ts.map create mode 100644 packages/interfacectl-validator/dist/ui-ast-authoring.js create mode 100644 packages/interfacectl-validator/src/ui-ast-authoring.ts create mode 100644 packages/interfacectl-validator/test/ui-ast-authoring.test.mjs diff --git a/packages/interfacectl-cli/dist/utils/ui-ast.d.ts b/packages/interfacectl-cli/dist/utils/ui-ast.d.ts index a662ec3..9cf1bb5 100644 --- a/packages/interfacectl-cli/dist/utils/ui-ast.d.ts +++ b/packages/interfacectl-cli/dist/utils/ui-ast.d.ts @@ -18,8 +18,6 @@ interface ResolveUiAstInputOptions { 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 index 34a1c54..7c14f5f 100644 --- a/packages/interfacectl-cli/dist/utils/ui-ast.d.ts.map +++ b/packages/interfacectl-cli/dist/utils/ui-ast.d.ts.map @@ -1 +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 +{"version":3,"file":"ui-ast.d.ts","sourceRoot":"","sources":["../../src/utils/ui-ast.ts"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,iBAAiB,EACtB,KAAK,YAAY,EAClB,MAAM,kCAAkC,CAAC;AAE1C,eAAO,MAAM,gBAAgB,kCAAkC,CAAC;AAChE,eAAO,MAAM,4BAA4B,yCAAyC,CAAC;AAEnF,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;AAgDD,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 index 3ef9554..f04961d 100644 --- a/packages/interfacectl-cli/dist/utils/ui-ast.js +++ b/packages/interfacectl-cli/dist/utils/ui-ast.js @@ -1,9 +1,8 @@ import path from "node:path"; import { access, readFile } from "node:fs/promises"; -import { getBundledContractSchema, getBundledUiAstSchema, validateContractStructure, validateUiAstStructure, } from "@surfaces/interfacectl-validator"; +import { deriveLegacyContractFromUiAst, getBundledContractSchema, getBundledUiAstSchema, migrateLegacyContractToUiAst, 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); @@ -41,223 +40,6 @@ function resolveCandidatePath(workspaceRoot, 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); diff --git a/packages/interfacectl-cli/src/utils/ui-ast.ts b/packages/interfacectl-cli/src/utils/ui-ast.ts index bfedce4..82cb6d7 100644 --- a/packages/interfacectl-cli/src/utils/ui-ast.ts +++ b/packages/interfacectl-cli/src/utils/ui-ast.ts @@ -1,25 +1,18 @@ import path from "node:path"; import { access, readFile } from "node:fs/promises"; import { + deriveLegacyContractFromUiAst, getBundledContractSchema, getBundledUiAstSchema, + migrateLegacyContractToUiAst, 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; @@ -87,267 +80,6 @@ function resolveCandidatePath( : 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 { diff --git a/packages/interfacectl-validator/dist/index.d.ts b/packages/interfacectl-validator/dist/index.d.ts index 35bfb6e..f9e5f3e 100644 --- a/packages/interfacectl-validator/dist/index.d.ts +++ b/packages/interfacectl-validator/dist/index.d.ts @@ -1,5 +1,6 @@ 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 { applyUiAstChange, deriveLegacyContractFromUiAst, diffUiAst, migrateLegacyContractToUiAst, normalizeUiAst, summarizeUiAst, type UiAstChange, type UiAstChangeAction, type UiAstDiffEntry, type UiAstSummary, type UiAstSurfaceSummary, } from "./ui-ast-authoring.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 c39baa6..1849654 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;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 +{"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;AACrB,OAAO,EACL,gBAAgB,EAChB,6BAA6B,EAC7B,SAAS,EACT,4BAA4B,EAC5B,cAAc,EACd,cAAc,EACd,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,mBAAmB,GACzB,MAAM,uBAAuB,CAAC;AAgB/B,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 7d41d33..e37d104 100644 --- a/packages/interfacectl-validator/dist/index.js +++ b/packages/interfacectl-validator/dist/index.js @@ -4,6 +4,7 @@ import bundledSchema from "./schema/web.surface.contract.schema.json" with { type: "json" }; export { getBundledUiAstSchema, validateUiAstStructure, } from "./ui-ast.js"; +export { applyUiAstChange, deriveLegacyContractFromUiAst, diffUiAst, migrateLegacyContractToUiAst, normalizeUiAst, summarizeUiAst, } from "./ui-ast-authoring.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/ui-ast-authoring.d.ts b/packages/interfacectl-validator/dist/ui-ast-authoring.d.ts new file mode 100644 index 0000000..9e500bd --- /dev/null +++ b/packages/interfacectl-validator/dist/ui-ast-authoring.d.ts @@ -0,0 +1,46 @@ +import type { InterfaceContract } from "./types.js"; +import type { UiAstActionIntent, UiAstPlatform, UiSurfaceAst } from "./ui-ast.js"; +export type UiAstChangeAction = "set" | "add"; +export type UiAstDiffKind = "added" | "removed" | "modified"; +export interface UiAstChange { + path: string; + action: UiAstChangeAction; + value: string | number; + summary: string; +} +export interface UiAstDiffEntry { + path: string; + kind: UiAstDiffKind; + before?: unknown; + after?: unknown; +} +export interface UiAstSurfaceSummary { + surfaceId: string; + displayName: string; + platforms: UiAstPlatform[]; + nodeCount: number; + nodeKinds: Record; + sectionIds: string[]; + actionIntents: UiAstActionIntent[]; + stateIds: string[]; + owner: string | null; + governanceStatus: string | null; + runtimePolicy: string | null; + maxContentWidthByPlatform: Partial>; +} +export interface UiAstSummary { + astId: string; + version: string; + surfaceCount: number; + platformCount: number; + nodeCount: number; + migrationEscalationCount: number; + surfaces: UiAstSurfaceSummary[]; +} +export declare function migrateLegacyContractToUiAst(contract: InterfaceContract): UiSurfaceAst; +export declare function deriveLegacyContractFromUiAst(ast: UiSurfaceAst): InterfaceContract; +export declare function normalizeUiAst(ast: UiSurfaceAst): UiSurfaceAst; +export declare function summarizeUiAst(ast: UiSurfaceAst): UiAstSummary; +export declare function diffUiAst(before: UiSurfaceAst, after: UiSurfaceAst): UiAstDiffEntry[]; +export declare function applyUiAstChange(ast: UiSurfaceAst, change: UiAstChange): UiSurfaceAst; +//# sourceMappingURL=ui-ast-authoring.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/ui-ast-authoring.d.ts.map b/packages/interfacectl-validator/dist/ui-ast-authoring.d.ts.map new file mode 100644 index 0000000..a266da7 --- /dev/null +++ b/packages/interfacectl-validator/dist/ui-ast-authoring.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ui-ast-authoring.d.ts","sourceRoot":"","sources":["../src/ui-ast-authoring.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAIV,iBAAiB,EAClB,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EACV,iBAAiB,EAGjB,aAAa,EAIb,YAAY,EACb,MAAM,aAAa,CAAC;AAIrB,MAAM,MAAM,iBAAiB,GAAG,KAAK,GAAG,KAAK,CAAC;AAC9C,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,CAAC;AAE7D,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,iBAAiB,CAAC;IAC1B,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,EAAE,iBAAiB,EAAE,CAAC;IACnC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,yBAAyB,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,wBAAwB,EAAE,MAAM,CAAC;IACjC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;CACjC;AA+lBD,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,iBAAiB,GAAG,YAAY,CAoBtF;AAED,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,YAAY,GAAG,iBAAiB,CAoDlF;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,YAAY,GAAG,YAAY,CA6D9D;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,YAAY,GAAG,YAAY,CAyC9D;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,YAAY,GAAG,cAAc,EAAE,CAiDrF;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW,GAAG,YAAY,CAqLrF"} \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/ui-ast-authoring.js b/packages/interfacectl-validator/dist/ui-ast-authoring.js new file mode 100644 index 0000000..bbfc37e --- /dev/null +++ b/packages/interfacectl-validator/dist/ui-ast-authoring.js @@ -0,0 +1,888 @@ +const AST_SCHEMA_URL = "https://contracts.surfaces.local/ui.surface.ast.schema.json"; +function uniqueSortedStrings(values) { + if (!values || values.length === 0) { + return undefined; + } + return [...new Set(values)].sort((left, right) => left.localeCompare(right)); +} +function cloneUiAst(value) { + return structuredClone(value); +} +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, + }; +} +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()]; +} +function sortNodes(nodes) { + return [...nodes] + .map((node) => ({ + ...node, + ...(node.children ? { children: [...node.children] } : {}), + ...(node.platformVisibility + ? { + platformVisibility: [...node.platformVisibility].sort((left, right) => left.localeCompare(right)), + } + : {}), + ...(node.stateRefs ? { stateRefs: uniqueSortedStrings(node.stateRefs) } : {}), + })) + .sort((left, right) => left.id.localeCompare(right.id)); +} +function sortPlatforms(platforms) { + return [...platforms] + .map((platform) => ({ + ...platform, + ...(platform.allowedFonts ? { allowedFonts: uniqueSortedStrings(platform.allowedFonts) } : {}), + ...(platform.mustNotEmit ? { mustNotEmit: uniqueSortedStrings(platform.mustNotEmit) } : {}), + ...(platform.shellOwnedPrimitiveAllowSources + ? { + shellOwnedPrimitiveAllowSources: uniqueSortedStrings(platform.shellOwnedPrimitiveAllowSources), + } + : {}), + ...(platform.layout?.requiredContainers + ? { + layout: { + ...platform.layout, + requiredContainers: uniqueSortedStrings(platform.layout.requiredContainers), + }, + } + : {}), + })) + .sort((left, right) => left.platform.localeCompare(right.platform)); +} +function sortStates(states) { + if (!states) { + return undefined; + } + return [...states].sort((left, right) => left.id.localeCompare(right.id)); +} +function sortEscalations(escalations) { + if (!escalations) { + return undefined; + } + return [...escalations].sort((left, right) => { + const surfaceComparison = (left.surfaceId ?? "").localeCompare(right.surfaceId ?? ""); + if (surfaceComparison !== 0) { + return surfaceComparison; + } + const codeComparison = left.code.localeCompare(right.code); + if (codeComparison !== 0) { + return codeComparison; + } + return left.message.localeCompare(right.message); + }); +} +function describePathSegment(pathPrefix, key) { + if (!pathPrefix) { + return key; + } + return `${pathPrefix}.${key}`; +} +function surfacePath(surfaceId, suffix = "") { + return `surfaces[${surfaceId}]${suffix ? `.${suffix}` : ""}`; +} +function platformPath(surfaceId, platform, suffix = "") { + return `${surfacePath(surfaceId)}.platforms[${platform}]${suffix ? `.${suffix}` : ""}`; +} +function nodePath(surfaceId, nodeId, suffix = "") { + return `${surfacePath(surfaceId)}.nodes[${nodeId}]${suffix ? `.${suffix}` : ""}`; +} +function statePath(surfaceId, stateId, suffix = "") { + return `${surfacePath(surfaceId)}.states[${stateId}]${suffix ? `.${suffix}` : ""}`; +} +function diffScalarArray(before, after, pathPrefix) { + const entries = []; + const beforeSet = new Set(before ?? []); + const afterSet = new Set(after ?? []); + for (const value of [...beforeSet].sort((left, right) => left.localeCompare(right))) { + if (!afterSet.has(value)) { + entries.push({ path: `${pathPrefix}[${value}]`, kind: "removed", before: value }); + } + } + for (const value of [...afterSet].sort((left, right) => left.localeCompare(right))) { + if (!beforeSet.has(value)) { + entries.push({ path: `${pathPrefix}[${value}]`, kind: "added", after: value }); + } + } + return entries; +} +function diffUnknown(before, after, pathPrefix = "") { + if (JSON.stringify(before) === JSON.stringify(after)) { + return []; + } + if (Array.isArray(before) || Array.isArray(after)) { + const beforeArray = Array.isArray(before) ? before : []; + const afterArray = Array.isArray(after) ? after : []; + const beforeObjectArray = beforeArray.every((entry) => entry && typeof entry === "object" && !Array.isArray(entry)); + const afterObjectArray = afterArray.every((entry) => entry && typeof entry === "object" && !Array.isArray(entry)); + if (!beforeObjectArray && !afterObjectArray) { + return [ + { + path: pathPrefix, + kind: "modified", + before, + after, + }, + ]; + } + } + if (before + && after + && typeof before === "object" + && typeof after === "object" + && !Array.isArray(before) + && !Array.isArray(after)) { + const entries = []; + const beforeRecord = before; + const afterRecord = after; + const keys = [...new Set([...Object.keys(beforeRecord), ...Object.keys(afterRecord)])].sort((left, right) => left.localeCompare(right)); + for (const key of keys) { + entries.push(...diffUnknown(beforeRecord[key], afterRecord[key], describePathSegment(pathPrefix, key))); + } + return entries; + } + return [{ path: pathPrefix, kind: "modified", before, after }]; +} +function diffSurface(before, after) { + if (!before && !after) { + return []; + } + if (!before && after) { + return [{ path: surfacePath(after.id), kind: "added", after }]; + } + if (before && !after) { + return [{ path: surfacePath(before.id), kind: "removed", before }]; + } + const left = before; + const right = after; + const entries = []; + if (left.displayName !== right.displayName) { + entries.push({ + path: surfacePath(left.id, "displayName"), + kind: "modified", + before: left.displayName, + after: right.displayName, + }); + } + if (left.owner !== right.owner) { + entries.push({ + path: surfacePath(left.id, "owner"), + kind: "modified", + before: left.owner ?? null, + after: right.owner ?? null, + }); + } + if ((left.governance?.status ?? null) !== (right.governance?.status ?? null)) { + entries.push({ + path: surfacePath(left.id, "governance.status"), + kind: "modified", + before: left.governance?.status ?? null, + after: right.governance?.status ?? null, + }); + } + entries.push(...diffScalarArray(left.governance?.roles?.designers, right.governance?.roles?.designers, surfacePath(left.id, "governance.roles.designers"))); + entries.push(...diffScalarArray(left.governance?.roles?.engineers, right.governance?.roles?.engineers, surfacePath(left.id, "governance.roles.engineers"))); + if ((left.runtime?.policy ?? null) !== (right.runtime?.policy ?? null)) { + entries.push({ + path: surfacePath(left.id, "runtime.policy"), + kind: "modified", + before: left.runtime?.policy ?? null, + after: right.runtime?.policy ?? null, + }); + } + if ((left.runtime?.mutationEnvelope?.mode ?? null) !== (right.runtime?.mutationEnvelope?.mode ?? null)) { + entries.push({ + path: surfacePath(left.id, "runtime.mutationEnvelope.mode"), + kind: "modified", + before: left.runtime?.mutationEnvelope?.mode ?? null, + after: right.runtime?.mutationEnvelope?.mode ?? null, + }); + } + entries.push(...diffScalarArray(left.runtime?.mutationEnvelope?.allowedSections, right.runtime?.mutationEnvelope?.allowedSections, surfacePath(left.id, "runtime.mutationEnvelope.allowedSections"))); + const leftPlatforms = new Map(left.platforms.map((platform) => [platform.platform, platform])); + const rightPlatforms = new Map(right.platforms.map((platform) => [platform.platform, platform])); + const platformIds = [...new Set([...leftPlatforms.keys(), ...rightPlatforms.keys()])] + .sort((a, b) => a.localeCompare(b)); + for (const platformId of platformIds) { + const beforePlatform = leftPlatforms.get(platformId); + const afterPlatform = rightPlatforms.get(platformId); + if (!beforePlatform && afterPlatform) { + entries.push({ + path: platformPath(left.id, afterPlatform.platform), + kind: "added", + after: afterPlatform, + }); + continue; + } + if (beforePlatform && !afterPlatform) { + entries.push({ + path: platformPath(left.id, beforePlatform.platform), + kind: "removed", + before: beforePlatform, + }); + continue; + } + if (!beforePlatform || !afterPlatform) { + continue; + } + entries.push(...diffScalarArray(beforePlatform.allowedFonts, afterPlatform.allowedFonts, platformPath(left.id, beforePlatform.platform, "allowedFonts"))); + if ((beforePlatform.layout?.maxContentWidth ?? null) !== (afterPlatform.layout?.maxContentWidth ?? null)) { + entries.push({ + path: platformPath(left.id, beforePlatform.platform, "layout.maxContentWidth"), + kind: "modified", + before: beforePlatform.layout?.maxContentWidth ?? null, + after: afterPlatform.layout?.maxContentWidth ?? null, + }); + } + } + const leftNodes = new Map(left.nodes.map((node) => [node.id, node])); + const rightNodes = new Map(right.nodes.map((node) => [node.id, node])); + const nodeIds = [...new Set([...leftNodes.keys(), ...rightNodes.keys()])] + .sort((a, b) => a.localeCompare(b)); + for (const nodeId of nodeIds) { + const beforeNode = leftNodes.get(nodeId); + const afterNode = rightNodes.get(nodeId); + if (!beforeNode && afterNode) { + entries.push({ path: nodePath(left.id, afterNode.id), kind: "added", after: afterNode }); + continue; + } + if (beforeNode && !afterNode) { + entries.push({ path: nodePath(left.id, beforeNode.id), kind: "removed", before: beforeNode }); + continue; + } + if (!beforeNode || !afterNode) { + continue; + } + if ((beforeNode.actionIntent ?? null) !== (afterNode.actionIntent ?? null)) { + entries.push({ + path: nodePath(left.id, nodeId, "actionIntent"), + kind: "modified", + before: beforeNode.actionIntent ?? null, + after: afterNode.actionIntent ?? null, + }); + } + if ((beforeNode.label ?? null) !== (afterNode.label ?? null)) { + entries.push({ + path: nodePath(left.id, nodeId, "label"), + kind: "modified", + before: beforeNode.label ?? null, + after: afterNode.label ?? null, + }); + } + if ((beforeNode.description ?? null) !== (afterNode.description ?? null)) { + entries.push({ + path: nodePath(left.id, nodeId, "description"), + kind: "modified", + before: beforeNode.description ?? null, + after: afterNode.description ?? null, + }); + } + } + const leftStates = new Map((left.states ?? []).map((state) => [state.id, state])); + const rightStates = new Map((right.states ?? []).map((state) => [state.id, state])); + const stateIds = [...new Set([...leftStates.keys(), ...rightStates.keys()])] + .sort((a, b) => a.localeCompare(b)); + for (const stateId of stateIds) { + const beforeState = leftStates.get(stateId); + const afterState = rightStates.get(stateId); + if (!beforeState && afterState) { + entries.push({ path: statePath(left.id, afterState.id), kind: "added", after: afterState }); + continue; + } + if (beforeState && !afterState) { + entries.push({ path: statePath(left.id, beforeState.id), kind: "removed", before: beforeState }); + continue; + } + if (!beforeState || !afterState) { + continue; + } + if ((beforeState.kind ?? null) !== (afterState.kind ?? null)) { + entries.push({ + path: statePath(left.id, stateId, "kind"), + kind: "modified", + before: beforeState.kind ?? null, + after: afterState.kind ?? null, + }); + } + if ((beforeState.description ?? null) !== (afterState.description ?? null)) { + entries.push({ + path: statePath(left.id, stateId, "description"), + kind: "modified", + before: beforeState.description ?? null, + after: afterState.description ?? null, + }); + } + } + return entries; +} +function parseBracketSelector(value, collection) { + const match = value.match(new RegExp(`^${collection}\\[([^\\]]+)\\](?:\\.(.+))?$`)); + if (!match?.[1]) { + return null; + } + return { + key: match[1], + suffix: match[2] ?? "", + }; +} +function resolveSurface(ast, surfaceId) { + const surface = ast.surfaces.find((candidate) => candidate.id === surfaceId); + if (!surface) { + throw new Error(`Unknown AST surface "${surfaceId}".`); + } + return surface; +} +function resolvePlatform(surface, platform) { + const projection = surface.platforms.find((candidate) => candidate.platform === platform); + if (!projection) { + throw new Error(`Unknown AST platform "${platform}" for surface "${surface.id}".`); + } + return projection; +} +function resolveNode(surface, nodeId) { + const node = surface.nodes.find((candidate) => candidate.id === nodeId); + if (!node) { + throw new Error(`Unknown AST node "${nodeId}" for surface "${surface.id}".`); + } + return node; +} +function resolveState(surface, stateId) { + const state = surface.states?.find((candidate) => candidate.id === stateId); + if (!state) { + throw new Error(`Unknown AST state "${stateId}" for surface "${surface.id}".`); + } + return state; +} +function addUniqueValue(target, value) { + return uniqueSortedStrings([...(target ?? []), value]) ?? []; +} +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), + }, + }; +} +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 function normalizeUiAst(ast) { + const cloned = cloneUiAst(ast); + return { + ...cloned, + color: { + ...cloned.color, + allowedValues: uniqueSortedStrings(cloned.color.allowedValues) ?? [], + }, + ...(cloned.surfaces + ? { + surfaces: [...cloned.surfaces] + .map((surface) => ({ + ...surface, + nodes: sortNodes(surface.nodes), + platforms: sortPlatforms(surface.platforms), + ...(surface.states ? { states: sortStates(surface.states) } : {}), + ...(surface.governance?.roles + ? { + governance: { + ...surface.governance, + roles: { + ...(surface.governance.roles.designers + ? { + designers: uniqueSortedStrings(surface.governance.roles.designers), + } + : {}), + ...(surface.governance.roles.engineers + ? { + engineers: uniqueSortedStrings(surface.governance.roles.engineers), + } + : {}), + }, + }, + } + : {}), + ...(surface.runtime?.mutationEnvelope?.allowedSections + && surface.runtime?.mutationEnvelope?.mode + ? { + runtime: { + ...surface.runtime, + mutationEnvelope: { + ...surface.runtime.mutationEnvelope, + mode: surface.runtime.mutationEnvelope.mode, + allowedSections: uniqueSortedStrings(surface.runtime.mutationEnvelope.allowedSections), + }, + }, + } + : {}), + })) + .sort((left, right) => left.id.localeCompare(right.id)), + } + : {}), + ...(cloned.migration + ? { + migration: { + ...cloned.migration, + escalations: sortEscalations(cloned.migration.escalations) ?? [], + }, + } + : {}), + }; +} +export function summarizeUiAst(ast) { + const normalized = normalizeUiAst(ast); + const surfaces = normalized.surfaces.map((surface) => { + const nodeKinds = surface.nodes.reduce((counts, node) => { + counts[node.kind] = (counts[node.kind] ?? 0) + 1; + return counts; + }, {}); + const maxContentWidthByPlatform = Object.fromEntries(surface.platforms + .filter((platform) => typeof platform.layout?.maxContentWidth === "number") + .map((platform) => [platform.platform, platform.layout.maxContentWidth])); + return { + surfaceId: surface.id, + displayName: surface.displayName, + platforms: surface.platforms.map((platform) => platform.platform), + nodeCount: surface.nodes.length, + nodeKinds, + sectionIds: traverseSectionOrder(surface).map((node) => node.sectionId ?? node.id), + actionIntents: [...new Set(surface.nodes + .map((node) => node.actionIntent) + .filter((value) => typeof value === "string"))].sort((left, right) => left.localeCompare(right)), + stateIds: (surface.states ?? []).map((state) => state.id), + owner: surface.owner ?? null, + governanceStatus: surface.governance?.status ?? null, + runtimePolicy: surface.runtime?.policy ?? null, + maxContentWidthByPlatform, + }; + }); + return { + astId: normalized.astId, + version: normalized.version, + surfaceCount: normalized.surfaces.length, + platformCount: surfaces.reduce((count, surface) => count + surface.platforms.length, 0), + nodeCount: surfaces.reduce((count, surface) => count + surface.nodeCount, 0), + migrationEscalationCount: normalized.migration?.escalations.length ?? 0, + surfaces, + }; +} +export function diffUiAst(before, after) { + const left = normalizeUiAst(before); + const right = normalizeUiAst(after); + const entries = []; + if (left.astId !== right.astId) { + entries.push({ path: "astId", kind: "modified", before: left.astId, after: right.astId }); + } + if (left.version !== right.version) { + entries.push({ path: "version", kind: "modified", before: left.version, after: right.version }); + } + if ((left.description ?? null) !== (right.description ?? null)) { + entries.push({ + path: "description", + kind: "modified", + before: left.description ?? null, + after: right.description ?? null, + }); + } + if ((left.color.policy ?? null) !== (right.color.policy ?? null)) { + entries.push({ + path: "color.policy", + kind: "modified", + before: left.color.policy ?? null, + after: right.color.policy ?? null, + }); + } + entries.push(...diffScalarArray(left.color.allowedValues, right.color.allowedValues, "color.allowedValues")); + const leftSurfaces = new Map(left.surfaces.map((surface) => [surface.id, surface])); + const rightSurfaces = new Map(right.surfaces.map((surface) => [surface.id, surface])); + const surfaceIds = [...new Set([...leftSurfaces.keys(), ...rightSurfaces.keys()])] + .sort((a, b) => a.localeCompare(b)); + for (const surfaceId of surfaceIds) { + entries.push(...diffSurface(leftSurfaces.get(surfaceId), rightSurfaces.get(surfaceId))); + } + entries.push(...diffUnknown(left.constraints, right.constraints, "constraints")); + entries.push(...diffUnknown(left.tokens, right.tokens, "tokens")); + entries.push(...diffUnknown(left.shell, right.shell, "shell")); + entries.push(...diffUnknown(left.migration, right.migration, "migration")); + return entries.sort((leftEntry, rightEntry) => { + const pathComparison = leftEntry.path.localeCompare(rightEntry.path); + if (pathComparison !== 0) { + return pathComparison; + } + return leftEntry.kind.localeCompare(rightEntry.kind); + }); +} +export function applyUiAstChange(ast, change) { + const next = normalizeUiAst(cloneUiAst(ast)); + if (change.path === "color.policy") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + next.color.policy = change.value; + return next; + } + if (change.path === "color.allowedValues") { + if (change.action !== "add" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + next.color.allowedValues = addUniqueValue(next.color.allowedValues, change.value); + return next; + } + const surfaceMatch = parseBracketSelector(change.path, "surfaces"); + if (!surfaceMatch) { + throw new Error(`Unsupported AST change path "${change.path}".`); + } + const surface = resolveSurface(next, surfaceMatch.key); + if (surfaceMatch.suffix === "owner") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.owner = change.value; + return next; + } + if (surfaceMatch.suffix === "governance.status") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.governance = { + ...surface.governance, + status: change.value, + }; + return next; + } + if (surfaceMatch.suffix === "governance.roles.designers") { + if (change.action !== "add" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.governance = { + ...surface.governance, + roles: { + ...surface.governance?.roles, + designers: addUniqueValue(surface.governance?.roles?.designers, change.value), + }, + }; + return next; + } + if (surfaceMatch.suffix === "governance.roles.engineers") { + if (change.action !== "add" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.governance = { + ...surface.governance, + roles: { + ...surface.governance?.roles, + engineers: addUniqueValue(surface.governance?.roles?.engineers, change.value), + }, + }; + return next; + } + if (surfaceMatch.suffix === "runtime.policy") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.runtime = { + ...surface.runtime, + policy: change.value, + }; + return next; + } + if (surfaceMatch.suffix === "runtime.mutationEnvelope.mode") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.runtime = { + ...surface.runtime, + mutationEnvelope: { + ...surface.runtime?.mutationEnvelope, + mode: change.value, + }, + }; + return next; + } + if (surfaceMatch.suffix === "runtime.mutationEnvelope.allowedSections") { + if (change.action !== "add" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + const existingMode = surface.runtime?.mutationEnvelope?.mode; + if (!existingMode) { + throw new Error(`AST mutation envelope mode must exist before adding allowed sections for ${change.path}.`); + } + surface.runtime = { + ...surface.runtime, + mutationEnvelope: { + ...surface.runtime?.mutationEnvelope, + mode: existingMode, + allowedSections: addUniqueValue(surface.runtime?.mutationEnvelope?.allowedSections, change.value), + }, + }; + return next; + } + const platformMatch = parseBracketSelector(surfaceMatch.suffix, "platforms"); + if (platformMatch) { + const platform = resolvePlatform(surface, platformMatch.key); + if (platformMatch.suffix === "allowedFonts") { + if (change.action !== "add" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + platform.allowedFonts = addUniqueValue(platform.allowedFonts, change.value); + return next; + } + if (platformMatch.suffix === "layout.maxContentWidth") { + if (change.action !== "set" || typeof change.value !== "number") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + platform.layout = { + ...platform.layout, + maxContentWidth: change.value, + }; + return next; + } + throw new Error(`Unsupported AST platform change path "${change.path}".`); + } + const nodeMatch = parseBracketSelector(surfaceMatch.suffix, "nodes"); + if (nodeMatch) { + const node = resolveNode(surface, nodeMatch.key); + if (nodeMatch.suffix === "label") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + node.label = change.value; + return next; + } + if (nodeMatch.suffix === "description") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + node.description = change.value; + return next; + } + if (nodeMatch.suffix === "actionIntent") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + node.actionIntent = change.value; + return next; + } + throw new Error(`Unsupported AST node change path "${change.path}".`); + } + const stateMatch = parseBracketSelector(surfaceMatch.suffix, "states"); + if (stateMatch) { + const state = resolveState(surface, stateMatch.key); + if (stateMatch.suffix === "description") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + state.description = change.value; + return next; + } + if (stateMatch.suffix === "kind") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + state.kind = change.value; + return next; + } + throw new Error(`Unsupported AST state change path "${change.path}".`); + } + throw new Error(`Unsupported AST change path "${change.path}".`); +} diff --git a/packages/interfacectl-validator/src/index.d.ts b/packages/interfacectl-validator/src/index.d.ts index e0f7029..7e5addb 100644 --- a/packages/interfacectl-validator/src/index.d.ts +++ b/packages/interfacectl-validator/src/index.d.ts @@ -10,4 +10,5 @@ export declare function evaluateSurfaceCompliance(contract: InterfaceContract, d 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"; 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 { applyUiAstChange, deriveLegacyContractFromUiAst, diffUiAst, migrateLegacyContractToUiAst, normalizeUiAst, summarizeUiAst, type UiAstChange, type UiAstChangeAction, type UiAstDiffEntry, type UiAstSummary, type UiAstSurfaceSummary } from "./ui-ast-authoring.js"; //# sourceMappingURL=index.d.ts.map diff --git a/packages/interfacectl-validator/src/index.ts b/packages/interfacectl-validator/src/index.ts index 248152a..f9e7ace 100644 --- a/packages/interfacectl-validator/src/index.ts +++ b/packages/interfacectl-validator/src/index.ts @@ -43,6 +43,19 @@ export { type UiAstTextRole, type UiSurfaceAst, } from "./ui-ast.js"; +export { + applyUiAstChange, + deriveLegacyContractFromUiAst, + diffUiAst, + migrateLegacyContractToUiAst, + normalizeUiAst, + summarizeUiAst, + type UiAstChange, + type UiAstChangeAction, + type UiAstDiffEntry, + type UiAstSummary, + type UiAstSurfaceSummary, +} from "./ui-ast-authoring.js"; import { normalizeColorValue } from "./color-policy.js"; import { matchTokenPolicy, normalizeTokenLiteralValue } from "./token-policy.js"; diff --git a/packages/interfacectl-validator/src/ui-ast-authoring.ts b/packages/interfacectl-validator/src/ui-ast-authoring.ts new file mode 100644 index 0000000..e2de966 --- /dev/null +++ b/packages/interfacectl-validator/src/ui-ast-authoring.ts @@ -0,0 +1,1081 @@ +import type { + ContractSection, + ContractSurface, + FlowPolicy, + InterfaceContract, +} from "./types.js"; +import type { + UiAstActionIntent, + UiAstMigrationEscalation, + UiAstNode, + UiAstPlatform, + UiAstPlatformProjection, + UiAstStateRef, + UiAstSurface, + UiSurfaceAst, +} from "./ui-ast.js"; + +const AST_SCHEMA_URL = "https://contracts.surfaces.local/ui.surface.ast.schema.json"; + +export type UiAstChangeAction = "set" | "add"; +export type UiAstDiffKind = "added" | "removed" | "modified"; + +export interface UiAstChange { + path: string; + action: UiAstChangeAction; + value: string | number; + summary: string; +} + +export interface UiAstDiffEntry { + path: string; + kind: UiAstDiffKind; + before?: unknown; + after?: unknown; +} + +export interface UiAstSurfaceSummary { + surfaceId: string; + displayName: string; + platforms: UiAstPlatform[]; + nodeCount: number; + nodeKinds: Record; + sectionIds: string[]; + actionIntents: UiAstActionIntent[]; + stateIds: string[]; + owner: string | null; + governanceStatus: string | null; + runtimePolicy: string | null; + maxContentWidthByPlatform: Partial>; +} + +export interface UiAstSummary { + astId: string; + version: string; + surfaceCount: number; + platformCount: number; + nodeCount: number; + migrationEscalationCount: number; + surfaces: UiAstSurfaceSummary[]; +} + +function uniqueSortedStrings(values: string[] | undefined): string[] | undefined { + if (!values || values.length === 0) { + return undefined; + } + return [...new Set(values)].sort((left, right) => left.localeCompare(right)); +} + +function cloneUiAst(value: T): T { + return structuredClone(value); +} + +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, + }; +} + +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()]; +} + +function sortNodes(nodes: UiAstNode[]): UiAstNode[] { + return [...nodes] + .map((node) => ({ + ...node, + ...(node.children ? { children: [...node.children] } : {}), + ...(node.platformVisibility + ? { + platformVisibility: [...node.platformVisibility].sort((left, right) => + left.localeCompare(right), + ), + } + : {}), + ...(node.stateRefs ? { stateRefs: uniqueSortedStrings(node.stateRefs) } : {}), + })) + .sort((left, right) => left.id.localeCompare(right.id)); +} + +function sortPlatforms(platforms: UiAstPlatformProjection[]): UiAstPlatformProjection[] { + return [...platforms] + .map((platform) => ({ + ...platform, + ...(platform.allowedFonts ? { allowedFonts: uniqueSortedStrings(platform.allowedFonts) } : {}), + ...(platform.mustNotEmit ? { mustNotEmit: uniqueSortedStrings(platform.mustNotEmit) } : {}), + ...(platform.shellOwnedPrimitiveAllowSources + ? { + shellOwnedPrimitiveAllowSources: uniqueSortedStrings(platform.shellOwnedPrimitiveAllowSources), + } + : {}), + ...(platform.layout?.requiredContainers + ? { + layout: { + ...platform.layout, + requiredContainers: uniqueSortedStrings(platform.layout.requiredContainers), + }, + } + : {}), + })) + .sort((left, right) => left.platform.localeCompare(right.platform)); +} + +function sortStates(states: UiAstStateRef[] | undefined): UiAstStateRef[] | undefined { + if (!states) { + return undefined; + } + return [...states].sort((left, right) => left.id.localeCompare(right.id)); +} + +function sortEscalations( + escalations: UiAstMigrationEscalation[] | undefined, +): UiAstMigrationEscalation[] | undefined { + if (!escalations) { + return undefined; + } + return [...escalations].sort((left, right) => { + const surfaceComparison = (left.surfaceId ?? "").localeCompare(right.surfaceId ?? ""); + if (surfaceComparison !== 0) { + return surfaceComparison; + } + const codeComparison = left.code.localeCompare(right.code); + if (codeComparison !== 0) { + return codeComparison; + } + return left.message.localeCompare(right.message); + }); +} + +function describePathSegment(pathPrefix: string, key: string): string { + if (!pathPrefix) { + return key; + } + return `${pathPrefix}.${key}`; +} + +function surfacePath(surfaceId: string, suffix = ""): string { + return `surfaces[${surfaceId}]${suffix ? `.${suffix}` : ""}`; +} + +function platformPath(surfaceId: string, platform: UiAstPlatform, suffix = ""): string { + return `${surfacePath(surfaceId)}.platforms[${platform}]${suffix ? `.${suffix}` : ""}`; +} + +function nodePath(surfaceId: string, nodeId: string, suffix = ""): string { + return `${surfacePath(surfaceId)}.nodes[${nodeId}]${suffix ? `.${suffix}` : ""}`; +} + +function statePath(surfaceId: string, stateId: string, suffix = ""): string { + return `${surfacePath(surfaceId)}.states[${stateId}]${suffix ? `.${suffix}` : ""}`; +} + +function diffScalarArray( + before: string[] | undefined, + after: string[] | undefined, + pathPrefix: string, +): UiAstDiffEntry[] { + const entries: UiAstDiffEntry[] = []; + const beforeSet = new Set(before ?? []); + const afterSet = new Set(after ?? []); + + for (const value of [...beforeSet].sort((left, right) => left.localeCompare(right))) { + if (!afterSet.has(value)) { + entries.push({ path: `${pathPrefix}[${value}]`, kind: "removed", before: value }); + } + } + for (const value of [...afterSet].sort((left, right) => left.localeCompare(right))) { + if (!beforeSet.has(value)) { + entries.push({ path: `${pathPrefix}[${value}]`, kind: "added", after: value }); + } + } + return entries; +} + +function diffUnknown(before: unknown, after: unknown, pathPrefix = ""): UiAstDiffEntry[] { + if (JSON.stringify(before) === JSON.stringify(after)) { + return []; + } + + if (Array.isArray(before) || Array.isArray(after)) { + const beforeArray = Array.isArray(before) ? before : []; + const afterArray = Array.isArray(after) ? after : []; + const beforeObjectArray = beforeArray.every((entry) => entry && typeof entry === "object" && !Array.isArray(entry)); + const afterObjectArray = afterArray.every((entry) => entry && typeof entry === "object" && !Array.isArray(entry)); + if (!beforeObjectArray && !afterObjectArray) { + return [ + { + path: pathPrefix, + kind: "modified", + before, + after, + }, + ]; + } + } + + if ( + before + && after + && typeof before === "object" + && typeof after === "object" + && !Array.isArray(before) + && !Array.isArray(after) + ) { + const entries: UiAstDiffEntry[] = []; + const beforeRecord = before as Record; + const afterRecord = after as Record; + const keys = [...new Set([...Object.keys(beforeRecord), ...Object.keys(afterRecord)])].sort((left, right) => + left.localeCompare(right), + ); + for (const key of keys) { + entries.push(...diffUnknown(beforeRecord[key], afterRecord[key], describePathSegment(pathPrefix, key))); + } + return entries; + } + + return [{ path: pathPrefix, kind: "modified", before, after }]; +} + +function diffSurface(before: UiAstSurface | undefined, after: UiAstSurface | undefined): UiAstDiffEntry[] { + if (!before && !after) { + return []; + } + if (!before && after) { + return [{ path: surfacePath(after.id), kind: "added", after }]; + } + if (before && !after) { + return [{ path: surfacePath(before.id), kind: "removed", before }]; + } + + const left = before!; + const right = after!; + const entries: UiAstDiffEntry[] = []; + + if (left.displayName !== right.displayName) { + entries.push({ + path: surfacePath(left.id, "displayName"), + kind: "modified", + before: left.displayName, + after: right.displayName, + }); + } + if (left.owner !== right.owner) { + entries.push({ + path: surfacePath(left.id, "owner"), + kind: "modified", + before: left.owner ?? null, + after: right.owner ?? null, + }); + } + if ((left.governance?.status ?? null) !== (right.governance?.status ?? null)) { + entries.push({ + path: surfacePath(left.id, "governance.status"), + kind: "modified", + before: left.governance?.status ?? null, + after: right.governance?.status ?? null, + }); + } + entries.push( + ...diffScalarArray( + left.governance?.roles?.designers, + right.governance?.roles?.designers, + surfacePath(left.id, "governance.roles.designers"), + ), + ); + entries.push( + ...diffScalarArray( + left.governance?.roles?.engineers, + right.governance?.roles?.engineers, + surfacePath(left.id, "governance.roles.engineers"), + ), + ); + if ((left.runtime?.policy ?? null) !== (right.runtime?.policy ?? null)) { + entries.push({ + path: surfacePath(left.id, "runtime.policy"), + kind: "modified", + before: left.runtime?.policy ?? null, + after: right.runtime?.policy ?? null, + }); + } + if ((left.runtime?.mutationEnvelope?.mode ?? null) !== (right.runtime?.mutationEnvelope?.mode ?? null)) { + entries.push({ + path: surfacePath(left.id, "runtime.mutationEnvelope.mode"), + kind: "modified", + before: left.runtime?.mutationEnvelope?.mode ?? null, + after: right.runtime?.mutationEnvelope?.mode ?? null, + }); + } + entries.push( + ...diffScalarArray( + left.runtime?.mutationEnvelope?.allowedSections, + right.runtime?.mutationEnvelope?.allowedSections, + surfacePath(left.id, "runtime.mutationEnvelope.allowedSections"), + ), + ); + + const leftPlatforms = new Map(left.platforms.map((platform) => [platform.platform, platform])); + const rightPlatforms = new Map(right.platforms.map((platform) => [platform.platform, platform])); + const platformIds = [...new Set([...leftPlatforms.keys(), ...rightPlatforms.keys()])] + .sort((a, b) => a.localeCompare(b)); + for (const platformId of platformIds) { + const beforePlatform = leftPlatforms.get(platformId); + const afterPlatform = rightPlatforms.get(platformId); + if (!beforePlatform && afterPlatform) { + entries.push({ + path: platformPath(left.id, afterPlatform.platform), + kind: "added", + after: afterPlatform, + }); + continue; + } + if (beforePlatform && !afterPlatform) { + entries.push({ + path: platformPath(left.id, beforePlatform.platform), + kind: "removed", + before: beforePlatform, + }); + continue; + } + if (!beforePlatform || !afterPlatform) { + continue; + } + entries.push( + ...diffScalarArray( + beforePlatform.allowedFonts, + afterPlatform.allowedFonts, + platformPath(left.id, beforePlatform.platform, "allowedFonts"), + ), + ); + if ((beforePlatform.layout?.maxContentWidth ?? null) !== (afterPlatform.layout?.maxContentWidth ?? null)) { + entries.push({ + path: platformPath(left.id, beforePlatform.platform, "layout.maxContentWidth"), + kind: "modified", + before: beforePlatform.layout?.maxContentWidth ?? null, + after: afterPlatform.layout?.maxContentWidth ?? null, + }); + } + } + + const leftNodes = new Map(left.nodes.map((node) => [node.id, node])); + const rightNodes = new Map(right.nodes.map((node) => [node.id, node])); + const nodeIds = [...new Set([...leftNodes.keys(), ...rightNodes.keys()])] + .sort((a, b) => a.localeCompare(b)); + for (const nodeId of nodeIds) { + const beforeNode = leftNodes.get(nodeId); + const afterNode = rightNodes.get(nodeId); + if (!beforeNode && afterNode) { + entries.push({ path: nodePath(left.id, afterNode.id), kind: "added", after: afterNode }); + continue; + } + if (beforeNode && !afterNode) { + entries.push({ path: nodePath(left.id, beforeNode.id), kind: "removed", before: beforeNode }); + continue; + } + if (!beforeNode || !afterNode) { + continue; + } + if ((beforeNode.actionIntent ?? null) !== (afterNode.actionIntent ?? null)) { + entries.push({ + path: nodePath(left.id, nodeId, "actionIntent"), + kind: "modified", + before: beforeNode.actionIntent ?? null, + after: afterNode.actionIntent ?? null, + }); + } + if ((beforeNode.label ?? null) !== (afterNode.label ?? null)) { + entries.push({ + path: nodePath(left.id, nodeId, "label"), + kind: "modified", + before: beforeNode.label ?? null, + after: afterNode.label ?? null, + }); + } + if ((beforeNode.description ?? null) !== (afterNode.description ?? null)) { + entries.push({ + path: nodePath(left.id, nodeId, "description"), + kind: "modified", + before: beforeNode.description ?? null, + after: afterNode.description ?? null, + }); + } + } + + const leftStates = new Map((left.states ?? []).map((state) => [state.id, state])); + const rightStates = new Map((right.states ?? []).map((state) => [state.id, state])); + const stateIds = [...new Set([...leftStates.keys(), ...rightStates.keys()])] + .sort((a, b) => a.localeCompare(b)); + for (const stateId of stateIds) { + const beforeState = leftStates.get(stateId); + const afterState = rightStates.get(stateId); + if (!beforeState && afterState) { + entries.push({ path: statePath(left.id, afterState.id), kind: "added", after: afterState }); + continue; + } + if (beforeState && !afterState) { + entries.push({ path: statePath(left.id, beforeState.id), kind: "removed", before: beforeState }); + continue; + } + if (!beforeState || !afterState) { + continue; + } + if ((beforeState.kind ?? null) !== (afterState.kind ?? null)) { + entries.push({ + path: statePath(left.id, stateId, "kind"), + kind: "modified", + before: beforeState.kind ?? null, + after: afterState.kind ?? null, + }); + } + if ((beforeState.description ?? null) !== (afterState.description ?? null)) { + entries.push({ + path: statePath(left.id, stateId, "description"), + kind: "modified", + before: beforeState.description ?? null, + after: afterState.description ?? null, + }); + } + } + + return entries; +} + +function parseBracketSelector( + value: string, + collection: string, +): { key: string; suffix: string } | null { + const match = value.match(new RegExp(`^${collection}\\[([^\\]]+)\\](?:\\.(.+))?$`)); + if (!match?.[1]) { + return null; + } + return { + key: match[1], + suffix: match[2] ?? "", + }; +} + +function resolveSurface(ast: UiSurfaceAst, surfaceId: string): UiAstSurface { + const surface = ast.surfaces.find((candidate) => candidate.id === surfaceId); + if (!surface) { + throw new Error(`Unknown AST surface "${surfaceId}".`); + } + return surface; +} + +function resolvePlatform(surface: UiAstSurface, platform: string): UiAstPlatformProjection { + const projection = surface.platforms.find((candidate) => candidate.platform === platform); + if (!projection) { + throw new Error(`Unknown AST platform "${platform}" for surface "${surface.id}".`); + } + return projection; +} + +function resolveNode(surface: UiAstSurface, nodeId: string): UiAstNode { + const node = surface.nodes.find((candidate) => candidate.id === nodeId); + if (!node) { + throw new Error(`Unknown AST node "${nodeId}" for surface "${surface.id}".`); + } + return node; +} + +function resolveState(surface: UiAstSurface, stateId: string): UiAstStateRef { + const state = surface.states?.find((candidate) => candidate.id === stateId); + if (!state) { + throw new Error(`Unknown AST state "${stateId}" for surface "${surface.id}".`); + } + return state; +} + +function addUniqueValue(target: string[] | undefined, value: string): string[] { + return uniqueSortedStrings([...(target ?? []), value]) ?? []; +} + +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), + }, + }; +} + +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 function normalizeUiAst(ast: UiSurfaceAst): UiSurfaceAst { + const cloned = cloneUiAst(ast); + return { + ...cloned, + color: { + ...cloned.color, + allowedValues: uniqueSortedStrings(cloned.color.allowedValues) ?? [], + }, + ...(cloned.surfaces + ? { + surfaces: [...cloned.surfaces] + .map((surface) => ({ + ...surface, + nodes: sortNodes(surface.nodes), + platforms: sortPlatforms(surface.platforms), + ...(surface.states ? { states: sortStates(surface.states) } : {}), + ...(surface.governance?.roles + ? { + governance: { + ...surface.governance, + roles: { + ...(surface.governance.roles.designers + ? { + designers: uniqueSortedStrings(surface.governance.roles.designers), + } + : {}), + ...(surface.governance.roles.engineers + ? { + engineers: uniqueSortedStrings(surface.governance.roles.engineers), + } + : {}), + }, + }, + } + : {}), + ...(surface.runtime?.mutationEnvelope?.allowedSections + && surface.runtime?.mutationEnvelope?.mode + ? { + runtime: { + ...surface.runtime, + mutationEnvelope: { + ...surface.runtime.mutationEnvelope, + mode: surface.runtime.mutationEnvelope.mode, + allowedSections: uniqueSortedStrings(surface.runtime.mutationEnvelope.allowedSections), + }, + }, + } + : {}), + })) + .sort((left, right) => left.id.localeCompare(right.id)), + } + : {}), + ...(cloned.migration + ? { + migration: { + ...cloned.migration, + escalations: sortEscalations(cloned.migration.escalations) ?? [], + }, + } + : {}), + }; +} + +export function summarizeUiAst(ast: UiSurfaceAst): UiAstSummary { + const normalized = normalizeUiAst(ast); + const surfaces = normalized.surfaces.map((surface) => { + const nodeKinds = surface.nodes.reduce>((counts, node) => { + counts[node.kind] = (counts[node.kind] ?? 0) + 1; + return counts; + }, {}); + const maxContentWidthByPlatform = Object.fromEntries( + surface.platforms + .filter((platform) => typeof platform.layout?.maxContentWidth === "number") + .map((platform) => [platform.platform, platform.layout!.maxContentWidth]), + ) as Partial>; + return { + surfaceId: surface.id, + displayName: surface.displayName, + platforms: surface.platforms.map((platform) => platform.platform), + nodeCount: surface.nodes.length, + nodeKinds, + sectionIds: traverseSectionOrder(surface).map((node) => node.sectionId ?? node.id), + actionIntents: [...new Set( + surface.nodes + .map((node) => node.actionIntent) + .filter((value): value is UiAstActionIntent => typeof value === "string"), + )].sort((left, right) => left.localeCompare(right)), + stateIds: (surface.states ?? []).map((state) => state.id), + owner: surface.owner ?? null, + governanceStatus: surface.governance?.status ?? null, + runtimePolicy: surface.runtime?.policy ?? null, + maxContentWidthByPlatform, + } satisfies UiAstSurfaceSummary; + }); + + return { + astId: normalized.astId, + version: normalized.version, + surfaceCount: normalized.surfaces.length, + platformCount: surfaces.reduce((count, surface) => count + surface.platforms.length, 0), + nodeCount: surfaces.reduce((count, surface) => count + surface.nodeCount, 0), + migrationEscalationCount: normalized.migration?.escalations.length ?? 0, + surfaces, + }; +} + +export function diffUiAst(before: UiSurfaceAst, after: UiSurfaceAst): UiAstDiffEntry[] { + const left = normalizeUiAst(before); + const right = normalizeUiAst(after); + const entries: UiAstDiffEntry[] = []; + + if (left.astId !== right.astId) { + entries.push({ path: "astId", kind: "modified", before: left.astId, after: right.astId }); + } + if (left.version !== right.version) { + entries.push({ path: "version", kind: "modified", before: left.version, after: right.version }); + } + if ((left.description ?? null) !== (right.description ?? null)) { + entries.push({ + path: "description", + kind: "modified", + before: left.description ?? null, + after: right.description ?? null, + }); + } + if ((left.color.policy ?? null) !== (right.color.policy ?? null)) { + entries.push({ + path: "color.policy", + kind: "modified", + before: left.color.policy ?? null, + after: right.color.policy ?? null, + }); + } + entries.push(...diffScalarArray(left.color.allowedValues, right.color.allowedValues, "color.allowedValues")); + + const leftSurfaces = new Map(left.surfaces.map((surface) => [surface.id, surface])); + const rightSurfaces = new Map(right.surfaces.map((surface) => [surface.id, surface])); + const surfaceIds = [...new Set([...leftSurfaces.keys(), ...rightSurfaces.keys()])] + .sort((a, b) => a.localeCompare(b)); + for (const surfaceId of surfaceIds) { + entries.push(...diffSurface(leftSurfaces.get(surfaceId), rightSurfaces.get(surfaceId))); + } + + entries.push(...diffUnknown(left.constraints, right.constraints, "constraints")); + entries.push(...diffUnknown(left.tokens, right.tokens, "tokens")); + entries.push(...diffUnknown(left.shell, right.shell, "shell")); + entries.push(...diffUnknown(left.migration, right.migration, "migration")); + + return entries.sort((leftEntry, rightEntry) => { + const pathComparison = leftEntry.path.localeCompare(rightEntry.path); + if (pathComparison !== 0) { + return pathComparison; + } + return leftEntry.kind.localeCompare(rightEntry.kind); + }); +} + +export function applyUiAstChange(ast: UiSurfaceAst, change: UiAstChange): UiSurfaceAst { + const next = normalizeUiAst(cloneUiAst(ast)); + + if (change.path === "color.policy") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + next.color.policy = change.value as typeof next.color.policy; + return next; + } + + if (change.path === "color.allowedValues") { + if (change.action !== "add" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + next.color.allowedValues = addUniqueValue(next.color.allowedValues, change.value); + return next; + } + + const surfaceMatch = parseBracketSelector(change.path, "surfaces"); + if (!surfaceMatch) { + throw new Error(`Unsupported AST change path "${change.path}".`); + } + + const surface = resolveSurface(next, surfaceMatch.key); + if (surfaceMatch.suffix === "owner") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.owner = change.value; + return next; + } + if (surfaceMatch.suffix === "governance.status") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.governance = { + ...surface.governance, + status: change.value as NonNullable["status"], + }; + return next; + } + if (surfaceMatch.suffix === "governance.roles.designers") { + if (change.action !== "add" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.governance = { + ...surface.governance, + roles: { + ...surface.governance?.roles, + designers: addUniqueValue(surface.governance?.roles?.designers, change.value), + }, + }; + return next; + } + if (surfaceMatch.suffix === "governance.roles.engineers") { + if (change.action !== "add" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.governance = { + ...surface.governance, + roles: { + ...surface.governance?.roles, + engineers: addUniqueValue(surface.governance?.roles?.engineers, change.value), + }, + }; + return next; + } + if (surfaceMatch.suffix === "runtime.policy") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.runtime = { + ...surface.runtime, + policy: change.value as NonNullable["policy"], + }; + return next; + } + if (surfaceMatch.suffix === "runtime.mutationEnvelope.mode") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + surface.runtime = { + ...surface.runtime, + mutationEnvelope: { + ...surface.runtime?.mutationEnvelope, + mode: change.value as NonNullable["mutationEnvelope"]>["mode"], + }, + }; + return next; + } + if (surfaceMatch.suffix === "runtime.mutationEnvelope.allowedSections") { + if (change.action !== "add" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + const existingMode = surface.runtime?.mutationEnvelope?.mode; + if (!existingMode) { + throw new Error(`AST mutation envelope mode must exist before adding allowed sections for ${change.path}.`); + } + surface.runtime = { + ...surface.runtime, + mutationEnvelope: { + ...surface.runtime?.mutationEnvelope, + mode: existingMode, + allowedSections: addUniqueValue(surface.runtime?.mutationEnvelope?.allowedSections, change.value), + }, + }; + return next; + } + + const platformMatch = parseBracketSelector(surfaceMatch.suffix, "platforms"); + if (platformMatch) { + const platform = resolvePlatform(surface, platformMatch.key); + if (platformMatch.suffix === "allowedFonts") { + if (change.action !== "add" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + platform.allowedFonts = addUniqueValue(platform.allowedFonts, change.value); + return next; + } + if (platformMatch.suffix === "layout.maxContentWidth") { + if (change.action !== "set" || typeof change.value !== "number") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + platform.layout = { + ...platform.layout, + maxContentWidth: change.value, + }; + return next; + } + throw new Error(`Unsupported AST platform change path "${change.path}".`); + } + + const nodeMatch = parseBracketSelector(surfaceMatch.suffix, "nodes"); + if (nodeMatch) { + const node = resolveNode(surface, nodeMatch.key); + if (nodeMatch.suffix === "label") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + node.label = change.value; + return next; + } + if (nodeMatch.suffix === "description") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + node.description = change.value; + return next; + } + if (nodeMatch.suffix === "actionIntent") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + node.actionIntent = change.value as UiAstActionIntent; + return next; + } + throw new Error(`Unsupported AST node change path "${change.path}".`); + } + + const stateMatch = parseBracketSelector(surfaceMatch.suffix, "states"); + if (stateMatch) { + const state = resolveState(surface, stateMatch.key); + if (stateMatch.suffix === "description") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + state.description = change.value; + return next; + } + if (stateMatch.suffix === "kind") { + if (change.action !== "set" || typeof change.value !== "string") { + throw new Error(`Unsupported AST change for ${change.path}.`); + } + state.kind = change.value as UiAstStateRef["kind"]; + return next; + } + throw new Error(`Unsupported AST state change path "${change.path}".`); + } + + throw new Error(`Unsupported AST change path "${change.path}".`); +} diff --git a/packages/interfacectl-validator/test/ui-ast-authoring.test.mjs b/packages/interfacectl-validator/test/ui-ast-authoring.test.mjs new file mode 100644 index 0000000..ae063fc --- /dev/null +++ b/packages/interfacectl-validator/test/ui-ast-authoring.test.mjs @@ -0,0 +1,269 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + applyUiAstChange, + deriveLegacyContractFromUiAst, + diffUiAst, + migrateLegacyContractToUiAst, + normalizeUiAst, + summarizeUiAst, +} from "../dist/index.js"; + +function buildAst() { + return { + astId: "authoring-demo", + version: "1.0.0", + description: "Deterministic AST authoring fixture.", + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: ["#ffffff", "#000000"], + }, + surfaces: [ + { + id: "alpha-surface", + displayName: "Alpha Surface", + kind: "application", + rootNodeId: "alpha.root", + owner: "@alpha-owner", + governance: { + status: "draft", + roles: { + designers: ["@design-a"], + engineers: ["@engineer-a"], + }, + }, + runtime: { + policy: "warn", + mutationEnvelope: { + mode: "layout-tuning", + allowedSections: ["alpha.hero"], + }, + }, + nodes: [ + { + id: "alpha.root", + kind: "group", + children: ["alpha.hero", "alpha.submit"], + }, + { + id: "alpha.submit", + kind: "action", + label: "Continue", + actionIntent: "continue", + }, + { + id: "alpha.hero", + kind: "section", + sectionId: "alpha.hero", + label: "Hero", + }, + ], + platforms: [ + { + platform: "ios", + layout: { + maxContentWidth: 600, + }, + }, + { + platform: "web", + allowedFonts: ["Inter", "sans-serif"], + layout: { + maxContentWidth: 1120, + }, + }, + ], + states: [ + { + id: "error", + kind: "error", + description: "Error state.", + }, + ], + }, + { + id: "beta-surface", + displayName: "Beta Surface", + kind: "application", + rootNodeId: "beta.root", + nodes: [ + { + id: "beta.root", + kind: "group", + children: ["beta.details"], + }, + { + id: "beta.details", + kind: "detail", + label: "Details", + }, + ], + platforms: [ + { + platform: "android", + layout: { + maxContentWidth: 720, + }, + }, + ], + }, + ], + migration: { + sourceFormat: "web.surface.contract@1", + escalations: [ + { + surfaceId: "alpha-surface", + code: "demo.escalation", + message: "Example escalation.", + }, + ], + }, + }; +} + +test("normalizeUiAst sorts surfaces, platforms, and scalar arrays deterministically", () => { + const normalized = normalizeUiAst(buildAst()); + assert.deepEqual(normalized.color.allowedValues, ["#000000", "#ffffff"]); + assert.deepEqual(normalized.surfaces.map((surface) => surface.id), ["alpha-surface", "beta-surface"]); + assert.deepEqual( + normalized.surfaces[0].platforms.map((platform) => platform.platform), + ["ios", "web"], + ); +}); + +test("summarizeUiAst reports platform-neutral AST structure", () => { + const summary = summarizeUiAst(buildAst()); + assert.equal(summary.surfaceCount, 2); + assert.equal(summary.platformCount, 3); + assert.equal(summary.nodeCount, 5); + assert.deepEqual(summary.surfaces[0].platforms, ["ios", "web"]); + assert.deepEqual(summary.surfaces[0].actionIntents, ["continue"]); + assert.deepEqual(summary.surfaces[0].stateIds, ["error"]); + assert.equal(summary.surfaces[0].maxContentWidthByPlatform.web, 1120); + assert.equal(summary.surfaces[0].maxContentWidthByPlatform.ios, 600); +}); + +test("applyUiAstChange updates governance, platforms, nodes, and states via AST paths", () => { + let ast = buildAst(); + ast = applyUiAstChange(ast, { + path: "surfaces[alpha-surface].platforms[web].layout.maxContentWidth", + action: "set", + value: 1280, + summary: "Increase max width.", + }); + ast = applyUiAstChange(ast, { + path: "surfaces[alpha-surface].platforms[web].allowedFonts", + action: "add", + value: "var(--font-brand)", + summary: "Add brand font.", + }); + ast = applyUiAstChange(ast, { + path: "surfaces[alpha-surface].nodes[alpha.submit].actionIntent", + action: "set", + value: "submit", + summary: "Change action intent.", + }); + ast = applyUiAstChange(ast, { + path: "surfaces[alpha-surface].states[error].description", + action: "set", + value: "Updated error state.", + summary: "Refresh error state text.", + }); + ast = applyUiAstChange(ast, { + path: "surfaces[alpha-surface].governance.roles.engineers", + action: "add", + value: "@engineer-b", + summary: "Add second engineer reviewer.", + }); + + const summary = summarizeUiAst(ast); + assert.equal(summary.surfaces[0].maxContentWidthByPlatform.web, 1280); + assert.deepEqual( + ast.surfaces[0].platforms.find((platform) => platform.platform === "web")?.allowedFonts, + ["Inter", "sans-serif", "var(--font-brand)"], + ); + assert.equal( + ast.surfaces[0].nodes.find((node) => node.id === "alpha.submit")?.actionIntent, + "submit", + ); + assert.equal( + ast.surfaces[0].states.find((state) => state.id === "error")?.description, + "Updated error state.", + ); + assert.deepEqual(ast.surfaces[0].governance.roles.engineers, ["@engineer-a", "@engineer-b"]); +}); + +test("diffUiAst emits deterministic AST-path diffs for consumer-neutral changes", () => { + const before = buildAst(); + const after = applyUiAstChange( + applyUiAstChange(before, { + path: "surfaces[alpha-surface].owner", + action: "set", + value: "@new-owner", + summary: "Change owner.", + }), + { + path: "surfaces[alpha-surface].platforms[web].allowedFonts", + action: "add", + value: "var(--font-brand)", + summary: "Add brand font.", + }, + ); + const diff = diffUiAst(before, after); + assert.deepEqual( + diff.map((entry) => entry.path), + [ + "surfaces[alpha-surface].owner", + "surfaces[alpha-surface].platforms[web].allowedFonts[var(--font-brand)]", + ], + ); +}); + +test("migrateLegacyContractToUiAst and deriveLegacyContractFromUiAst preserve bundle compatibility shape", () => { + const contract = { + contractId: "legacy-demo", + version: "1.0.0", + description: "Legacy contract fixture.", + sections: [ + { + id: "hero.main", + intent: "hero", + description: "Hero section.", + }, + ], + surfaces: [ + { + id: "legacy-web", + displayName: "Legacy Web", + type: "web", + requiredSections: ["hero.main"], + allowedFonts: ["Inter", "sans-serif"], + layout: { + maxContentWidth: 960, + }, + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: ["#ffffff"], + }, + }; + const ast = migrateLegacyContractToUiAst(contract); + const roundTripped = deriveLegacyContractFromUiAst(ast); + assert.equal(ast.surfaces[0].platforms[0].platform, "web"); + assert.equal(roundTripped.surfaces[0].requiredSections[0], "hero.main"); + assert.equal(roundTripped.surfaces[0].layout.maxContentWidth, 960); + assert.equal(roundTripped.color.allowedValues[0], "#ffffff"); +}); From 7fab67f2bacc4d18e7f86ce7716508c4e9e2c416 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sat, 28 Mar 2026 23:05:27 -0700 Subject: [PATCH 3/3] feat(cli): make init bootstrap UI AST-first --- .../interfacectl-cli/dist/commands/init.d.ts | 4 + .../dist/commands/init.d.ts.map | 2 +- .../interfacectl-cli/dist/commands/init.js | 99 +++++++++--- packages/interfacectl-cli/dist/index.js | 11 +- .../interfacectl-cli/src/commands/init.ts | 147 +++++++++++++++--- packages/interfacectl-cli/src/index.ts | 11 +- .../interfacectl-cli/test/init-auth.test.mjs | 79 +++++++++- 7 files changed, 305 insertions(+), 48 deletions(-) diff --git a/packages/interfacectl-cli/dist/commands/init.d.ts b/packages/interfacectl-cli/dist/commands/init.d.ts index a6f8c2d..1b89e69 100644 --- a/packages/interfacectl-cli/dist/commands/init.d.ts +++ b/packages/interfacectl-cli/dist/commands/init.d.ts @@ -1,13 +1,17 @@ import { type InteractiveInitOptions } from "../utils/init-interactive.js"; export interface InitOptions extends InteractiveInitOptions { nonInteractive?: boolean; + json?: boolean; verbose?: boolean; continueOnGate?: boolean; outDir?: string; analysisOut?: string; draftOut?: string; + astOut?: string; contractOut?: string; reportOut?: string; + bundleOutDir?: string; + toolVersion?: string; } export declare function runInitCommand(options: InitOptions): Promise; //# sourceMappingURL=init.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/init.d.ts.map b/packages/interfacectl-cli/dist/commands/init.d.ts.map index a11d002..2a369c4 100644 --- a/packages/interfacectl-cli/dist/commands/init.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/init.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AA4BA,OAAO,EASL,KAAK,sBAAsB,EAE5B,MAAM,8BAA8B,CAAC;AAgCtC,MAAM,WAAW,WAAY,SAAQ,sBAAsB;IACzD,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAsiBD,wBAAsB,cAAc,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CA8O1E"} \ No newline at end of file +{"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAmCA,OAAO,EASL,KAAK,sBAAsB,EAE5B,MAAM,8BAA8B,CAAC;AAiCtC,MAAM,WAAW,WAAY,SAAQ,sBAAsB;IACzD,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA6lBD,wBAAsB,cAAc,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAoR1E"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/init.js b/packages/interfacectl-cli/dist/commands/init.js index f149849..09fcbc6 100644 --- a/packages/interfacectl-cli/dist/commands/init.js +++ b/packages/interfacectl-cli/dist/commands/init.js @@ -2,7 +2,8 @@ import { existsSync } from "node:fs"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { getBundledContractSchema, validateContractStructure, } from "@surfaces/interfacectl-validator"; +import { getBundledContractSchema, getBundledUiAstSchema, deriveLegacyContractFromUiAst, migrateLegacyContractToUiAst, normalizeUiAst, validateContractStructure, validateUiAstStructure, } from "@surfaces/interfacectl-validator"; +import { runCompileCommand } from "./compile.js"; import { runValidateCommand } from "./validate.js"; import { runValidateExtractedCommand } from "./validate-extracted.js"; import { getAuthStorageMode, inspectAuthProfile, saveReplayAuthProfile, } from "../utils/auth-profiles.js"; @@ -10,6 +11,7 @@ import { captureBrowserStorageState, observeRemotePage } from "../utils/browser- import { analyzeSurface, stringifyStableArtifact, } from "../utils/first-run-analysis.js"; import { emitOnboardingRunArtifact, normalizeRemoteUrlInput, suggestSurfaceIdFromPath, suggestSurfaceIdFromUrl, suggestSurfaceName, } from "../utils/onboarding.js"; import { inferSourceMode, normalizeSurfaceId, promptGateResolution, promptInteractiveInitInputs, promptSurfaceKindConfirmation, promptWriteConfirmation, } from "../utils/init-interactive.js"; +import { normalizeContract } from "../utils/normalize.js"; import { redactSensitiveText } from "../utils/redaction.js"; const DEFAULT_OUT_DIR = "contracts/generated"; async function maybeCaptureAuthProfile(inputValue) { @@ -92,8 +94,12 @@ function resolveArtifactPaths(rootDir, surfaceId, options) { outDir, analysisPath: resolvePath(options.analysisOut, `${surfaceId}.analysis.json`), draftPath: resolvePath(options.draftOut, `${surfaceId}.design-system.draft.json`), + astPath: resolvePath(options.astOut, `${surfaceId}.ui.surface.ast.json`), contractPath: resolvePath(options.contractOut, `${surfaceId}.contract.json`), reportPath: resolvePath(options.reportOut, `${surfaceId}.extraction.json`), + bundleRoot: options.bundleOutDir + ? path.resolve(rootDir, options.bundleOutDir) + : path.join(outDir, `${surfaceId}.bundle`), }; } async function writeArtifact(filePath, payload) { @@ -215,7 +221,10 @@ function rewritePreviewMessage(message, verbose = false) { } return message; } -function logStage(step, total, message) { +function logStage(step, total, message, enabled = true) { + if (!enabled) { + return; + } console.log(`[${step}/${total}] ${message}`); } function hasBlockingValidationError(validateResult, validateExtractedResult) { @@ -261,7 +270,7 @@ async function validateTempArtifacts(input) { const validateExtractedPath = path.join(input.tempDir, "validate-extracted.json"); await writeArtifact(analysisPath, input.analysisResult.analysis); await writeArtifact(draftPath, input.analysisResult.draft); - await writeArtifact(contractPath, input.analysisResult.contract); + await writeArtifact(contractPath, input.compatibilityContract); await writeArtifact(reportPath, input.analysisResult.extractionReport); const validateExitCode = await runValidateCommand({ contractPath, @@ -351,12 +360,12 @@ function printWriteSummary(input) { const { rootDir, resolved, artifacts, provisional, verbose, runId, storageMode, authProfileName, } = input; console.log(""); console.log("Created"); - console.log(` - Created a first contract and draft design system for ${resolved.surfaceName}.`); + console.log(` - Created a first UI AST bootstrap for ${resolved.surfaceName}.`); if (provisional) { console.log(" - Results are marked provisional because the source view was limited."); } console.log("Next"); - console.log(" - Review the generated draft design system and contract."); + console.log(" - Review the generated UI AST, derived compatibility contract, and compiled bundle."); if (resolved.sourceMode === "local-root") { console.log(" - Connect the local app root in interfacectl.config.json for stronger repeatable validation."); } @@ -365,16 +374,17 @@ function printWriteSummary(input) { } if (verbose) { console.log(` - interfacectl validate-extracted --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --extracted ${relativeDisplay(rootDir, artifacts.reportPath)} --surface ${resolved.surfaceId}`); - if (resolved.sourceMode === "local-root") { - console.log(` - interfacectl validate --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --surface ${resolved.surfaceId}`); - } + console.log(` - interfacectl validate --ast ${relativeDisplay(rootDir, artifacts.astPath)} --surface ${resolved.surfaceId}`); + console.log(` - interfacectl compile --ast ${relativeDisplay(rootDir, artifacts.astPath)} --out-dir ${relativeDisplay(rootDir, artifacts.bundleRoot)}`); } console.log("Artifacts"); const displayPath = (filePath) => verbose ? filePath : relativeDisplay(rootDir, filePath); console.log(` - analysis: ${displayPath(artifacts.analysisPath)}`); console.log(` - draft: ${displayPath(artifacts.draftPath)}`); - console.log(` - contract: ${displayPath(artifacts.contractPath)}`); + console.log(` - ast: ${displayPath(artifacts.astPath)}`); + console.log(` - compatibility contract: ${displayPath(artifacts.contractPath)}`); console.log(` - report: ${displayPath(artifacts.reportPath)}`); + console.log(` - bundle: ${displayPath(artifacts.bundleRoot)}`); if (verbose) { console.log("Technical details"); console.log(` - Run id: ${runId}`); @@ -387,6 +397,24 @@ function printWriteSummary(input) { } } } +function buildInitJsonSummary(input) { + return { + state: "completed", + surfaceId: input.resolved.surfaceId, + surfaceName: input.resolved.surfaceName, + status: input.status, + runId: input.runId, + recommendedNextStep: "review-ui-ast", + artifacts: { + analysisPath: relativeDisplay(input.rootDir, input.artifacts.analysisPath), + draftPath: relativeDisplay(input.rootDir, input.artifacts.draftPath), + astPath: relativeDisplay(input.rootDir, input.artifacts.astPath), + compatibilityContractPath: relativeDisplay(input.rootDir, input.artifacts.contractPath), + extractionReportPath: relativeDisplay(input.rootDir, input.artifacts.reportPath), + bundleRoot: relativeDisplay(input.rootDir, input.artifacts.bundleRoot), + }, + }; +} function gateFailureMessage(analysis) { if (analysis.sourceHealth.status === "access-denied") { return "Remote onboarding stopped because we reached an access-denied page instead of the target surface. Capture auth, switch to --app-root, or pass --continue-on-gate for provisional output."; @@ -408,6 +436,10 @@ export async function runInitCommand(options) { const rootDir = process.cwd(); const storageMode = getAuthStorageMode(); try { + if (options.json === true && options.nonInteractive !== true) { + console.error("--json requires --non-interactive."); + return 1; + } let resolved = await resolveInputs(options); let pendingAuthCapture; while (true) { @@ -415,7 +447,7 @@ export async function runInitCommand(options) { if (localRootValidation !== null) { return localRootValidation; } - logStage(1, 6, "Discovering source"); + logStage(1, 6, "Discovering source", options.json !== true); const authCapture = pendingAuthCapture ?? (resolved.sourceMode === "remote-url" && resolved.url ? await maybeCaptureAuthProfile({ @@ -426,7 +458,7 @@ export async function runInitCommand(options) { }) : { authMode: "none", storageState: undefined }); pendingAuthCapture = undefined; - logStage(2, 6, "Checking access"); + logStage(2, 6, "Checking access", options.json !== true); const remoteObservation = resolved.sourceMode === "remote-url" && resolved.url ? await observeRemotePage({ url: resolved.url, @@ -442,7 +474,7 @@ export async function runInitCommand(options) { : `Authenticated replay still resolved to a login page at ${remoteObservation.sourceHealth.finalUrl}. Re-capture the auth profile and retry.`); return 1; } - logStage(3, 6, "Analyzing surface kind and UI system"); + logStage(3, 6, "Analyzing surface kind and UI system", options.json !== true); let analysisResult = await analyzeSurface({ workspaceRoot: rootDir, surfaceId: resolved.surfaceId, @@ -528,7 +560,17 @@ export async function runInitCommand(options) { } return 1; } - logStage(4, 6, "Validating generated outputs"); + const astDraft = normalizeUiAst(migrateLegacyContractToUiAst(analysisResult.contract)); + const astStructure = validateUiAstStructure(astDraft, getBundledUiAstSchema()); + if (!astStructure.ok || !astStructure.ast) { + console.error("Generated UI AST failed schema validation:"); + for (const issue of astStructure.errors) { + console.error(` ${issue}`); + } + return 1; + } + const compatibilityContract = normalizeContract(deriveLegacyContractFromUiAst(astStructure.ast)).contract; + logStage(4, 6, "Validating generated outputs", options.json !== true); const tempDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-preview-")); try { const validation = await validateTempArtifacts({ @@ -536,6 +578,7 @@ export async function runInitCommand(options) { rootDir, surfaceId: resolved.surfaceId, analysisResult, + compatibilityContract, }); const blockingValidationError = hasBlockingValidationError(validation.validateResult, validation.validateExtractedResult); if (blockingValidationError) { @@ -545,7 +588,7 @@ export async function runInitCommand(options) { } return 1; } - logStage(5, 6, "Previewing generated draft"); + logStage(5, 6, "Previewing generated draft", options.json !== true); if (!options.nonInteractive) { printPreviewSummary({ analysis: analysisResult.analysis, @@ -560,12 +603,20 @@ export async function runInitCommand(options) { return 0; } } - logStage(6, 6, "Writing onboarding artifacts"); + logStage(6, 6, "Writing onboarding artifacts", options.json !== true); const artifacts = resolveArtifactPaths(rootDir, resolved.surfaceId, options); await writeArtifact(artifacts.analysisPath, analysisResult.analysis); await writeArtifact(artifacts.draftPath, analysisResult.draft); - await writeArtifact(artifacts.contractPath, analysisResult.contract); + await writeArtifact(artifacts.astPath, astStructure.ast); + await writeArtifact(artifacts.contractPath, compatibilityContract); await writeArtifact(artifacts.reportPath, analysisResult.extractionReport); + const compileExitCode = await runCompileCommand({ + astPath: artifacts.astPath, + outDir: artifacts.bundleRoot, + }, options.toolVersion ?? "0.0.0"); + if (compileExitCode !== 0) { + return compileExitCode; + } const findingCodes = collectFindingCodes(analysisResult.analysis, validation.validateResult, validation.validateExtractedResult); const status = findingCodes.length > 0 ? "warn" @@ -573,12 +624,22 @@ export async function runInitCommand(options) { const run = await emitOnboardingRunArtifact({ rootDir, surfaceId: resolved.surfaceId, - source: "generation", + source: "bootstrap", status, findingCodes, extractionPath: artifacts.contractPath, reportPath: artifacts.reportPath, }); + if (options.json) { + console.log(JSON.stringify(buildInitJsonSummary({ + rootDir, + resolved, + artifacts, + runId: run.runId, + status, + }), null, 2)); + return 0; + } printWriteSummary({ rootDir, resolved, @@ -589,9 +650,7 @@ export async function runInitCommand(options) { storageMode, authProfileName: authCapture.profileName, }); - return validation.validateExitCode === 10 || validation.validateExtractedExitCode === 10 - ? 1 - : 0; + return 0; } finally { await rm(tempDir, { recursive: true, force: true }); diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index 36cd96a..7de1261 100755 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -533,7 +533,7 @@ program }); program .command("init") - .description("Interactive onboarding for first-surface extraction") + .description("Interactive onboarding for the first governed-surface UI AST bootstrap") .option("--url ", "Surface URL for onboarding") .option("--surface ", "Surface identifier override") .option("--surface-name ", "Surface display name override") @@ -542,13 +542,16 @@ program .option("--app-root ", "Local app root (required for local-root)") .option("--auth-profile ", "Replay or capture an auth profile for browser-session onboarding") .option("--non-interactive", "Run without prompts") + .option("--json", "Emit a machine-readable bootstrap summary") .option("--verbose", "Show technical onboarding detail") .option("--continue-on-gate", "Allow provisional output when remote onboarding resolves to a login or access-denied page") .option("--out-dir ", "Output directory for generated onboarding artifacts") .option("--analysis-out ", "Explicit output path for the analysis artifact") .option("--draft-out ", "Explicit output path for the design-system draft artifact") - .option("--contract-out ", "Explicit output path for the generated contract") + .option("--ast-out ", "Explicit output path for the canonical UI AST") + .option("--contract-out ", "Explicit output path for the derived compatibility contract") .option("--report-out ", "Explicit output path for the extraction report") + .option("--bundle-out-dir ", "Explicit output directory for the compiled bundle") .action(async (options) => { const extractMode = options.extractMode === "local-root" ? "local-root" @@ -564,13 +567,17 @@ program appRoot: options.appRoot, authProfile: options.authProfile, nonInteractive: options.nonInteractive === true, + json: options.json === true, verbose: options.verbose === true, continueOnGate: options.continueOnGate === true, outDir: options.outDir, analysisOut: options.analysisOut, draftOut: options.draftOut, + astOut: options.astOut, contractOut: options.contractOut, reportOut: options.reportOut, + bundleOutDir: options.bundleOutDir, + toolVersion: pkg.version ?? "0.0.0", }); process.exitCode = exitCode; }); diff --git a/packages/interfacectl-cli/src/commands/init.ts b/packages/interfacectl-cli/src/commands/init.ts index 100be23..f1d9f89 100644 --- a/packages/interfacectl-cli/src/commands/init.ts +++ b/packages/interfacectl-cli/src/commands/init.ts @@ -4,8 +4,15 @@ import os from "node:os"; import path from "node:path"; import { getBundledContractSchema, + getBundledUiAstSchema, + deriveLegacyContractFromUiAst, + migrateLegacyContractToUiAst, + normalizeUiAst, + type InterfaceContract, validateContractStructure, + validateUiAstStructure, } from "@surfaces/interfacectl-validator"; +import { runCompileCommand } from "./compile.js"; import { runValidateCommand } from "./validate.js"; import { runValidateExtractedCommand } from "./validate-extracted.js"; import { @@ -38,6 +45,7 @@ import { type InteractiveInitOptions, type ResolvedInitInputs, } from "../utils/init-interactive.js"; +import { normalizeContract } from "../utils/normalize.js"; import { redactSensitiveText } from "../utils/redaction.js"; interface ValidateJsonFinding { @@ -71,13 +79,34 @@ interface ValidateExtractedJsonResult { export interface InitOptions extends InteractiveInitOptions { nonInteractive?: boolean; + json?: boolean; verbose?: boolean; continueOnGate?: boolean; outDir?: string; analysisOut?: string; draftOut?: string; + astOut?: string; contractOut?: string; reportOut?: string; + bundleOutDir?: string; + toolVersion?: string; +} + +interface InitJsonSummary { + state: "completed"; + surfaceId: string; + surfaceName: string; + status: "pass" | "warn"; + runId: string; + recommendedNextStep: "review-ui-ast"; + artifacts: { + analysisPath: string; + draftPath: string; + astPath: string; + compatibilityContractPath: string; + extractionReportPath: string; + bundleRoot: string; + }; } interface AuthCaptureResult { @@ -188,8 +217,10 @@ function resolveArtifactPaths( outDir: string; analysisPath: string; draftPath: string; + astPath: string; contractPath: string; reportPath: string; + bundleRoot: string; } { const outDir = options.outDir ? path.resolve(rootDir, options.outDir) @@ -201,8 +232,12 @@ function resolveArtifactPaths( outDir, analysisPath: resolvePath(options.analysisOut, `${surfaceId}.analysis.json`), draftPath: resolvePath(options.draftOut, `${surfaceId}.design-system.draft.json`), + astPath: resolvePath(options.astOut, `${surfaceId}.ui.surface.ast.json`), contractPath: resolvePath(options.contractOut, `${surfaceId}.contract.json`), reportPath: resolvePath(options.reportOut, `${surfaceId}.extraction.json`), + bundleRoot: options.bundleOutDir + ? path.resolve(rootDir, options.bundleOutDir) + : path.join(outDir, `${surfaceId}.bundle`), }; } @@ -355,7 +390,10 @@ function rewritePreviewMessage(message: string, verbose = false): string { return message; } -function logStage(step: number, total: number, message: string): void { +function logStage(step: number, total: number, message: string, enabled = true): void { + if (!enabled) { + return; + } console.log(`[${step}/${total}] ${message}`); } @@ -412,6 +450,7 @@ async function validateTempArtifacts(input: { rootDir: string; surfaceId: string; analysisResult: Awaited>; + compatibilityContract: InterfaceContract; }): Promise<{ analysisPath: string; draftPath: string; @@ -431,7 +470,7 @@ async function validateTempArtifacts(input: { await writeArtifact(analysisPath, input.analysisResult.analysis); await writeArtifact(draftPath, input.analysisResult.draft); - await writeArtifact(contractPath, input.analysisResult.contract); + await writeArtifact(contractPath, input.compatibilityContract); await writeArtifact(reportPath, input.analysisResult.extractionReport); const validateExitCode = await runValidateCommand({ @@ -563,13 +602,13 @@ function printWriteSummary(input: { } = input; console.log(""); console.log("Created"); - console.log(` - Created a first contract and draft design system for ${resolved.surfaceName}.`); + console.log(` - Created a first UI AST bootstrap for ${resolved.surfaceName}.`); if (provisional) { console.log(" - Results are marked provisional because the source view was limited."); } console.log("Next"); - console.log(" - Review the generated draft design system and contract."); + console.log(" - Review the generated UI AST, derived compatibility contract, and compiled bundle."); if (resolved.sourceMode === "local-root") { console.log( " - Connect the local app root in interfacectl.config.json for stronger repeatable validation.", @@ -581,11 +620,12 @@ function printWriteSummary(input: { console.log( ` - interfacectl validate-extracted --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --extracted ${relativeDisplay(rootDir, artifacts.reportPath)} --surface ${resolved.surfaceId}`, ); - if (resolved.sourceMode === "local-root") { - console.log( - ` - interfacectl validate --contract ${relativeDisplay(rootDir, artifacts.contractPath)} --surface ${resolved.surfaceId}`, - ); - } + console.log( + ` - interfacectl validate --ast ${relativeDisplay(rootDir, artifacts.astPath)} --surface ${resolved.surfaceId}`, + ); + console.log( + ` - interfacectl compile --ast ${relativeDisplay(rootDir, artifacts.astPath)} --out-dir ${relativeDisplay(rootDir, artifacts.bundleRoot)}`, + ); } console.log("Artifacts"); @@ -593,8 +633,10 @@ function printWriteSummary(input: { verbose ? filePath : relativeDisplay(rootDir, filePath); console.log(` - analysis: ${displayPath(artifacts.analysisPath)}`); console.log(` - draft: ${displayPath(artifacts.draftPath)}`); - console.log(` - contract: ${displayPath(artifacts.contractPath)}`); + console.log(` - ast: ${displayPath(artifacts.astPath)}`); + console.log(` - compatibility contract: ${displayPath(artifacts.contractPath)}`); console.log(` - report: ${displayPath(artifacts.reportPath)}`); + console.log(` - bundle: ${displayPath(artifacts.bundleRoot)}`); if (verbose) { console.log("Technical details"); @@ -609,6 +651,31 @@ function printWriteSummary(input: { } } +function buildInitJsonSummary(input: { + rootDir: string; + resolved: ResolvedInitInputs; + artifacts: ReturnType; + runId: string; + status: "pass" | "warn"; +}): InitJsonSummary { + return { + state: "completed", + surfaceId: input.resolved.surfaceId, + surfaceName: input.resolved.surfaceName, + status: input.status, + runId: input.runId, + recommendedNextStep: "review-ui-ast", + artifacts: { + analysisPath: relativeDisplay(input.rootDir, input.artifacts.analysisPath), + draftPath: relativeDisplay(input.rootDir, input.artifacts.draftPath), + astPath: relativeDisplay(input.rootDir, input.artifacts.astPath), + compatibilityContractPath: relativeDisplay(input.rootDir, input.artifacts.contractPath), + extractionReportPath: relativeDisplay(input.rootDir, input.artifacts.reportPath), + bundleRoot: relativeDisplay(input.rootDir, input.artifacts.bundleRoot), + }, + }; +} + function gateFailureMessage(analysis: SurfaceAnalysisArtifact): string { if (analysis.sourceHealth.status === "access-denied") { return "Remote onboarding stopped because we reached an access-denied page instead of the target surface. Capture auth, switch to --app-root, or pass --continue-on-gate for provisional output."; @@ -633,6 +700,10 @@ export async function runInitCommand(options: InitOptions): Promise { const storageMode = getAuthStorageMode(); try { + if (options.json === true && options.nonInteractive !== true) { + console.error("--json requires --non-interactive."); + return 1; + } let resolved = await resolveInputs(options); let pendingAuthCapture: AuthCaptureResult | undefined; @@ -642,7 +713,7 @@ export async function runInitCommand(options: InitOptions): Promise { return localRootValidation; } - logStage(1, 6, "Discovering source"); + logStage(1, 6, "Discovering source", options.json !== true); const authCapture = pendingAuthCapture ?? ( @@ -657,7 +728,7 @@ export async function runInitCommand(options: InitOptions): Promise { ); pendingAuthCapture = undefined; - logStage(2, 6, "Checking access"); + logStage(2, 6, "Checking access", options.json !== true); const remoteObservation = resolved.sourceMode === "remote-url" && resolved.url ? await observeRemotePage({ @@ -679,7 +750,7 @@ export async function runInitCommand(options: InitOptions): Promise { return 1; } - logStage(3, 6, "Analyzing surface kind and UI system"); + logStage(3, 6, "Analyzing surface kind and UI system", options.json !== true); let analysisResult = await analyzeSurface({ workspaceRoot: rootDir, surfaceId: resolved.surfaceId, @@ -777,7 +848,23 @@ export async function runInitCommand(options: InitOptions): Promise { return 1; } - logStage(4, 6, "Validating generated outputs"); + const astDraft = normalizeUiAst(migrateLegacyContractToUiAst(analysisResult.contract)); + const astStructure = validateUiAstStructure( + astDraft, + getBundledUiAstSchema() as object, + ); + if (!astStructure.ok || !astStructure.ast) { + console.error("Generated UI AST failed schema validation:"); + for (const issue of astStructure.errors) { + console.error(` ${issue}`); + } + return 1; + } + const compatibilityContract = normalizeContract( + deriveLegacyContractFromUiAst(astStructure.ast), + ).contract; + + logStage(4, 6, "Validating generated outputs", options.json !== true); const tempDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-preview-")); try { const validation = await validateTempArtifacts({ @@ -785,6 +872,7 @@ export async function runInitCommand(options: InitOptions): Promise { rootDir, surfaceId: resolved.surfaceId, analysisResult, + compatibilityContract, }); const blockingValidationError = hasBlockingValidationError( validation.validateResult, @@ -802,7 +890,7 @@ export async function runInitCommand(options: InitOptions): Promise { return 1; } - logStage(5, 6, "Previewing generated draft"); + logStage(5, 6, "Previewing generated draft", options.json !== true); if (!options.nonInteractive) { printPreviewSummary({ analysis: analysisResult.analysis, @@ -818,12 +906,20 @@ export async function runInitCommand(options: InitOptions): Promise { } } - logStage(6, 6, "Writing onboarding artifacts"); + logStage(6, 6, "Writing onboarding artifacts", options.json !== true); const artifacts = resolveArtifactPaths(rootDir, resolved.surfaceId, options); await writeArtifact(artifacts.analysisPath, analysisResult.analysis); await writeArtifact(artifacts.draftPath, analysisResult.draft); - await writeArtifact(artifacts.contractPath, analysisResult.contract); + await writeArtifact(artifacts.astPath, astStructure.ast); + await writeArtifact(artifacts.contractPath, compatibilityContract); await writeArtifact(artifacts.reportPath, analysisResult.extractionReport); + const compileExitCode = await runCompileCommand({ + astPath: artifacts.astPath, + outDir: artifacts.bundleRoot, + }, options.toolVersion ?? "0.0.0"); + if (compileExitCode !== 0) { + return compileExitCode; + } const findingCodes = collectFindingCodes( analysisResult.analysis, @@ -837,13 +933,24 @@ export async function runInitCommand(options: InitOptions): Promise { const run = await emitOnboardingRunArtifact({ rootDir, surfaceId: resolved.surfaceId, - source: "generation", + source: "bootstrap", status, findingCodes, extractionPath: artifacts.contractPath, reportPath: artifacts.reportPath, }); + if (options.json) { + console.log(JSON.stringify(buildInitJsonSummary({ + rootDir, + resolved, + artifacts, + runId: run.runId, + status, + }), null, 2)); + return 0; + } + printWriteSummary({ rootDir, resolved, @@ -855,9 +962,7 @@ export async function runInitCommand(options: InitOptions): Promise { authProfileName: authCapture.profileName, }); - return validation.validateExitCode === 10 || validation.validateExtractedExitCode === 10 - ? 1 - : 0; + return 0; } finally { await rm(tempDir, { recursive: true, force: true }); } diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index bc580ef..656a794 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -676,7 +676,7 @@ program program .command("init") - .description("Interactive onboarding for first-surface extraction") + .description("Interactive onboarding for the first governed-surface UI AST bootstrap") .option("--url ", "Surface URL for onboarding") .option("--surface ", "Surface identifier override") .option("--surface-name ", "Surface display name override") @@ -685,13 +685,16 @@ program .option("--app-root ", "Local app root (required for local-root)") .option("--auth-profile ", "Replay or capture an auth profile for browser-session onboarding") .option("--non-interactive", "Run without prompts") + .option("--json", "Emit a machine-readable bootstrap summary") .option("--verbose", "Show technical onboarding detail") .option("--continue-on-gate", "Allow provisional output when remote onboarding resolves to a login or access-denied page") .option("--out-dir ", "Output directory for generated onboarding artifacts") .option("--analysis-out ", "Explicit output path for the analysis artifact") .option("--draft-out ", "Explicit output path for the design-system draft artifact") - .option("--contract-out ", "Explicit output path for the generated contract") + .option("--ast-out ", "Explicit output path for the canonical UI AST") + .option("--contract-out ", "Explicit output path for the derived compatibility contract") .option("--report-out ", "Explicit output path for the extraction report") + .option("--bundle-out-dir ", "Explicit output directory for the compiled bundle") .action(async (options) => { const extractMode = options.extractMode === "local-root" @@ -708,13 +711,17 @@ program appRoot: options.appRoot, authProfile: options.authProfile, nonInteractive: options.nonInteractive === true, + json: options.json === true, verbose: options.verbose === true, continueOnGate: options.continueOnGate === true, outDir: options.outDir, analysisOut: options.analysisOut, draftOut: options.draftOut, + astOut: options.astOut, contractOut: options.contractOut, reportOut: options.reportOut, + bundleOutDir: options.bundleOutDir, + toolVersion: pkg.version ?? "0.0.0", }); process.exitCode = exitCode; }); diff --git a/packages/interfacectl-cli/test/init-auth.test.mjs b/packages/interfacectl-cli/test/init-auth.test.mjs index 8af8078..7183099 100644 --- a/packages/interfacectl-cli/test/init-auth.test.mjs +++ b/packages/interfacectl-cli/test/init-auth.test.mjs @@ -260,12 +260,18 @@ test("init: non-interactive remote-url writes first-run artifacts and run metada const draft = JSON.parse( await readFile(path.join(generatedDir, "customer-products.design-system.draft.json"), "utf-8"), ); + const ast = JSON.parse( + await readFile(path.join(generatedDir, "customer-products.ui.surface.ast.json"), "utf-8"), + ); const contract = JSON.parse( await readFile(path.join(generatedDir, "customer-products.contract.json"), "utf-8"), ); const extraction = JSON.parse( await readFile(path.join(generatedDir, "customer-products.extraction.json"), "utf-8"), ); + const manifest = JSON.parse( + await readFile(path.join(generatedDir, "customer-products.bundle", "manifest.json"), "utf-8"), + ); const runs = JSON.parse( await readFile(path.join(generatedDir, "contract-runs.json"), "utf-8"), ); @@ -275,20 +281,89 @@ test("init: non-interactive remote-url writes first-run artifacts and run metada assert.equal(analysis.classification.confirmedKind, "marketing"); assert.equal(draft.webSurfaceKind, "marketing"); + assert.equal(ast.surfaces[0].id, "customer-products"); assert.equal(contract.surfaces[0].id, "customer-products"); assert.equal(extraction.onboarding.extractMode, "remote-url"); assert.equal(extraction.onboarding.authMode, "none"); assert.equal(analysis.sourceHealth.status, "ok"); assert.equal(extraction.sourceHealth.confidence, "full"); + assert.equal(manifest.sourceFormat, "ui-ast"); + assert.equal(manifest.bundleVersion, "3.0"); const runsValidation = validateDiffOutput(runs, contractRunsSchema); assert.equal(runsValidation.ok, true, JSON.stringify(runsValidation.errors)); const lineageValidation = validateDiffOutput(lineage, contractLineageSchema); assert.equal(lineageValidation.ok, true, JSON.stringify(lineageValidation.errors)); assert.equal(runs.schemaVersion, 2); - assert.equal(runs.runs[0].source, "generation"); + assert.equal(runs.runs[0].source, "bootstrap"); assert.equal(runs.runs[0].workspaceId, "ws-local-default"); assert.match(runs.runs[0].ingestedAt, /T/); - assert.equal(lineage.surfaces["customer-products"].lastSource, "generation"); + assert.equal(lineage.surfaces["customer-products"].lastSource, "bootstrap"); + } finally { + server.closeAllConnections?.(); + server.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("init: --json emits AST-first bootstrap metadata", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "interfacectl-init-json-")); + const server = createServer((req, res) => { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + +
+
+

Settings

+
+
+ + + `); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address === "object"); + await mkdir(path.join(cwd, "contracts"), { recursive: true }); + await writeFile( + path.join(cwd, "contracts", "surfaces.web.contract.json"), + JSON.stringify({ + contractId: "test-contract", + version: "1.0.0", + surfaces: [], + sections: [], + constraints: { + motion: { allowedDurationsMs: [120], allowedTimingFunctions: ["linear"] }, + }, + }, null, 2), + "utf-8", + ); + + const result = await run( + [ + "init", + "--non-interactive", + "--json", + "--url", + `http://127.0.0.1:${address.port}/settings`, + "--surface", + "settings-app", + "--surface-kind", + "application", + ], + { cwd, env: forceFileStorageEnv }, + ); + assert.equal(result.exitCode, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.state, "completed"); + assert.equal(payload.surfaceId, "settings-app"); + assert.equal(payload.recommendedNextStep, "review-ui-ast"); + assert.match(payload.artifacts.astPath, /settings-app\.ui\.surface\.ast\.json$/); + assert.match(payload.artifacts.compatibilityContractPath, /settings-app\.contract\.json$/); + assert.match(payload.artifacts.bundleRoot, /settings-app\.bundle$/); } finally { server.closeAllConnections?.(); server.close();