From e282e6c3774e8adf2dab634f3c514db2e79421e4 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Thu, 11 Jun 2026 09:54:28 -0400 Subject: [PATCH] feat: add dspack contract surface to cross-surface drift analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a read-only dspack contract surface so `af design drift` can compare component metadata across Figma, Storybook, and code against a declared design-system contract (a dspack v0.1/v0.2 file committed to source control). This is the first code-level integration between AF and the dspack ecosystem (dspack / dspack-export / ds-mcp). - shared: widen drift types with a DriftSurfaceId union ('figma' | 'storybook' | 'code' | 'contract'), optional contract surface/finding members, ContractComponentData, and a 'contract' SurfaceType (descriptor only). All additive — no-contract reports are shape-identical to before, regression-guarded by test. - shared: new contract.dspackPath config section (af.config.json). - watcher: new contractSurface/ module — Ajv loader mirroring ds-mcp's reference semantics with vendored v0.1/v0.2 schemas (pinned to dspack commit 7008c3e), and read-only component/prop/enum-variant accessors. Deliberately NOT a DesignAdapter and never registered in the adapter registry. - watcher: compareContractSurface() in the drift engine — presence, prop inventory vs code, and enum-variant coverage vs code and Figma. Contract declaring something a surface lacks is drift (warn); code having something the contract lacks is a staleness signal (contract-staleness:* findings, info) pointing at dspack-export regeneration. Existing comparison functions untouched. - CLI: `af design drift --dspack `, contract counts toward the 2-surface availability gate, contract-as-inventory fallback when Storybook is down, Contract status segment, one staleness remediation hint per report. - tests: 33 new (loader, mapping, analyzer, CLI e2e against the committed shadcn-demo fixture); full watcher suite 1986/1986. Out of scope by design: token drift, CI gating, reconciliation changes (Phase 14F semantics untouched), write paths, dspack generation, ds-mcp involvement. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- claude.md | 1 + docs/adapter-model.md | 45 + docs/cli-reference.md | 47 + packages/cli/src/commands/design.ts | 3 +- packages/shared/src/config.ts | 20 + packages/shared/src/configLoader.ts | 15 + packages/shared/src/crossSurfaceDrift.ts | 59 +- packages/shared/src/surfaceMetadata.ts | 5 +- packages/watcher/package.json | 1 + .../src/__fixtures__/contract/README.md | 9 + .../contract/invalid-schema.dspack.json | 15 + .../contract/invalid-version.dspack.json | 4 + .../contract/shadcn-demo.dspack.json | 285 ++++++ .../__tests__/loadContract.test.ts | 59 ++ .../contractSurface/__tests__/surface.test.ts | 99 ++ packages/watcher/src/contractSurface/index.ts | 17 + .../src/contractSurface/loadContract.ts | 83 ++ .../src/contractSurface/schema/README.md | 12 + .../schema/dspack.v0.1.schema.json | 408 ++++++++ .../schema/dspack.v0.2.schema.json | 912 ++++++++++++++++++ .../watcher/src/contractSurface/surface.ts | 147 +++ packages/watcher/src/contractSurface/types.ts | 44 + .../__tests__/cliContractE2e.test.ts | 127 +++ .../__tests__/contractDrift.test.ts | 237 +++++ .../watcher/src/crossSurfaceDrift/analyze.ts | 196 +++- .../crossSurfaceDrift/cliCrossSurfaceDrift.ts | 117 ++- .../src/crossSurfaceDrift/normalize.ts | 3 +- pnpm-lock.yaml | 3 + 29 files changed, 2954 insertions(+), 21 deletions(-) create mode 100644 packages/watcher/src/__fixtures__/contract/README.md create mode 100644 packages/watcher/src/__fixtures__/contract/invalid-schema.dspack.json create mode 100644 packages/watcher/src/__fixtures__/contract/invalid-version.dspack.json create mode 100644 packages/watcher/src/__fixtures__/contract/shadcn-demo.dspack.json create mode 100644 packages/watcher/src/contractSurface/__tests__/loadContract.test.ts create mode 100644 packages/watcher/src/contractSurface/__tests__/surface.test.ts create mode 100644 packages/watcher/src/contractSurface/index.ts create mode 100644 packages/watcher/src/contractSurface/loadContract.ts create mode 100644 packages/watcher/src/contractSurface/schema/README.md create mode 100644 packages/watcher/src/contractSurface/schema/dspack.v0.1.schema.json create mode 100644 packages/watcher/src/contractSurface/schema/dspack.v0.2.schema.json create mode 100644 packages/watcher/src/contractSurface/surface.ts create mode 100644 packages/watcher/src/contractSurface/types.ts create mode 100644 packages/watcher/src/crossSurfaceDrift/__tests__/cliContractE2e.test.ts create mode 100644 packages/watcher/src/crossSurfaceDrift/__tests__/contractDrift.test.ts diff --git a/README.md b/README.md index ab275f3..fff75f8 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ Available profiles: `designer-first`, `code-first`, `balanced`, `strict-review`. | `af design pull` | Pull design data (tokens + components + styles) | | `af design screenshot` | Capture design screenshot | | `af design component [name]` | List or inspect components | -| `af design drift [name]` | Cross-surface drift analysis (Figma vs Storybook vs code) | +| `af design drift [name]` | Cross-surface drift analysis (Figma vs Storybook vs code vs dspack contract) | ## Project Structure diff --git a/claude.md b/claude.md index 7950b7d..68045a2 100644 --- a/claude.md +++ b/claude.md @@ -44,6 +44,7 @@ This repository implements an AI-driven Code → Design synchronization system. - Storybook MCP adapter: `af design drift [component]` for cross-surface analysis - Storybook adapter requires dev server running (`pnpm dev:storybook`) - Cross-surface drift is a separate analysis pass — it does NOT modify reconciliation +- dspack contract surface (`af design drift --dspack `) is read-only; AF never generates or modifies dspack files, and the contract surface is never registered as a DesignAdapter ## Design Token Rules - Prefer semantic tokens over raw values diff --git a/docs/adapter-model.md b/docs/adapter-model.md index 9504862..12323d9 100644 --- a/docs/adapter-model.md +++ b/docs/adapter-model.md @@ -14,6 +14,7 @@ AF uses design adapters to read data from external design systems. Adapters are | **Figma REST** | HTTP (Figma API) | Tokens, components, styles | 16A | | **Figma Console MCP** | stdio / SSE / REST fallback | Screenshots, component inspection | 16B | | **Storybook MCP** | StreamableHTTP / SSE / HTTP fallback | Component metadata, stories, props | 16C | +| **dspack Contract Surface** | Local file read | Declared components, props, enum variants | — | ## Safety Model @@ -58,6 +59,50 @@ Same default-deny pattern as Figma Console MCP: The adapter validates that the Storybook instance is React-based. Non-React frameworks (Vue, Angular, Svelte) cause `isAvailable()` to return `false` with an explicit error. +## Contract Surface (dspack) + +The contract surface (`packages/watcher/src/contractSurface/`) lets +`af design drift` compare live surfaces against a **declared design-system +contract**: a [dspack](https://github.com/aestheticfunction/dspack) v0.1/v0.2 +file committed to source control. Enable it with +`af design drift --dspack ` or `contract.dspackPath` in `af.config.json`. + +### Why it is NOT a DesignAdapter + +The contract surface deliberately does not implement the `DesignAdapter` +interface and is **never registered in the design adapter registry**: + +- The drift CLI treats the registry's first available adapter as the *Figma* + surface; registering a contract loader there would let a static file be + mistaken for live design state. +- The `DesignAdapter` interface (styles, file data, screenshots) is the wrong + shape for a versioned document. + +It still carries a `SurfaceMetadata` descriptor for classification: + +| Dimension | Value | +|-----------|-------| +| Surface type | `contract` — declared contract artifact, not a live tool | +| Access mode | `read-only` | +| Authority role | `external-non-authoritative` | +| Stability | `canonical` — it *is* the declared canonical state | + +### Behavior + +- The file is Ajv-validated against the vendored dspack JSON Schemas + (`contractSurface/schema/`) at load time; invalid contracts are rejected + with instance paths, never repaired. Loader semantics mirror ds-mcp, the + dspack reference implementation. +- Comparison scope (first slice): component presence, prop inventory vs. + code, and enum-derived variant coverage vs. code and Figma. +- Direction semantics: the contract declaring something a live surface lacks + is genuine drift (warn); code having something the contract lacks is a + **staleness signal** (`contract-staleness:` findings, info) — regenerate + the snapshot with dspack-export. +- AF never generates, modifies, or writes dspack files. Token-level contract + drift, CI gating on contract findings, and any reconciliation involvement + are explicitly out of scope. + ## Figma Console MCP Adapter — Component Search `getComponent(name)` searches the entire Figma file tree recursively across all pages. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 542ac24..bc7c9c6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -347,6 +347,53 @@ af design component af design component ButtonPrimary ``` +### `af design drift [name]` + +Cross-surface drift analysis — compares component metadata across Figma, +Storybook, code (AST), and an optional dspack contract file. Read-only: it +does not modify reconciliation, and the contract file is never written. + +```bash +af design drift [component-name] [options] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--json` | boolean | `false` | Output JSON format | +| `--verbose`, `-v` | boolean | `false` | Verbose output with trace details | +| `--include-uncorroborated` | boolean | `false` | Include uncorroborated story-derived variants | +| `--dspack ` | string | — | dspack contract file to compare against (overrides `contract.dspackPath` in `af.config.json`) | + +**Contract surface:** when a dspack file is supplied (flag or config), its +declared components, props, and enum variants participate in the comparison. +Findings where the contract declares something code lacks are genuine drift +(warn). Findings where code has something the contract lacks are tagged +`contract-staleness:` (info) — the snapshot may be out of date; regenerate it +with [dspack-export](https://github.com/aestheticfunction/dspack-export). +Relative paths resolve against the current directory, then the repo root. + +**Examples:** + +```bash +# Compare a component across all available surfaces +af design drift Button + +# Compare against a dspack contract (works even with Figma/Storybook down) +af design drift Button --dspack ./my-system.dspack.json + +# Analyze every component the contract declares +af design drift --dspack ./my-system.dspack.json --json +``` + +```json +// af.config.json +{ + "contract": { + "dspackPath": "./my-system.dspack.json" + } +} +``` + --- ## pnpm Scripts diff --git a/packages/cli/src/commands/design.ts b/packages/cli/src/commands/design.ts index c1f29f8..015708f 100644 --- a/packages/cli/src/commands/design.ts +++ b/packages/cli/src/commands/design.ts @@ -38,7 +38,7 @@ Subcommands: inspect --all Inspect all design components screenshot Capture a design screenshot component [name] List or inspect design components - drift [component] Cross-surface drift analysis (Figma vs Storybook vs Code) + drift [component] Cross-surface drift analysis (Figma vs Storybook vs Code vs Contract) Options (all subcommands): --json Output JSON format @@ -54,6 +54,7 @@ Examples: af design screenshot --node 1:100 af design component Button af design drift Button + af design drift Button --dspack ./my-system.dspack.json af design drift --json --include-uncorroborated`); } diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 5058337..0877fed 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -168,6 +168,21 @@ export interface AfConfig { /** Expected framework. Adapter validates at startup and rejects non-matching. Default: 'react' */ framework?: 'react'; }; + + /** + * Contract surface configuration (dspack). + * + * Points `af design drift` at a dspack contract file so the declared + * design-system contract participates in cross-surface drift analysis. + * Read-only — AF never generates or modifies dspack files. + */ + contract?: { + /** + * Path to a dspack v0.1/v0.2 file. Relative paths are resolved against + * the current working directory, then the repo root. Default: unset. + */ + dspackPath?: string; + }; } // ============================================================================= @@ -221,6 +236,11 @@ export interface ResolvedAfConfig { framework: 'react'; }; + contract: { + /** Path to a dspack contract file, or null when no contract is configured */ + dspackPath: string | null; + }; + /** Where the config was loaded from, or null if using defaults */ _source: string | null; } diff --git a/packages/shared/src/configLoader.ts b/packages/shared/src/configLoader.ts index 49d7a64..4d6b204 100644 --- a/packages/shared/src/configLoader.ts +++ b/packages/shared/src/configLoader.ts @@ -77,6 +77,9 @@ export const DEFAULT_CONFIG: ResolvedAfConfig = { enabled: false, framework: 'react', }, + contract: { + dspackPath: null, + }, _source: null, }; @@ -282,6 +285,15 @@ function validateAfConfig(raw: Record): AfConfig { } } + // Contract surface (dspack) + if (typeof raw.contract === 'object' && raw.contract !== null && !Array.isArray(raw.contract)) { + const ct = raw.contract as Record; + config.contract = {}; + if (typeof ct.dspackPath === 'string' && ct.dspackPath.length > 0) { + config.contract.dspackPath = ct.dspackPath; + } + } + return config; } @@ -442,6 +454,9 @@ function mergeFileConfig(defaults: ResolvedAfConfig, file: AfConfig, source: str if (file.storybook?.enabled !== undefined) config.storybook.enabled = file.storybook.enabled; if (file.storybook?.framework !== undefined) config.storybook.framework = file.storybook.framework; + // Contract surface (dspack) + if (file.contract?.dspackPath !== undefined) config.contract.dspackPath = file.contract.dspackPath; + return config; } diff --git a/packages/shared/src/crossSurfaceDrift.ts b/packages/shared/src/crossSurfaceDrift.ts index 3d2e8ef..c539268 100644 --- a/packages/shared/src/crossSurfaceDrift.ts +++ b/packages/shared/src/crossSurfaceDrift.ts @@ -12,6 +12,21 @@ * where surfaces disagree. */ +// ============================================================================= +// SURFACE IDENTIFIERS +// ============================================================================= + +/** + * Identifier for a comparable surface in cross-surface drift analysis. + * + * - 'figma': design tool state (Figma Console MCP adapter) + * - 'storybook': code-adjacent runtime metadata (Storybook MCP adapter) + * - 'code': source AST extraction + * - 'contract': declared design-system contract (a dspack file) — a versioned, + * read-only artifact, not a live tool + */ +export type DriftSurfaceId = 'figma' | 'storybook' | 'code' | 'contract'; + // ============================================================================= // DRIFT REPORT // ============================================================================= @@ -28,6 +43,7 @@ export interface CrossSurfaceDriftReport { figma?: SurfaceSnapshot; storybook?: SurfaceSnapshot; code?: SurfaceSnapshot; + contract?: SurfaceSnapshot; }; /** Individual drift findings */ @@ -37,7 +53,7 @@ export interface CrossSurfaceDriftReport { severity: DriftSeverity; /** Surfaces that were actually queried (regardless of whether data was found) */ - queriedSurfaces: ('figma' | 'storybook' | 'code')[]; + queriedSurfaces: DriftSurfaceId[]; /** When the analysis was performed */ analyzedAt: string; @@ -110,6 +126,9 @@ export interface DriftFinding { /** Value from code surface (if available) */ codeValue?: string; + /** Value from the contract surface (if available) */ + contractValue?: string; + /** Optional Storybook story reference for the mismatched item */ storyRef?: string; @@ -129,6 +148,8 @@ export type DriftType = | 'missing-in-figma' | 'missing-in-storybook' | 'missing-in-code' + | 'missing-in-contract' + | 'contract-mismatch' | 'value-mismatch' | 'name-mismatch'; @@ -148,10 +169,40 @@ export interface DriftAnalysisOptions { includeUncorroborated?: boolean; /** Surfaces that were queried by the caller (used to distinguish "not checked" from "checked, not found") */ - queriedSurfaces?: ('figma' | 'storybook' | 'code')[]; + queriedSurfaces?: DriftSurfaceId[]; /** Override the default normalization config (alias mappings + design-only filters) */ normalizationConfig?: NormalizationConfig; + + /** + * Component data from the dspack contract surface, if queried. + * Read-only — supplied by the watcher's contractSurface module. + * Passed via options (not a positional parameter) to keep the + * analyzeCrossSurfaceDrift() signature stable for existing callers. + */ + contractData?: ContractComponentData | null; +} + +// ============================================================================= +// CONTRACT SURFACE +// ============================================================================= + +/** + * Component data extracted from a dspack contract file for drift comparison. + * Produced by the watcher's contractSurface module — read-only. + */ +export interface ContractComponentData { + /** dspack component ID (kebab-case, e.g., "alert-dialog") */ + id: string; + + /** Display name from the contract entry (e.g., "AlertDialog") */ + name: string; + + /** Props declared by the contract */ + props: SurfaceProp[]; + + /** Variant values: union of all enum-prop values declared by the contract */ + variants: string[]; } // ============================================================================= @@ -189,13 +240,13 @@ export interface NormalizationMetadata { appliedRules: Array<{ originalName: string; canonicalName: string; - surface: 'figma' | 'storybook' | 'code'; + surface: DriftSurfaceId; }>; /** Props excluded from comparison as design-only */ excludedProps: Array<{ name: string; - surface: 'figma' | 'storybook' | 'code'; + surface: DriftSurfaceId; reason: 'design-only'; }>; } diff --git a/packages/shared/src/surfaceMetadata.ts b/packages/shared/src/surfaceMetadata.ts index aabfec6..3eb8a2c 100644 --- a/packages/shared/src/surfaceMetadata.ts +++ b/packages/shared/src/surfaceMetadata.ts @@ -32,12 +32,15 @@ * - "runtime": Framework runtime or code analysis (Vuetify, AntD, Storybook, AST) * - "generation": AI/code generation source (UXPilot, v0, etc.) * - "inspection": Observation/monitoring tool (DevTools, visual regression, etc.) + * - "contract": Declared design-system contract artifact (e.g., a dspack file) — + * versioned and reviewed in source control, not a live tool */ export type SurfaceType = | 'design' | 'runtime' | 'generation' - | 'inspection'; + | 'inspection' + | 'contract'; // ============================================================================= // ACCESS MODE — Whether the adapter can mutate anything diff --git a/packages/watcher/package.json b/packages/watcher/package.json index 66db4e0..23eaf64 100644 --- a/packages/watcher/package.json +++ b/packages/watcher/package.json @@ -51,6 +51,7 @@ "dependencies": { "@aesthetic-function/shared": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", + "ajv": "^8.17.0", "chokidar": "^3.5.3", "ws": "^8.14.0" }, diff --git a/packages/watcher/src/__fixtures__/contract/README.md b/packages/watcher/src/__fixtures__/contract/README.md new file mode 100644 index 0000000..d6a6a31 --- /dev/null +++ b/packages/watcher/src/__fixtures__/contract/README.md @@ -0,0 +1,9 @@ +# Contract surface test fixtures + +- `shadcn-demo.dspack.json` — copy of the dspack-export golden fixture + (`dspack-export/fixtures/shadcn-demo/shadcn-demo.dspack.json`, exporter + 0.1.0-alpha.1). Committed locally so AF tests are self-contained; do not + reference the dspack-export repo at test time. +- `invalid-version.dspack.json` — unsupported `dspack` version, loader must reject. +- `invalid-schema.dspack.json` — violates the v0.2 schema (`values` not an + array), loader must reject with instance paths. diff --git a/packages/watcher/src/__fixtures__/contract/invalid-schema.dspack.json b/packages/watcher/src/__fixtures__/contract/invalid-schema.dspack.json new file mode 100644 index 0000000..b9cfc52 --- /dev/null +++ b/packages/watcher/src/__fixtures__/contract/invalid-schema.dspack.json @@ -0,0 +1,15 @@ +{ + "dspack": "0.2", + "name": "Schema Invalid", + "components": { + "button": { + "name": "Button", + "props": { + "variant": { + "type": "enum", + "values": "not-an-array" + } + } + } + } +} \ No newline at end of file diff --git a/packages/watcher/src/__fixtures__/contract/invalid-version.dspack.json b/packages/watcher/src/__fixtures__/contract/invalid-version.dspack.json new file mode 100644 index 0000000..14df190 --- /dev/null +++ b/packages/watcher/src/__fixtures__/contract/invalid-version.dspack.json @@ -0,0 +1,4 @@ +{ + "dspack": "0.9", + "name": "Bad Version" +} \ No newline at end of file diff --git a/packages/watcher/src/__fixtures__/contract/shadcn-demo.dspack.json b/packages/watcher/src/__fixtures__/contract/shadcn-demo.dspack.json new file mode 100644 index 0000000..3ef5412 --- /dev/null +++ b/packages/watcher/src/__fixtures__/contract/shadcn-demo.dspack.json @@ -0,0 +1,285 @@ +{ + "dspack": "0.2", + "name": "Shadcn Demo", + "description": "Demo shadcn-style design system used as the dspack-export golden fixture.", + "version": "1.0.0", + "metadata": { + "generatedBy": "@aestheticfunction/dspack-export@0.1.0-alpha.1", + "generatedAt": "2026-06-10T00:00:00.000Z", + "source": "fixtures/shadcn-demo", + "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated and will be overwritten on regeneration." + }, + "tokens": { + "color": { + "description": "Semantic color tokens extracted from CSS custom properties. Values are the default (light) theme.", + "tier": "semantic", + "values": { + "background": { + "value": "hsl(0 0% 100%)", + "type": "color", + "description": "Page background color." + }, + "foreground": { + "value": "hsl(222.2 84% 4.9%)", + "type": "color", + "description": "Default text color on the page background." + }, + "card": { + "value": "hsl(0 0% 100%)", + "type": "color", + "description": "Card surface background." + }, + "card-foreground": { + "value": "hsl(222.2 84% 4.9%)", + "type": "color", + "description": "Text color on card surfaces." + }, + "primary": { + "value": "hsl(222.2 47.4% 11.2%)", + "type": "color", + "description": "Primary brand color for prominent interactive elements." + }, + "primary-foreground": { + "value": "hsl(210 40% 98%)", + "type": "color", + "description": "Text color on primary-colored surfaces." + }, + "secondary": { + "value": "hsl(210 40% 96.1%)", + "type": "color", + "description": "Secondary surface color for less prominent elements." + }, + "secondary-foreground": { + "value": "hsl(222.2 47.4% 11.2%)", + "type": "color", + "description": "Text color on secondary surfaces." + }, + "muted": { + "value": "hsl(210 40% 96.1%)", + "type": "color", + "description": "Muted background for subdued UI regions." + }, + "muted-foreground": { + "value": "hsl(215.4 16.3% 46.9%)", + "type": "color", + "description": "Subdued text color." + }, + "accent": { + "value": "hsl(210 40% 96.1%)", + "type": "color", + "description": "Accent background for hover and highlight states." + }, + "accent-foreground": { + "value": "hsl(222.2 47.4% 11.2%)", + "type": "color", + "description": "Text color on accent surfaces." + }, + "destructive": { + "value": "hsl(0 84.2% 60.2%)", + "type": "color", + "description": "Color for destructive actions and errors." + }, + "destructive-foreground": { + "value": "hsl(210 40% 98%)", + "type": "color", + "description": "Text color on destructive surfaces." + }, + "border": { + "value": "hsl(214.3 31.8% 91.4%)", + "type": "color", + "description": "Default border color." + }, + "input": { + "value": "hsl(214.3 31.8% 91.4%)", + "type": "color", + "description": "Form input border color." + }, + "ring": { + "value": "hsl(222.2 84% 4.9%)", + "type": "color", + "description": "Focus ring color." + } + } + }, + "radius": { + "description": "Border radius tokens extracted from CSS custom properties.", + "tier": "semantic", + "values": { + "radius": { + "value": "0.5rem", + "type": "borderRadius" + } + } + } + }, + "components": { + "badge": { + "name": "Badge", + "description": "Displays a badge or a component that looks like a badge.", + "x-componentKey": "ui/Badge", + "props": { + "variant": { + "type": "enum", + "values": [ + "default", + "secondary", + "destructive", + "outline" + ], + "default": "default", + "propRole": "choice" + } + } + }, + "button": { + "name": "Button", + "description": "Displays a button or a component that looks like a button.", + "x-componentKey": "ui/Button", + "props": { + "variant": { + "type": "enum", + "values": [ + "default", + "destructive", + "outline", + "secondary", + "ghost", + "link" + ], + "default": "default", + "propRole": "choice" + }, + "size": { + "type": "enum", + "values": [ + "default", + "sm", + "lg", + "icon" + ], + "default": "default", + "propRole": "dimension" + }, + "asChild": { + "type": "boolean", + "propRole": "flag", + "description": "Render as the child element via Radix Slot instead of a native button.", + "default": false + } + } + }, + "card": { + "name": "Card", + "description": "Displays a card container with header, content, and footer sections.", + "x-componentKey": "ui/Card" + }, + "card-header": { + "name": "CardHeader", + "description": "Header section of a Card.", + "x-componentKey": "ui/CardHeader" + }, + "card-title": { + "name": "CardTitle", + "description": "Title text within a CardHeader.", + "x-componentKey": "ui/CardTitle" + }, + "card-content": { + "name": "CardContent", + "description": "Main content section of a Card.", + "x-componentKey": "ui/CardContent" + }, + "input": { + "name": "Input", + "description": "Displays a form input field.", + "x-componentKey": "ui/Input" + } + }, + "frameworkBindings": { + "react": { + "name": "React", + "components": { + "badge": { + "importPath": "./components/ui/badge", + "exportName": "Badge" + }, + "button": { + "importPath": "./components/ui/button", + "exportName": "Button" + }, + "card": { + "importPath": "./components/ui/card", + "exportName": "Card" + }, + "card-header": { + "importPath": "./components/ui/card", + "exportName": "CardHeader" + }, + "card-title": { + "importPath": "./components/ui/card", + "exportName": "CardTitle" + }, + "card-content": { + "importPath": "./components/ui/card", + "exportName": "CardContent" + }, + "input": { + "importPath": "./components/ui/input", + "exportName": "Input" + } + } + } + }, + "themes": { + "dark": { + "name": "Dark", + "description": "Dark theme overrides extracted from the .dark CSS block.", + "overrides": { + "color.background": "hsl(222.2 84% 4.9%)", + "color.foreground": "hsl(210 40% 98%)", + "color.card": "hsl(222.2 84% 4.9%)", + "color.card-foreground": "hsl(210 40% 98%)", + "color.primary": "hsl(210 40% 98%)", + "color.primary-foreground": "hsl(222.2 47.4% 11.2%)", + "color.secondary": "hsl(217.2 32.6% 17.5%)", + "color.secondary-foreground": "hsl(210 40% 98%)", + "color.muted": "hsl(217.2 32.6% 17.5%)", + "color.muted-foreground": "hsl(215 20.2% 65.1%)", + "color.accent": "hsl(217.2 32.6% 17.5%)", + "color.accent-foreground": "hsl(210 40% 98%)", + "color.destructive": "hsl(0 62.8% 30.6%)", + "color.destructive-foreground": "hsl(210 40% 98%)", + "color.border": "hsl(217.2 32.6% 17.5%)", + "color.input": "hsl(217.2 32.6% 17.5%)", + "color.ring": "hsl(212.7 26.8% 83.9%)" + } + } + }, + "layout": { + "breakpoints": { + "sm": { + "minWidth": "640px", + "description": "Small devices and large phones in landscape." + }, + "md": { + "minWidth": "768px", + "description": "Tablets." + }, + "lg": { + "minWidth": "1024px", + "description": "Laptops and small desktops." + }, + "xl": { + "minWidth": "1280px", + "description": "Desktops." + }, + "2xl": { + "minWidth": "1536px", + "description": "Large desktops." + } + }, + "spacingScale": { + "baseUnit": "0.25rem", + "description": "Spacing follows a 0.25rem base unit; use integer multiples of the scale." + } + } +} diff --git a/packages/watcher/src/contractSurface/__tests__/loadContract.test.ts b/packages/watcher/src/contractSurface/__tests__/loadContract.test.ts new file mode 100644 index 0000000..8f955a4 --- /dev/null +++ b/packages/watcher/src/contractSurface/__tests__/loadContract.test.ts @@ -0,0 +1,59 @@ +/** + * @aesthetic-function/watcher - contractSurface/__tests__/loadContract.test.ts + * + * Loader tests: valid v0.1/v0.2 documents load; bad files are rejected + * with actionable errors. Mirrors ds-mcp loader semantics. + */ + +import { describe, it, expect } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + +import { loadContract } from '../loadContract.js'; + +const fixtureDir = join( + dirname(fileURLToPath(import.meta.url)), + '..', '..', '__fixtures__', 'contract', +); + +describe('loadContract', () => { + it('loads the shadcn-demo v0.2 fixture', () => { + const doc = loadContract(join(fixtureDir, 'shadcn-demo.dspack.json')); + expect(doc.dspack).toBe('0.2'); + expect(doc.name).toBeTruthy(); + expect(Object.keys(doc.components ?? {})).toContain('button'); + }); + + it('loads a minimal v0.1 document (only dspack + name)', () => { + const dir = mkdtempSync(join(tmpdir(), 'af-contract-')); + const file = join(dir, 'minimal.dspack.json'); + writeFileSync(file, JSON.stringify({ dspack: '0.1', name: 'Minimal' })); + const doc = loadContract(file); + expect(doc.dspack).toBe('0.1'); + expect(doc.name).toBe('Minimal'); + }); + + it('rejects an unsupported dspack version', () => { + expect(() => loadContract(join(fixtureDir, 'invalid-version.dspack.json'))) + .toThrow(/Unsupported dspack version '0\.9'/); + }); + + it('rejects a missing file with a readable error', () => { + expect(() => loadContract(join(fixtureDir, 'does-not-exist.dspack.json'))) + .toThrow(/Failed to read dspack contract file/); + }); + + it('rejects malformed JSON', () => { + const dir = mkdtempSync(join(tmpdir(), 'af-contract-')); + const file = join(dir, 'broken.dspack.json'); + writeFileSync(file, '{ not json'); + expect(() => loadContract(file)).toThrow(/Invalid JSON in dspack contract file/); + }); + + it('rejects a schema-invalid document with instance paths', () => { + expect(() => loadContract(join(fixtureDir, 'invalid-schema.dspack.json'))) + .toThrow(/dspack schema validation failed[\s\S]*\/components\/button\/props\/variant\/values/); + }); +}); diff --git a/packages/watcher/src/contractSurface/__tests__/surface.test.ts b/packages/watcher/src/contractSurface/__tests__/surface.test.ts new file mode 100644 index 0000000..e2fda5e --- /dev/null +++ b/packages/watcher/src/contractSurface/__tests__/surface.test.ts @@ -0,0 +1,99 @@ +/** + * @aesthetic-function/watcher - contractSurface/__tests__/surface.test.ts + * + * Mapping tests: dspack component entries → ContractComponentData + * (SurfaceProp inventory + enum-derived variants), name/ID lookup rules. + */ + +import { describe, it, expect } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { loadContract } from '../loadContract.js'; +import { + findContractComponent, + listContractComponentNames, + toContractId, +} from '../surface.js'; + +const fixturePath = join( + dirname(fileURLToPath(import.meta.url)), + '..', '..', '__fixtures__', 'contract', 'shadcn-demo.dspack.json', +); + +const doc = loadContract(fixturePath); + +describe('toContractId', () => { + it('converts PascalCase to kebab-case', () => { + expect(toContractId('AlertDialog')).toBe('alert-dialog'); + expect(toContractId('Button')).toBe('button'); + expect(toContractId('CardHeader')).toBe('card-header'); + }); + + it('strips characters outside the dspack ID grammar', () => { + expect(toContractId('My Component_v2!')).toBe('my-component-v2'); + }); +}); + +describe('findContractComponent', () => { + it('matches by display name, case-insensitively', () => { + const button = findContractComponent(doc, 'Button'); + expect(button).not.toBeNull(); + expect(button!.id).toBe('button'); + expect(button!.name).toBe('Button'); + + expect(findContractComponent(doc, 'button')!.id).toBe('button'); + expect(findContractComponent(doc, 'BUTTON')!.id).toBe('button'); + }); + + it('falls back to kebab-converted ID lookup', () => { + const header = findContractComponent(doc, 'CardHeader'); + expect(header).not.toBeNull(); + expect(header!.id).toBe('card-header'); + }); + + it('returns null for components not in the contract', () => { + expect(findContractComponent(doc, 'Tooltip')).toBeNull(); + }); + + it('maps enum props to SurfaceProp with values', () => { + const button = findContractComponent(doc, 'Button')!; + const variant = button.props.find(p => p.name === 'variant'); + expect(variant).toBeDefined(); + expect(variant!.type).toBe('enum'); + expect(variant!.values).toEqual([ + 'default', 'destructive', 'outline', 'secondary', 'ghost', 'link', + ]); + }); + + it('maps non-enum props without values', () => { + const button = findContractComponent(doc, 'Button')!; + const asChild = button.props.find(p => p.name === 'asChild'); + expect(asChild).toBeDefined(); + expect(asChild!.type).toBe('boolean'); + expect(asChild!.values).toBeUndefined(); + }); + + it('derives variants as the union of all enum prop values', () => { + const button = findContractComponent(doc, 'Button')!; + // variant enum (6 values) + size enum, deduplicated ('default' appears in both) + expect(button.variants).toContain('destructive'); + expect(button.variants).toContain('ghost'); + expect(button.variants).toContain('sm'); + expect(button.variants).toContain('icon'); + expect(button.variants.filter(v => v === 'default')).toHaveLength(1); + }); +}); + +describe('listContractComponentNames', () => { + it('lists display names of all contract components', () => { + const names = listContractComponentNames(doc); + expect(names).toContain('Button'); + expect(names).toContain('CardHeader'); + expect(names.length).toBe(Object.keys(doc.components ?? {}).length); + }); + + it('returns an empty list for a contract without components', () => { + expect(listContractComponentNames({ dspack: '0.1', name: 'Empty' })).toEqual([]); + }); +}); diff --git a/packages/watcher/src/contractSurface/index.ts b/packages/watcher/src/contractSurface/index.ts new file mode 100644 index 0000000..fe84dc2 --- /dev/null +++ b/packages/watcher/src/contractSurface/index.ts @@ -0,0 +1,17 @@ +/** + * @aesthetic-function/watcher - contractSurface/index.ts + * + * Public surface of the dspack contract module: load + validate a contract + * file, then read component/prop/variant data for drift comparison. + * Read-only by construction — no write paths exist in this module. + */ + +export { loadContract, SUPPORTED_DSPACK_VERSIONS } from './loadContract.js'; +export { + findContractComponent, + listContractComponentNames, + toContractId, + CONTRACT_SOURCE_ID, + CONTRACT_SURFACE_METADATA, +} from './surface.js'; +export type { DspackDocument, DspackComponentEntry, DspackPropDescriptor } from './types.js'; diff --git a/packages/watcher/src/contractSurface/loadContract.ts b/packages/watcher/src/contractSurface/loadContract.ts new file mode 100644 index 0000000..303544e --- /dev/null +++ b/packages/watcher/src/contractSurface/loadContract.ts @@ -0,0 +1,83 @@ +/** + * @aesthetic-function/watcher - contractSurface/loadContract.ts + * + * Load and validate a dspack contract file (v0.1 or v0.2). + * + * WHY: `af design drift` can compare live surfaces (Figma, Storybook, code) + * against the declared design-system contract — a dspack file committed to + * source control. This loader is the only entry point for contract data. + * + * CONSTRAINTS: + * - READ-ONLY. Reads one file from disk; never writes, never networks. + * - Validates against the vendored dspack JSON Schemas (schema/) before + * any data is used. An invalid contract is rejected, not repaired. + * - Loader/validation behavior mirrors the ds-mcp reference implementation + * so a file that loads in ds-mcp loads here, and vice versa. + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- ajv/dist/2020 has broken ESM types +import { createRequire } from 'node:module'; + +import type { DspackDocument } from './types.js'; + +const require = createRequire(import.meta.url); +const Ajv2020 = require('ajv/dist/2020'); + +const moduleDir = dirname(fileURLToPath(import.meta.url)); + +function readSchema(filename: string): Record { + return JSON.parse(readFileSync(join(moduleDir, 'schema', filename), 'utf-8')); +} + +const ajv = new Ajv2020({ allErrors: true, validateFormats: false }); +const validateV01 = ajv.compile(readSchema('dspack.v0.1.schema.json')); +const validateV02 = ajv.compile(readSchema('dspack.v0.2.schema.json')); + +export const SUPPORTED_DSPACK_VERSIONS = ['0.1', '0.2'] as const; + +/** + * Load a dspack contract file, validate it, and return the typed document. + * Throws with a path-prefixed message on any failure (missing file, invalid + * JSON, unsupported version, schema violation). + */ +export function loadContract(filePath: string): DspackDocument { + let raw: string; + try { + raw = readFileSync(filePath, 'utf-8'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to read dspack contract file: ${msg}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid JSON in dspack contract file ${filePath}: ${msg}`); + } + + const peeked = parsed as { dspack?: unknown }; + const version = typeof peeked.dspack === 'string' ? peeked.dspack : null; + + if (!version || !(SUPPORTED_DSPACK_VERSIONS as readonly string[]).includes(version)) { + throw new Error( + `Unsupported dspack version '${peeked.dspack ?? '(missing)'}' in ${filePath}. ` + + `Supported versions: ${SUPPORTED_DSPACK_VERSIONS.join(', ')}.`, + ); + } + + const validate = version === '0.1' ? validateV01 : validateV02; + + if (!validate(parsed)) { + const errors = (validate.errors as Array<{ instancePath?: string; message?: string }>) + .map((e) => ` ${e.instancePath || '/'}: ${e.message}`) + .join('\n'); + throw new Error(`dspack schema validation failed for ${filePath}:\n${errors}`); + } + + return parsed as DspackDocument; +} diff --git a/packages/watcher/src/contractSurface/schema/README.md b/packages/watcher/src/contractSurface/schema/README.md new file mode 100644 index 0000000..a4a4be6 --- /dev/null +++ b/packages/watcher/src/contractSurface/schema/README.md @@ -0,0 +1,12 @@ +# Vendored dspack schemas + +These JSON Schemas are vendored copies from the dspack specification repository, +following the same pattern used by ds-mcp and dspack-export (the dspack schema +is not published as an npm package). + +- Source: https://github.com/aestheticfunction/dspack/tree/main/schema +- Vendored at commit: `7008c3e1c136038bf112a517bde4a69c16443590` (2026-06-10) +- Files: `dspack.v0.1.schema.json`, `dspack.v0.2.schema.json` (draft 2020-12) + +The dspack spec is pre-1.0; pinning to a known commit is intentional. When the +spec adds a version, re-vendor both schemas and update this provenance note. diff --git a/packages/watcher/src/contractSurface/schema/dspack.v0.1.schema.json b/packages/watcher/src/contractSurface/schema/dspack.v0.1.schema.json new file mode 100644 index 0000000..6c9c67f --- /dev/null +++ b/packages/watcher/src/contractSurface/schema/dspack.v0.1.schema.json @@ -0,0 +1,408 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/aestheticfunction/dspack/blob/main/schema/dspack.v0.1.schema.json", + "title": "dspack v0.1", + "description": "Schema for dspack v0.1 — a JSON format for representing design system corpora.", + "type": "object", + "required": ["dspack", "name"], + "properties": { + "$schema": { + "type": "string", + "description": "URI reference to this JSON Schema. Optional; consumers MUST NOT require this property." + }, + "dspack": { + "type": "string", + "const": "0.1", + "description": "Specification version. MUST be \"0.1\" for documents conforming to this version." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name of the design system." + }, + "description": { + "type": "string", + "description": "Brief description of the design system's purpose and scope." + }, + "version": { + "type": "string", + "description": "Version of the design system content (not the spec version)." + }, + "metadata": { + "$ref": "#/$defs/metadata" + }, + "tokens": { + "type": "object", + "description": "Token definitions organized by category.", + "additionalProperties": { + "$ref": "#/$defs/tokenCategory" + } + }, + "components": { + "type": "object", + "description": "Component definitions keyed by component ID.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/componentEntry" + } + }, + "patterns": { + "type": "array", + "description": "Pattern entries describing preferred ways of combining components.", + "items": { + "$ref": "#/$defs/patternEntry" + } + }, + "antiPatterns": { + "type": "array", + "description": "Anti-pattern entries describing approaches that are deliberately ruled out.", + "items": { + "$ref": "#/$defs/antiPatternEntry" + } + }, + "frameworkBindings": { + "type": "object", + "description": "Framework-specific information keyed by framework identifier.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/frameworkBinding" + } + } + }, + "additionalProperties": true, + "$defs": { + "metadata": { + "type": "object", + "description": "Extensible metadata about the dspack file.", + "properties": { + "generatedBy": { + "type": "string", + "description": "Tool or process that created this file." + }, + "generatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when the file was generated." + }, + "source": { + "type": "string", + "description": "URL or description of the upstream source." + }, + "license": { + "type": "string", + "description": "SPDX license identifier or freeform description." + } + }, + "additionalProperties": true + }, + "tokenCategory": { + "type": "object", + "description": "A category of tokens.", + "required": ["values"], + "properties": { + "description": { + "type": "string", + "description": "What this category covers." + }, + "values": { + "type": "object", + "description": "Map of token name to token entry.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/tokenEntry" + } + } + }, + "additionalProperties": true + }, + "tokenEntry": { + "type": "object", + "description": "A single token definition.", + "required": ["value"], + "properties": { + "value": { + "type": "string", + "description": "The resolved value of the token." + }, + "description": { + "type": "string", + "description": "Semantic meaning of the token." + }, + "type": { + "type": "string", + "description": "The value type (e.g., color, dimension, fontFamily)." + }, + "deprecated": { + "type": "boolean", + "description": "Whether this token is deprecated.", + "default": false + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Other names by which this token is known." + } + }, + "additionalProperties": true + }, + "componentEntry": { + "type": "object", + "description": "A component definition.", + "required": ["name", "description"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "What the component is for." + }, + "whenToUse": { + "type": "string", + "description": "Guidance on when to use this component." + }, + "whenNotToUse": { + "type": "string", + "description": "Guidance on when to choose a different component." + }, + "props": { + "type": "object", + "description": "Map of prop name to prop descriptor.", + "additionalProperties": { + "$ref": "#/$defs/propDescriptor" + } + }, + "tokens": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Token names this component depends on." + }, + "relatedComponents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Component IDs of related components." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Freeform classification tags." + }, + "deprecated": { + "type": "boolean", + "description": "Whether this component is deprecated.", + "default": false + }, + "deprecatedMessage": { + "type": "string", + "description": "What to use instead of this component." + } + }, + "additionalProperties": true + }, + "propDescriptor": { + "type": "object", + "description": "Describes a single component prop.", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "The value type of the prop (e.g., string, number, boolean, enum)." + }, + "description": { + "type": "string", + "description": "What this prop controls." + }, + "values": { + "type": "array", + "description": "For enum type, the allowed values." + }, + "default": { + "description": "Default value of the prop." + }, + "required": { + "type": "boolean", + "description": "Whether this prop must be provided.", + "default": false + } + }, + "additionalProperties": true + }, + "patternEntry": { + "type": "object", + "description": "A pattern describing a preferred way of combining components.", + "required": ["id", "name", "description"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Unique identifier for this pattern." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name." + }, + "description": { + "type": "string", + "description": "What problem this pattern addresses." + }, + "intent": { + "type": "string", + "description": "The underlying design goal or UX objective." + }, + "context": { + "type": "string", + "description": "When this pattern applies." + }, + "components": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Component IDs involved in this pattern." + }, + "guidance": { + "type": "string", + "description": "Prose guidance on how to apply the pattern correctly." + }, + "relatedPatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Pattern IDs of related patterns." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Freeform classification tags." + } + }, + "additionalProperties": true + }, + "antiPatternEntry": { + "type": "object", + "description": "An anti-pattern describing an approach that is deliberately ruled out.", + "required": ["id", "name", "description", "reason"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Unique identifier for this anti-pattern." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name describing what not to do." + }, + "description": { + "type": "string", + "description": "What this anti-pattern is." + }, + "reason": { + "type": "string", + "description": "Why this approach is ruled out." + }, + "insteadUse": { + "type": "string", + "description": "Pattern ID of the preferred approach." + }, + "components": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Component IDs involved in this anti-pattern." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Freeform classification tags." + } + }, + "additionalProperties": true + }, + "frameworkBinding": { + "type": "object", + "description": "Framework-specific information for the design system.", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable framework name." + }, + "package": { + "type": "string", + "description": "Primary package name." + }, + "installCommand": { + "type": "string", + "description": "How to install the framework binding." + }, + "description": { + "type": "string", + "description": "What this binding provides." + }, + "guidance": { + "type": "string", + "description": "Framework-wide guidance." + }, + "components": { + "type": "object", + "description": "Per-component framework details keyed by component ID.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/componentBinding" + } + } + }, + "additionalProperties": true + }, + "componentBinding": { + "type": "object", + "description": "Framework-specific details for a single component.", + "properties": { + "importPath": { + "type": "string", + "description": "Where to import the component." + }, + "installCommand": { + "type": "string", + "description": "Component-specific install command." + }, + "exportName": { + "type": "string", + "description": "Named export if different from the component name." + }, + "guidance": { + "type": "string", + "description": "Framework-specific usage guidance for this component." + } + }, + "additionalProperties": true + } + } +} diff --git a/packages/watcher/src/contractSurface/schema/dspack.v0.2.schema.json b/packages/watcher/src/contractSurface/schema/dspack.v0.2.schema.json new file mode 100644 index 0000000..a11d2eb --- /dev/null +++ b/packages/watcher/src/contractSurface/schema/dspack.v0.2.schema.json @@ -0,0 +1,912 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/aestheticfunction/dspack/blob/main/schema/dspack.v0.2.schema.json", + "title": "dspack v0.2", + "description": "Schema for dspack v0.2 — a JSON format for representing design system corpora. Backward-compatible with v0.1 documents.", + "type": "object", + "required": ["dspack", "name"], + "properties": { + "$schema": { + "type": "string", + "description": "URI reference to this JSON Schema. Optional; consumers MUST NOT require this property." + }, + "dspack": { + "type": "string", + "const": "0.2", + "description": "Specification version. MUST be \"0.2\" for documents conforming to this version." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name of the design system." + }, + "description": { + "type": "string", + "description": "Brief description of the design system's purpose and scope." + }, + "version": { + "type": "string", + "description": "Version of the design system content (not the spec version)." + }, + "metadata": { + "$ref": "#/$defs/metadata" + }, + "tokens": { + "type": "object", + "description": "Token definitions organized by category.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/tokenCategory" + } + }, + "components": { + "type": "object", + "description": "Component definitions keyed by component ID.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/componentEntry" + } + }, + "patterns": { + "type": "array", + "description": "Pattern entries describing preferred ways of combining components.", + "items": { + "$ref": "#/$defs/patternEntry" + } + }, + "antiPatterns": { + "type": "array", + "description": "Anti-pattern entries describing approaches that are deliberately ruled out.", + "items": { + "$ref": "#/$defs/antiPatternEntry" + } + }, + "frameworkBindings": { + "type": "object", + "description": "Framework-specific information keyed by framework identifier.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/frameworkBinding" + } + }, + "themes": { + "type": "object", + "description": "Named sets of token overrides representing alternative visual modes.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/themeEntry" + } + }, + "layout": { + "$ref": "#/$defs/layoutPrimitives" + } + }, + "additionalProperties": true, + "$defs": { + "metadata": { + "type": "object", + "description": "Extensible metadata about the dspack file.", + "properties": { + "generatedBy": { + "type": "string", + "description": "Tool or process that created this file." + }, + "generatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 datetime when the file was generated." + }, + "source": { + "type": "string", + "description": "URL or description of the upstream source." + }, + "license": { + "type": "string", + "description": "SPDX license identifier or freeform description." + } + }, + "additionalProperties": true + }, + "tokenCategory": { + "type": "object", + "description": "A category of tokens.", + "required": ["values"], + "properties": { + "description": { + "type": "string", + "description": "What this category covers." + }, + "tier": { + "type": "string", + "enum": ["primitive", "semantic", "component"], + "description": "Default abstraction level for tokens in this category." + }, + "values": { + "type": "object", + "description": "Map of token name to token entry.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/tokenEntry" + } + } + }, + "additionalProperties": true + }, + "tokenEntry": { + "type": "object", + "description": "A single token definition.", + "required": ["value"], + "properties": { + "value": { + "type": "string", + "description": "The resolved value of the token." + }, + "description": { + "type": "string", + "description": "Semantic meaning of the token." + }, + "type": { + "type": "string", + "description": "The value type (e.g., color, dimension, fontFamily)." + }, + "deprecated": { + "type": "boolean", + "description": "Whether this token is deprecated.", + "default": false + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Other names by which this token is known." + }, + "status": { + "oneOf": [ + { + "type": "string", + "enum": ["draft", "experimental", "stable", "deprecated"] + }, + { + "$ref": "#/$defs/statusObject" + } + ], + "description": "Lifecycle stage. String for uniform status, object for per-platform granularity." + }, + "aliasOf": { + "oneOf": [ + { + "type": "string", + "description": "Token name that this token aliases." + }, + { + "$ref": "#/$defs/aliasReference" + } + ], + "description": "Token that this token aliases. String for unambiguous names, object for cross-category disambiguation." + }, + "tier": { + "type": "string", + "enum": ["primitive", "semantic", "component"], + "description": "Abstraction level of this token, overriding the category default." + } + }, + "additionalProperties": true + }, + "statusObject": { + "type": "object", + "description": "Lifecycle status with per-platform granularity.", + "required": ["default"], + "properties": { + "default": { + "type": "string", + "enum": ["draft", "experimental", "stable", "deprecated"], + "description": "Default lifecycle stage when no platform-specific override applies." + }, + "platforms": { + "type": "object", + "description": "Map of platform/framework ID to platform status object.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/platformStatus" + } + } + }, + "additionalProperties": true + }, + "platformStatus": { + "type": "object", + "description": "Lifecycle status for a specific platform.", + "required": ["stage"], + "properties": { + "stage": { + "type": "string", + "enum": ["draft", "experimental", "stable", "deprecated"], + "description": "Lifecycle stage for this platform." + }, + "since": { + "type": "string", + "description": "Version of the design system content at which this stage took effect." + }, + "migrateTo": { + "type": "string", + "description": "Component ID or token name of the recommended replacement." + }, + "note": { + "type": "string", + "description": "Prose migration guidance or context for this platform's status." + } + }, + "additionalProperties": true + }, + "aliasReference": { + "type": "object", + "description": "Structured token alias reference for cross-category disambiguation.", + "required": ["category", "token"], + "properties": { + "category": { + "type": "string", + "description": "Token category name containing the referenced token." + }, + "token": { + "type": "string", + "description": "Token name within that category." + } + }, + "additionalProperties": true + }, + "componentEntry": { + "type": "object", + "description": "A component definition.", + "required": ["name", "description"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "What the component is for." + }, + "whenToUse": { + "type": "string", + "description": "Guidance on when to use this component." + }, + "whenNotToUse": { + "type": "string", + "description": "Guidance on when to choose a different component." + }, + "props": { + "type": "object", + "description": "Map of prop name to prop descriptor.", + "additionalProperties": { + "$ref": "#/$defs/propDescriptor" + } + }, + "tokens": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Token names this component depends on." + }, + "relatedComponents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Component IDs of related components." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Freeform classification tags." + }, + "deprecated": { + "type": "boolean", + "description": "Whether this component is deprecated.", + "default": false + }, + "deprecatedMessage": { + "type": "string", + "description": "What to use instead of this component." + }, + "status": { + "oneOf": [ + { + "type": "string", + "enum": ["draft", "experimental", "stable", "deprecated"] + }, + { + "$ref": "#/$defs/statusObject" + } + ], + "description": "Lifecycle stage. String for uniform status, object for per-platform granularity." + }, + "accessibility": { + "$ref": "#/$defs/accessibilityConstraints" + }, + "composition": { + "$ref": "#/$defs/compositionRules" + }, + "constraints": { + "type": "array", + "description": "Structured usage constraints.", + "items": { + "$ref": "#/$defs/constraintEntry" + } + } + }, + "additionalProperties": true + }, + "propDescriptor": { + "type": "object", + "description": "Describes a single component prop.", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "The value type of the prop (e.g., string, number, boolean, enum)." + }, + "description": { + "type": "string", + "description": "What this prop controls." + }, + "values": { + "type": "array", + "description": "For enum type, the allowed values. Items may be bare values or value descriptor objects.", + "items": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "$ref": "#/$defs/valueDescriptor" } + ] + } + }, + "default": { + "description": "Default value of the prop." + }, + "required": { + "type": "boolean", + "description": "Whether this prop must be provided.", + "default": false + }, + "propRole": { + "type": "string", + "enum": ["flag", "dimension", "choice", "slot", "handler", "content", "state"], + "description": "Semantic role of this prop." + } + }, + "additionalProperties": true + }, + "valueDescriptor": { + "type": "object", + "description": "Describes a single allowed value for an enum prop.", + "required": ["value"], + "properties": { + "value": { + "description": "The actual enum value." + }, + "description": { + "type": "string", + "description": "When to choose this value." + }, + "deprecated": { + "type": "boolean", + "description": "Whether this specific value is deprecated.", + "default": false + } + }, + "additionalProperties": true + }, + "accessibilityConstraints": { + "type": "object", + "description": "Accessibility constraints and expectations for a component.", + "properties": { + "role": { + "type": "string", + "description": "WAI-ARIA role this component fulfills." + }, + "requiredAttributes": { + "type": "array", + "description": "HTML or ARIA attributes that must be present for correct accessible usage.", + "items": { + "$ref": "#/$defs/attributeDescriptor" + } + }, + "keyboardInteractions": { + "type": "array", + "description": "Expected keyboard behaviors.", + "items": { + "$ref": "#/$defs/keyboardInteraction" + } + }, + "contrastRequirement": { + "type": "string", + "description": "Minimum contrast ratio or WCAG level." + }, + "focusManagement": { + "type": "string", + "description": "Prose description of focus behavior expectations." + }, + "labelRequirement": { + "type": "string", + "enum": ["required-visible", "required-accessible-name", "required-aria", "optional", "none"], + "description": "How the component must be labeled." + }, + "notes": { + "type": "string", + "description": "Additional accessibility guidance in prose." + } + }, + "additionalProperties": true + }, + "attributeDescriptor": { + "type": "object", + "description": "Describes a required HTML or ARIA attribute for accessible usage.", + "required": ["attribute"], + "properties": { + "attribute": { + "type": "string", + "description": "The attribute name (e.g., aria-label, aria-describedby, id, type)." + }, + "description": { + "type": "string", + "description": "When and how to provide this attribute." + }, + "condition": { + "type": "string", + "description": "Condition under which this attribute is required." + } + }, + "additionalProperties": true + }, + "keyboardInteraction": { + "type": "object", + "description": "Describes an expected keyboard behavior.", + "required": ["key", "description"], + "properties": { + "key": { + "type": "string", + "description": "The key or key combination." + }, + "description": { + "type": "string", + "description": "What this key does in the context of this component." + } + }, + "additionalProperties": true + }, + "compositionRules": { + "type": "object", + "description": "Rules governing how a component composes with other components.", + "properties": { + "subComponents": { + "type": "array", + "description": "Sub-components that belong to this compound component.", + "items": { + "$ref": "#/$defs/subComponentDescriptor" + } + }, + "requiredChildren": { + "type": "array", + "items": { "type": "string" }, + "description": "Component IDs or sub-component IDs that must appear as descendants." + }, + "allowedChildren": { + "type": "array", + "items": { "type": "string" }, + "description": "Component IDs or sub-component IDs that may appear as direct children." + }, + "requiredParent": { + "type": "string", + "description": "Component ID or sub-component ID that must be an ancestor." + }, + "allowedParents": { + "type": "array", + "items": { "type": "string" }, + "description": "Component IDs or sub-component IDs that may be the parent." + }, + "requiredSiblings": { + "type": "array", + "items": { "type": "string" }, + "description": "Component IDs or sub-component IDs that must also be present among siblings." + }, + "notes": { + "type": "string", + "description": "Prose description of composition constraints not captured by structured fields." + } + }, + "additionalProperties": true + }, + "subComponentDescriptor": { + "type": "object", + "description": "Describes a sub-component of a compound component.", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Identifier for the sub-component. Should be parent-prefixed." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name." + }, + "description": { + "type": "string", + "description": "What this sub-component is for." + }, + "required": { + "type": "boolean", + "description": "Whether this sub-component must be present when the parent is used.", + "default": false + }, + "slot": { + "type": "string", + "description": "Named slot this sub-component fills." + }, + "acceptsChildren": { + "type": "string", + "enum": ["any", "text", "components", "none"], + "description": "What this sub-component expects as children." + } + }, + "additionalProperties": true + }, + "constraintEntry": { + "type": "object", + "description": "A structured usage constraint.", + "required": ["context", "rule", "severity"], + "properties": { + "context": { + "type": "string", + "description": "The situation or condition this constraint applies to." + }, + "rule": { + "type": "string", + "description": "What to do or not do." + }, + "severity": { + "type": "string", + "enum": ["must", "should", "should-not", "must-not"], + "description": "RFC 2119 strength of the constraint." + } + }, + "additionalProperties": true + }, + "patternEntry": { + "type": "object", + "description": "A pattern describing a preferred way of combining components.", + "required": ["id", "name", "description"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Unique identifier for this pattern." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name." + }, + "description": { + "type": "string", + "description": "What problem this pattern addresses." + }, + "intent": { + "type": "string", + "description": "The underlying design goal or UX objective." + }, + "context": { + "type": "string", + "description": "When this pattern applies." + }, + "components": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Component IDs involved in this pattern." + }, + "guidance": { + "type": "string", + "description": "Prose guidance on how to apply the pattern correctly." + }, + "relatedPatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Pattern IDs of related patterns." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Freeform classification tags." + } + }, + "additionalProperties": true + }, + "antiPatternEntry": { + "type": "object", + "description": "An anti-pattern describing an approach that is deliberately ruled out.", + "required": ["id", "name", "description", "reason"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Unique identifier for this anti-pattern." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name describing what not to do." + }, + "description": { + "type": "string", + "description": "What this anti-pattern is." + }, + "reason": { + "type": "string", + "description": "Why this approach is ruled out." + }, + "severity": { + "type": "string", + "enum": ["must-not", "should-not", "discouraged"], + "description": "Strength of the prohibition. Defaults to should-not.", + "default": "should-not" + }, + "insteadUse": { + "type": "string", + "description": "Pattern ID of the preferred approach." + }, + "components": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Component IDs involved in this anti-pattern." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Freeform classification tags." + } + }, + "additionalProperties": true + }, + "frameworkBinding": { + "type": "object", + "description": "Framework-specific information for the design system.", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable framework name." + }, + "package": { + "type": "string", + "description": "Primary package name." + }, + "installCommand": { + "type": "string", + "description": "How to install the framework binding." + }, + "description": { + "type": "string", + "description": "What this binding provides." + }, + "guidance": { + "type": "string", + "description": "Framework-wide guidance." + }, + "components": { + "type": "object", + "description": "Per-component framework details keyed by component ID.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/componentBinding" + } + } + }, + "additionalProperties": true + }, + "componentBinding": { + "type": "object", + "description": "Framework-specific details for a single component.", + "properties": { + "importPath": { + "type": "string", + "description": "Where to import the component." + }, + "installCommand": { + "type": "string", + "description": "Component-specific install command." + }, + "exportName": { + "type": "string", + "description": "Named export if different from the component name." + }, + "guidance": { + "type": "string", + "description": "Framework-specific usage guidance for this component." + }, + "subComponents": { + "type": "object", + "description": "Map of sub-component ID to sub-component binding.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/subComponentBinding" + } + } + }, + "additionalProperties": true + }, + "subComponentBinding": { + "type": "object", + "description": "Framework-specific details for a sub-component.", + "properties": { + "exportName": { + "type": "string", + "description": "Named export for this sub-component." + }, + "importPath": { + "type": "string", + "description": "Import path if different from the parent component's import path." + } + }, + "additionalProperties": true + }, + "themeEntry": { + "type": "object", + "description": "A named set of token overrides representing an alternative visual mode.", + "required": ["name", "overrides"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable theme name." + }, + "description": { + "type": "string", + "description": "What this theme is for." + }, + "overrides": { + "type": "object", + "description": "Map of token reference (category.tokenName) to overridden resolved value.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": true + }, + "layoutPrimitives": { + "type": "object", + "description": "Layout system primitives: breakpoints, grid, containers, and spacing scale.", + "properties": { + "breakpoints": { + "type": "object", + "description": "Named responsive breakpoints.", + "additionalProperties": { + "$ref": "#/$defs/breakpointEntry" + } + }, + "grid": { + "$ref": "#/$defs/gridConfig" + }, + "containers": { + "type": "object", + "description": "Named container width configurations.", + "additionalProperties": { + "$ref": "#/$defs/containerEntry" + } + }, + "spacingScale": { + "$ref": "#/$defs/spacingScaleConfig" + } + }, + "additionalProperties": true + }, + "breakpointEntry": { + "type": "object", + "description": "A responsive breakpoint definition.", + "required": ["minWidth"], + "properties": { + "minWidth": { + "type": "string", + "description": "Minimum viewport width for this breakpoint." + }, + "description": { + "type": "string", + "description": "What this breakpoint targets." + } + }, + "additionalProperties": true + }, + "gridConfig": { + "type": "object", + "description": "Grid system parameters.", + "properties": { + "columns": { + "type": "number", + "description": "Number of columns in the grid system." + }, + "gutter": { + "type": "string", + "description": "Default gutter width between columns." + }, + "margin": { + "type": "string", + "description": "Default outer margin of the grid container." + }, + "description": { + "type": "string", + "description": "Guidance on grid usage." + } + }, + "additionalProperties": true + }, + "containerEntry": { + "type": "object", + "description": "A container width configuration.", + "required": ["maxWidth"], + "properties": { + "maxWidth": { + "type": "string", + "description": "Maximum width of this container." + }, + "description": { + "type": "string", + "description": "When to use this container size." + } + }, + "additionalProperties": true + }, + "spacingScaleConfig": { + "type": "object", + "description": "Spacing scale system description.", + "properties": { + "baseUnit": { + "type": "string", + "description": "The fundamental unit of the spacing scale." + }, + "description": { + "type": "string", + "description": "How the scale is constructed." + } + }, + "additionalProperties": true + } + } +} diff --git a/packages/watcher/src/contractSurface/surface.ts b/packages/watcher/src/contractSurface/surface.ts new file mode 100644 index 0000000..72a5e38 --- /dev/null +++ b/packages/watcher/src/contractSurface/surface.ts @@ -0,0 +1,147 @@ +/** + * @aesthetic-function/watcher - contractSurface/surface.ts + * + * Read-only accessors over a loaded dspack contract document, shaped for + * cross-surface drift comparison. + * + * WHY: The drift engine compares SurfaceProp/variant inventories. This module + * maps dspack component entries into that vocabulary: + * - props: dspack prop descriptors → SurfaceProp {name, type, values} + * - variants: union of all enum-prop string values (mirrors how the code + * surface collects all string-literal-union values, so the two are + * comparable) + * + * CONSTRAINTS: + * - READ-ONLY accessors over an in-memory document. No I/O here. + * - NOT a DesignAdapter and never registered in the design adapter registry: + * the drift CLI treats the registry's first available adapter as the Figma + * surface, and the DesignAdapter interface (styles, file data, screenshots) + * is the wrong shape for a static contract file. + */ + +import type { + ContractComponentData, + SurfaceProp, +} from '@aesthetic-function/shared/crossSurfaceDrift'; +import type { SurfaceMetadata } from '@aesthetic-function/shared/surfaceMetadata'; + +import type { DspackDocument, DspackComponentEntry } from './types.js'; + +/** Source identifier used in drift snapshots and findings. */ +export const CONTRACT_SOURCE_ID = 'dspack-contract'; + +/** + * Surface classification for the dspack contract surface. + * Descriptor only — carries no authority into reconciliation. + */ +export const CONTRACT_SURFACE_METADATA: SurfaceMetadata = { + surfaceType: 'contract', + accessMode: 'read-only', + authorityRole: 'external-non-authoritative', + stability: 'canonical', +}; + +/** + * Convert a component name to the dspack ID convention (^[a-z][a-z0-9-]*$). + * "AlertDialog" → "alert-dialog", "Button" → "button". + */ +export function toContractId(name: string): string { + return name + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + .replace(/^-+|-+$/g, ''); +} + +/** + * Find a component in the contract by requested name. + * + * Match order: + * 1. Entry display name, case-insensitive ("Button" matches name "Button") + * 2. Entry ID equals the kebab-converted request ("CardHeader" → "card-header") + * + * Exact-name match wins so flat compound entries (card-header, card-title) + * never shadow their parent. + */ +export function findContractComponent( + doc: DspackDocument, + requestedName: string, +): ContractComponentData | null { + const components = doc.components ?? {}; + const requestedLower = requestedName.toLowerCase(); + + let matchedId: string | null = null; + let matchedEntry: DspackComponentEntry | null = null; + + for (const [id, entry] of Object.entries(components)) { + if (entry.name.toLowerCase() === requestedLower) { + matchedId = id; + matchedEntry = entry; + break; + } + } + + if (!matchedEntry) { + const kebab = toContractId(requestedName); + if (kebab && components[kebab]) { + matchedId = kebab; + matchedEntry = components[kebab]; + } + } + + if (!matchedEntry || !matchedId) return null; + + return { + id: matchedId, + name: matchedEntry.name, + props: extractProps(matchedEntry), + variants: extractVariants(matchedEntry), + }; +} + +/** + * List the display names of all components declared by the contract. + * Used as an inventory source for all-components drift analysis. + */ +export function listContractComponentNames(doc: DspackDocument): string[] { + return Object.values(doc.components ?? {}).map((entry) => entry.name); +} + +// ============================================================================= +// MAPPING HELPERS +// ============================================================================= + +function extractProps(entry: DspackComponentEntry): SurfaceProp[] { + const props: SurfaceProp[] = []; + for (const [propName, descriptor] of Object.entries(entry.props ?? {})) { + const values = stringValues(descriptor.values); + props.push({ + name: propName, + type: descriptor.type, + ...(values.length > 0 ? { values } : {}), + }); + } + return props; +} + +/** + * Variant values: union of all enum-prop string values, deduplicated in + * declaration order. Mirrors the code surface, which collects all + * string-literal-union values found in the component's source. + */ +function extractVariants(entry: DspackComponentEntry): string[] { + const variants: string[] = []; + for (const descriptor of Object.values(entry.props ?? {})) { + if (descriptor.type !== 'enum') continue; + for (const value of stringValues(descriptor.values)) { + if (!variants.includes(value)) variants.push(value); + } + } + return variants; +} + +function stringValues(values: unknown[] | undefined): string[] { + if (!values) return []; + return values.filter((v): v is string => typeof v === 'string'); +} diff --git a/packages/watcher/src/contractSurface/types.ts b/packages/watcher/src/contractSurface/types.ts new file mode 100644 index 0000000..0a8cd2d --- /dev/null +++ b/packages/watcher/src/contractSurface/types.ts @@ -0,0 +1,44 @@ +/** + * @aesthetic-function/watcher - contractSurface/types.ts + * + * Minimal dspack document types — only the parts the contract surface reads + * for component/prop/variant drift comparison. The full document is schema- + * validated at load time (loadContract.ts); these types deliberately use + * index signatures so unrecognized spec sections pass through untouched, + * matching the dspack conformance rule that consumers MUST ignore properties + * they do not recognize. + */ + +/** + * A prop declared by a dspack component entry (dspack v0.2 §6.2). + */ +export interface DspackPropDescriptor { + type: string; + description?: string; + values?: unknown[]; + default?: unknown; + required?: boolean; + [key: string]: unknown; +} + +/** + * A component entry in a dspack document (dspack v0.2 §6.1). + */ +export interface DspackComponentEntry { + name: string; + description?: string; + props?: Record; + deprecated?: boolean; + [key: string]: unknown; +} + +/** + * A dspack document (v0.1 or v0.2). Only `dspack`, `name`, and `components` + * are read by the contract surface; everything else passes through. + */ +export interface DspackDocument { + dspack: string; + name: string; + components?: Record; + [key: string]: unknown; +} diff --git a/packages/watcher/src/crossSurfaceDrift/__tests__/cliContractE2e.test.ts b/packages/watcher/src/crossSurfaceDrift/__tests__/cliContractE2e.test.ts new file mode 100644 index 0000000..1862348 --- /dev/null +++ b/packages/watcher/src/crossSurfaceDrift/__tests__/cliContractE2e.test.ts @@ -0,0 +1,127 @@ +/** + * @aesthetic-function/watcher - crossSurfaceDrift/__tests__/cliContractE2e.test.ts + * + * End-to-end: drive the drift CLI main() with the committed shadcn-demo + * contract fixture against the react-demo-app code surface, with Figma and + * Storybook unavailable. The demo Button (props: label/disabled/onClick, no + * variant unions) guarantees deterministic contract findings. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import type { CrossSurfaceDriftReport } from '@aesthetic-function/shared/crossSurfaceDrift'; +import { main } from '../cliCrossSurfaceDrift.js'; + +const fixturePath = join( + dirname(fileURLToPath(import.meta.url)), + '..', '..', '__fixtures__', 'contract', 'shadcn-demo.dspack.json', +); + +describe('af design drift --dspack (e2e)', () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + const savedEnv: Record = {}; + + beforeEach(() => { + // Ensure the Figma adapter is not registered and Storybook stays local + for (const key of ['FIGMA_ACCESS_TOKEN', 'FIGMA_FILE_KEY', 'STORYBOOK_URL']) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + function jsonOutput(): CrossSurfaceDriftReport[] { + const jsonCall = logSpy.mock.calls.find(c => typeof c[0] === 'string' && c[0].startsWith('[')); + expect(jsonCall, 'expected a JSON array on stdout').toBeDefined(); + return JSON.parse(jsonCall![0] as string); + } + + it('analyzes Button against contract + code with live surfaces down', async () => { + const exitCode = await main(['Button', '--dspack', fixturePath, '--json']); + + // Findings are warn-level at most in this slice → exit 0 + expect(exitCode).toBe(0); + + const reports = jsonOutput(); + expect(reports).toHaveLength(1); + const report = reports[0]; + + expect(report.componentName).toBe('Button'); + expect(report.queriedSurfaces).toContain('contract'); + expect(report.queriedSurfaces).toContain('code'); + expect(report.surfaces.contract?.source).toBe('dspack-contract'); + + // Contract declares variant/size/asChild — demo Button has none of them + const missingProps = report.findings + .filter(f => f.type === 'missing-in-code' && f.field.startsWith('prop:')) + .map(f => f.field); + expect(missingProps).toContain('prop:variant'); + expect(missingProps).toContain('prop:size'); + expect(missingProps).toContain('prop:asChild'); + + // Demo Button props label/disabled/onClick are not in the contract → staleness + const staleness = report.findings.filter(f => f.field.startsWith('contract-staleness:')); + expect(staleness.map(f => f.field)).toContain('contract-staleness:prop:label'); + expect(staleness.every(f => f.severity === 'info')).toBe(true); + + // Contract enum variants missing from code + const missingVariants = report.findings + .filter(f => f.type === 'missing-in-code' && f.field.startsWith('variant:')) + .map(f => f.field); + expect(missingVariants).toContain('variant:destructive'); + expect(missingVariants).toContain('variant:ghost'); + + expect(report.severity).toBe('warn'); + }); + + it('prints the staleness remediation hint in human-readable mode', async () => { + const exitCode = await main(['Button', '--dspack', fixturePath]); + expect(exitCode).toBe(0); + + const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(output).toContain('Contract ✓'); + expect(output).toContain('Contract may be stale'); + expect(output).toContain('dspack-export generate'); + }); + + it('exits 2 with a clear error for an invalid contract file', async () => { + const invalidPath = join(dirname(fixturePath), 'invalid-schema.dspack.json'); + const exitCode = await main(['Button', '--dspack', invalidPath, '--json']); + + expect(exitCode).toBe(2); + const errOutput = errorSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(errOutput).toContain('dspack schema validation failed'); + }); + + it('exits 2 when the contract file does not exist', async () => { + const exitCode = await main(['Button', '--dspack', './no-such-file.dspack.json', '--json']); + + expect(exitCode).toBe(2); + const errOutput = errorSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(errOutput).toContain('dspack contract file not found'); + }); + + it('uses the contract as inventory when no component is named and Storybook is down', async () => { + const exitCode = await main(['--dspack', fixturePath, '--json']); + expect(exitCode).toBe(0); + + const reports = jsonOutput(); + // One report per contract component (7 in the shadcn-demo fixture) + expect(reports.length).toBe(7); + expect(reports.map(r => r.componentName)).toContain('Button'); + expect(reports.every(r => r.queriedSurfaces.includes('contract'))).toBe(true); + }); +}); diff --git a/packages/watcher/src/crossSurfaceDrift/__tests__/contractDrift.test.ts b/packages/watcher/src/crossSurfaceDrift/__tests__/contractDrift.test.ts new file mode 100644 index 0000000..8d21905 --- /dev/null +++ b/packages/watcher/src/crossSurfaceDrift/__tests__/contractDrift.test.ts @@ -0,0 +1,237 @@ +/** + * @aesthetic-function/watcher - crossSurfaceDrift/__tests__/contractDrift.test.ts + * + * Contract-surface drift tests: component/prop/variant comparison against a + * dspack contract, staleness direction semantics, and the regression guard + * that no-contract reports are shape-identical to pre-contract behavior. + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeCrossSurfaceDrift } from '../analyze.js'; +import type { CodeSurfaceData } from '../analyze.js'; +import type { ContractComponentData } from '@aesthetic-function/shared/crossSurfaceDrift'; + +// ============================================================================= +// TEST HELPERS +// ============================================================================= + +function makeContractButton(overrides?: Partial): ContractComponentData { + return { + id: 'button', + name: 'Button', + props: [ + { name: 'variant', type: 'enum', values: ['default', 'destructive', 'ghost'] }, + { name: 'size', type: 'enum', values: ['sm', 'lg'] }, + { name: 'asChild', type: 'boolean' }, + ], + variants: ['default', 'destructive', 'ghost', 'sm', 'lg'], + ...overrides, + }; +} + +function makeCodeButton(overrides?: Partial): CodeSurfaceData { + return { + props: ['variant', 'size', 'asChild'], + variants: ['default', 'destructive', 'ghost', 'sm', 'lg'], + ...overrides, + }; +} + +// ============================================================================= +// AGREEMENT +// ============================================================================= + +describe('contract surface — agreement', () => { + it('produces no findings when contract and code agree', () => { + const report = analyzeCrossSurfaceDrift('Button', null, null, makeCodeButton(), { + queriedSurfaces: ['code', 'contract'], + contractData: makeContractButton(), + }); + + expect(report.findings).toEqual([]); + expect(report.severity).toBe('none'); + expect(report.surfaces.contract).toBeDefined(); + expect(report.surfaces.contract!.source).toBe('dspack-contract'); + expect(report.queriedSurfaces).toContain('contract'); + }); +}); + +// ============================================================================= +// CONTRACT → CODE DRIFT (code regressed against the contract) +// ============================================================================= + +describe('contract surface — code regressions', () => { + it('flags a contract variant missing from code as missing-in-code (warn, high)', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', null, null, + makeCodeButton({ variants: ['default', 'ghost', 'sm', 'lg'] }), // no 'destructive' + { queriedSurfaces: ['code', 'contract'], contractData: makeContractButton() }, + ); + + const finding = report.findings.find(f => f.field === 'variant:destructive'); + expect(finding).toBeDefined(); + expect(finding!.type).toBe('missing-in-code'); + expect(finding!.severity).toBe('warn'); + expect(finding!.confidence).toBe('high'); + expect(finding!.contractValue).toBe('destructive'); + expect(report.severity).toBe('warn'); + }); + + it('flags a contract prop missing from code as missing-in-code (warn)', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', null, null, + makeCodeButton({ props: ['variant', 'size'] }), // no 'asChild' + { queriedSurfaces: ['code', 'contract'], contractData: makeContractButton() }, + ); + + const finding = report.findings.find(f => f.field === 'prop:asChild'); + expect(finding).toBeDefined(); + expect(finding!.type).toBe('missing-in-code'); + expect(finding!.severity).toBe('warn'); + }); + + it('flags a declared component absent from code', () => { + const report = analyzeCrossSurfaceDrift('Button', null, null, null, { + queriedSurfaces: ['code', 'contract'], + contractData: makeContractButton(), + }); + + const finding = report.findings.find(f => f.field === 'component'); + expect(finding).toBeDefined(); + expect(finding!.type).toBe('missing-in-code'); + expect(finding!.contractValue).toBe('Button'); + }); + + it('compares prop names case-insensitively', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', null, null, + makeCodeButton({ props: ['Variant', 'SIZE', 'aschild'] }), + { queriedSurfaces: ['code', 'contract'], contractData: makeContractButton() }, + ); + + expect(report.findings.filter(f => f.field.startsWith('prop:'))).toEqual([]); + }); +}); + +// ============================================================================= +// CODE → CONTRACT STALENESS (snapshot out of date) +// ============================================================================= + +describe('contract surface — staleness signals', () => { + it('tags code props absent from the contract as contract-staleness (info)', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', null, null, + makeCodeButton({ props: ['variant', 'size', 'asChild', 'loading'] }), + { queriedSurfaces: ['code', 'contract'], contractData: makeContractButton() }, + ); + + const finding = report.findings.find(f => f.field === 'contract-staleness:prop:loading'); + expect(finding).toBeDefined(); + expect(finding!.type).toBe('missing-in-contract'); + expect(finding!.severity).toBe('info'); + expect(finding!.codeValue).toBe('loading'); + expect(finding!.message).toContain('may be out of date'); + }); + + it('tags code variants absent from the contract as contract-staleness (info)', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', null, null, + makeCodeButton({ variants: [...makeCodeButton().variants, 'outline'] }), + { queriedSurfaces: ['code', 'contract'], contractData: makeContractButton() }, + ); + + const finding = report.findings.find(f => f.field === 'contract-staleness:variant:outline'); + expect(finding).toBeDefined(); + expect(finding!.type).toBe('missing-in-contract'); + expect(finding!.severity).toBe('info'); + }); + + it('reports a component undeclared by the contract as missing-in-contract (info)', () => { + const report = analyzeCrossSurfaceDrift('Tooltip', null, null, makeCodeButton(), { + queriedSurfaces: ['code', 'contract'], + contractData: null, // queried, not found in contract + }); + + const finding = report.findings.find(f => f.type === 'missing-in-contract'); + expect(finding).toBeDefined(); + expect(finding!.field).toBe('component'); + expect(finding!.severity).toBe('info'); + expect(report.surfaces.contract).toBeDefined(); // empty snapshot recorded + expect(report.surfaces.contract!.props).toEqual([]); + }); +}); + +// ============================================================================= +// CONTRACT ↔ FIGMA +// ============================================================================= + +describe('contract surface — Figma comparison', () => { + const figmaButton = { + name: 'Button', + nodeId: 'figma:1:100', + type: 'component' as const, + properties: {}, + unmappedProperties: [], + componentPropertyDefinitions: { + variant: { type: 'VARIANT' as const, variantOptions: ['default', 'ghost'] }, + }, + }; + + it('flags contract variants missing from Figma (warn)', () => { + const report = analyzeCrossSurfaceDrift('Button', figmaButton, null, null, { + queriedSurfaces: ['figma', 'contract'], + contractData: makeContractButton({ variants: ['default', 'ghost', 'destructive'] }), + }); + + const finding = report.findings.find( + f => f.field === 'variant:destructive' && f.type === 'missing-in-figma', + ); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('warn'); + expect(finding!.contractValue).toBe('destructive'); + }); + + it('flags Figma variants undeclared by the contract (info, no staleness prefix)', () => { + const report = analyzeCrossSurfaceDrift('Button', figmaButton, null, null, { + queriedSurfaces: ['figma', 'contract'], + contractData: makeContractButton({ variants: ['default'] }), + }); + + const finding = report.findings.find( + f => f.field === 'variant:ghost' && f.type === 'missing-in-contract', + ); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('info'); + expect(finding!.field.startsWith('contract-staleness:')).toBe(false); + }); +}); + +// ============================================================================= +// REGRESSION GUARD — no-contract behavior unchanged +// ============================================================================= + +describe('contract surface — no-contract regression guard', () => { + it('produces a report without contract members when contract is not queried', () => { + const report = analyzeCrossSurfaceDrift('Button', null, null, makeCodeButton(), { + queriedSurfaces: ['code'], + }); + + expect(report.surfaces.contract).toBeUndefined(); + expect(report.queriedSurfaces).not.toContain('contract'); + expect(report.findings.some(f => f.type === 'missing-in-contract')).toBe(false); + expect(report.findings.some(f => f.contractValue !== undefined)).toBe(false); + // Shape check: only the three legacy surface keys may appear + expect(Object.keys(report.surfaces).every(k => ['figma', 'storybook', 'code'].includes(k))).toBe(true); + }); + + it('derives contract into queriedSurfaces only when contractData is supplied', () => { + // Backward-compat derivation path (no explicit queriedSurfaces) + const without = analyzeCrossSurfaceDrift('Button', null, null, makeCodeButton()); + expect(without.queriedSurfaces).toEqual(['code']); + + const withContract = analyzeCrossSurfaceDrift('Button', null, null, makeCodeButton(), { + contractData: makeContractButton(), + }); + expect(withContract.queriedSurfaces).toEqual(['code', 'contract']); + }); +}); diff --git a/packages/watcher/src/crossSurfaceDrift/analyze.ts b/packages/watcher/src/crossSurfaceDrift/analyze.ts index 33ffa57..e60058e 100644 --- a/packages/watcher/src/crossSurfaceDrift/analyze.ts +++ b/packages/watcher/src/crossSurfaceDrift/analyze.ts @@ -17,13 +17,16 @@ import type { DriftFinding, DriftSeverity, DriftConfidence, + DriftSurfaceId, SurfaceSnapshot, SurfaceProp, DriftAnalysisOptions, NormalizationMetadata, + ContractComponentData, } from '@aesthetic-function/shared/crossSurfaceDrift'; import type { StorybookComponentMeta, StorybookProp } from '@aesthetic-function/shared/storybookAdapter'; import type { NormalizedDesignComponent } from '../designAdapter/types.js'; +import { CONTRACT_SOURCE_ID } from '../contractSurface/surface.js'; import { normalizeSnapshot, DEFAULT_NORMALIZATION_CONFIG } from './normalize.js'; // ============================================================================= @@ -60,14 +63,17 @@ export function analyzeCrossSurfaceDrift( const findings: DriftFinding[] = []; const now = new Date().toISOString(); + const contractData = options?.contractData ?? null; + // Determine which surfaces were queried. // If caller provides queriedSurfaces, use that. Otherwise derive from // non-null data params (backward compat for direct callers). - const queriedSurfaces: ('figma' | 'storybook' | 'code')[] = + const queriedSurfaces: DriftSurfaceId[] = options?.queriedSurfaces ?? [ ...(figmaData ? ['figma' as const] : []), ...(storybookData ? ['storybook' as const] : []), ...(codeData ? ['code' as const] : []), + ...(contractData ? ['contract' as const] : []), ]; // Build surface snapshots @@ -90,12 +96,17 @@ export function analyzeCrossSurfaceDrift( } else if (queriedSurfaces.includes('code')) { surfaces.code = buildEmptySnapshot('code-ast', componentName); } + if (contractData) { + surfaces.contract = buildContractSnapshot(contractData); + } else if (queriedSurfaces.includes('contract')) { + surfaces.contract = buildEmptySnapshot(CONTRACT_SOURCE_ID, componentName); + } // Normalize snapshots before comparison (Phase 16D) const normConfig = options?.normalizationConfig ?? DEFAULT_NORMALIZATION_CONFIG; const normalization: NormalizationMetadata = { appliedRules: [], excludedProps: [] }; - for (const key of ['figma', 'storybook', 'code'] as const) { + for (const key of ['figma', 'storybook', 'code', 'contract'] as const) { const snap = surfaces[key]; if (!snap) continue; const result = normalizeSnapshot(snap, key, normConfig); @@ -110,6 +121,7 @@ export function analyzeCrossSurfaceDrift( findings.push( ...compareVariantCoverage(surfaces, storybookData, options), ); + findings.push(...compareContractSurface(componentName, surfaces, queriedSurfaces)); // Compute severity (highest finding wins) const severity = computeOverallSeverity(findings); @@ -261,6 +273,18 @@ function buildCodeSnapshot(name: string, data: CodeSurfaceData): SurfaceSnapshot }; } +function buildContractSnapshot(data: ContractComponentData): SurfaceSnapshot { + return { + source: CONTRACT_SOURCE_ID, + componentName: data.name, + // Copy props/variants — normalizeSnapshot mutates snapshots in place and + // the caller's contract data must stay pristine. + props: data.props.map(p => ({ ...p })), + variants: [...data.variants], + lastObserved: new Date().toISOString(), + }; +} + // ============================================================================= // COMPARISON FUNCTIONS // ============================================================================= @@ -276,7 +300,7 @@ function buildCodeSnapshot(name: string, data: CodeSurfaceData): SurfaceSnapshot function compareComponentPresence( componentName: string, surfaces: CrossSurfaceDriftReport['surfaces'], - queriedSurfaces: ('figma' | 'storybook' | 'code')[], + queriedSurfaces: DriftSurfaceId[], ): DriftFinding[] { const findings: DriftFinding[] = []; @@ -519,6 +543,172 @@ function compareVariantCoverage( return findings; } +/** + * Compare the contract surface (dspack) against the live surfaces. + * + * Scope (first slice): component presence, prop inventory vs. code, and + * enum-derived variant coverage vs. code and Figma. The three pre-existing + * comparison functions are untouched — when no contract surface is queried + * this function returns nothing and the report is identical to before. + * + * Direction semantics: + * - Contract declares something a live surface lacks → genuine drift + * (the surface regressed against the declared contract). + * - Code has something the contract lacks → staleness signal (the committed + * dspack snapshot may be out of date; regenerate with dspack-export). + */ +function compareContractSurface( + componentName: string, + surfaces: CrossSurfaceDriftReport['surfaces'], + queriedSurfaces: DriftSurfaceId[], +): DriftFinding[] { + const findings: DriftFinding[] = []; + + if (!queriedSurfaces.includes('contract')) return findings; + + const contract = surfaces.contract; + const contractHasData = contract != null && + (contract.props.length > 0 || contract.variants.length > 0); + + const codeQueried = queriedSurfaces.includes('code'); + const codeHasData = surfaces.code != null && + (surfaces.code.props.length > 0 || surfaces.code.variants.length > 0); + const figmaHasData = surfaces.figma != null && + (surfaces.figma.props.length > 0 || surfaces.figma.variants.length > 0); + const storybookHasData = surfaces.storybook != null && + (surfaces.storybook.props.length > 0 || surfaces.storybook.variants.length > 0); + + // Presence: component absent from the contract while live surfaces have it. + // Severity info — the contract may simply not document this component yet. + if (!contractHasData && (codeHasData || figmaHasData || storybookHasData)) { + const where = codeHasData ? 'code' : figmaHasData ? 'Figma' : 'Storybook'; + findings.push({ + field: 'component', + type: 'missing-in-contract', + severity: 'info', + message: `Component "${componentName}" exists in ${where} but is not declared in the contract`, + codeValue: codeHasData ? componentName : undefined, + figmaValue: !codeHasData && figmaHasData ? componentName : undefined, + storybookValue: !codeHasData && !figmaHasData && storybookHasData ? componentName : undefined, + confidence: 'high', + }); + return findings; + } + + if (!contractHasData || !contract) return findings; + + // Presence: contract declares the component but code doesn't have it. + if (codeQueried && !codeHasData) { + findings.push({ + field: 'component', + type: 'missing-in-code', + severity: 'warn', + message: `Component "${componentName}" is declared in the contract but not found in code`, + contractValue: componentName, + confidence: 'high', + }); + } + + // Prop inventory: contract ↔ code, both directions. + if (codeHasData && surfaces.code) { + const codeProps = new Set(surfaces.code.props.map(p => p.name.toLowerCase())); + const contractProps = new Set(contract.props.map(p => p.name.toLowerCase())); + + for (const prop of contract.props) { + if (!codeProps.has(prop.name.toLowerCase())) { + findings.push({ + field: `prop:${prop.name}`, + type: 'missing-in-code', + severity: 'warn', + message: `Prop "${prop.name}" is declared in the contract but not found in code`, + contractValue: prop.name, + confidence: 'high', + }); + } + } + + for (const prop of surfaces.code.props) { + if (!contractProps.has(prop.name.toLowerCase())) { + findings.push({ + field: `contract-staleness:prop:${prop.name}`, + type: 'missing-in-contract', + severity: 'info', + message: `Prop "${prop.name}" exists in code but not in the contract — the dspack snapshot may be out of date`, + codeValue: prop.name, + confidence: 'high', + }); + } + } + } + + // Variant coverage: contract ↔ code. Contract variants come from constrained + // enums, so confidence is high in both directions. + if (codeHasData && surfaces.code) { + const codeVariants = new Set(surfaces.code.variants.map(v => v.toLowerCase())); + const contractVariants = new Set(contract.variants.map(v => v.toLowerCase())); + + for (const variant of contract.variants) { + if (!codeVariants.has(variant.toLowerCase())) { + findings.push({ + field: `variant:${variant}`, + type: 'missing-in-code', + severity: 'warn', + message: `Contract declares variant "${variant}" but it was not found in code`, + contractValue: variant, + confidence: 'high', + }); + } + } + + for (const variant of surfaces.code.variants) { + if (!contractVariants.has(variant.toLowerCase())) { + findings.push({ + field: `contract-staleness:variant:${variant}`, + type: 'missing-in-contract', + severity: 'info', + message: `Code has variant "${variant}" that the contract does not declare — the dspack snapshot may be out of date`, + codeValue: variant, + confidence: 'high', + }); + } + } + } + + // Variant coverage: contract ↔ Figma. + if (figmaHasData && surfaces.figma) { + const figmaVariants = new Set(surfaces.figma.variants.map(v => v.toLowerCase())); + const contractVariants = new Set(contract.variants.map(v => v.toLowerCase())); + + for (const variant of contract.variants) { + if (!figmaVariants.has(variant.toLowerCase())) { + findings.push({ + field: `variant:${variant}`, + type: 'missing-in-figma', + severity: 'warn', + message: `Contract declares variant "${variant}" but Figma is missing this variant`, + contractValue: variant, + confidence: 'high', + }); + } + } + + for (const variant of surfaces.figma.variants) { + if (!contractVariants.has(variant.toLowerCase())) { + findings.push({ + field: `variant:${variant}`, + type: 'missing-in-contract', + severity: 'info', + message: `Figma has variant "${variant}" that the contract does not declare`, + figmaValue: variant, + confidence: 'high', + }); + } + } + } + + return findings; +} + // ============================================================================= // CORROBORATION HELPERS // ============================================================================= diff --git a/packages/watcher/src/crossSurfaceDrift/cliCrossSurfaceDrift.ts b/packages/watcher/src/crossSurfaceDrift/cliCrossSurfaceDrift.ts index a57a597..2dd0ceb 100644 --- a/packages/watcher/src/crossSurfaceDrift/cliCrossSurfaceDrift.ts +++ b/packages/watcher/src/crossSurfaceDrift/cliCrossSurfaceDrift.ts @@ -14,13 +14,15 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import type { CrossSurfaceDriftReport } from '@aesthetic-function/shared/crossSurfaceDrift'; +import type { CrossSurfaceDriftReport, DriftSurfaceId } from '@aesthetic-function/shared/crossSurfaceDrift'; import type { StorybookMCPConfig } from '@aesthetic-function/shared/storybookAdapter'; import { loadAfConfig } from '@aesthetic-function/shared/configLoader'; import { StorybookMCPAdapter } from '../designAdapter/storybookAdapter.js'; import { getAvailableAdapter, registerDesignAdapter } from '../designAdapter/registry.js'; import { FigmaConsoleMCPAdapter } from '../designAdapter/figmaConsoleMCPAdapter.js'; import { normalizeDesignComponent } from '../designAdapter/normalize.js'; +import { loadContract, findContractComponent, listContractComponentNames } from '../contractSurface/index.js'; +import type { DspackDocument } from '../contractSurface/index.js'; import { analyzeCrossSurfaceDrift } from './analyze.js'; import type { CodeSurfaceData } from './analyze.js'; @@ -177,6 +179,7 @@ interface CliOptions { json: boolean; verbose: boolean; includeUncorroborated: boolean; + dspackPath?: string; } function parseArgs(args: string[]): CliOptions { @@ -194,6 +197,14 @@ function parseArgs(args: string[]): CliOptions { options.verbose = true; } else if (arg === '--include-uncorroborated') { options.includeUncorroborated = true; + } else if (arg === '--dspack') { + const next = args[i + 1]; + if (!next || next.startsWith('-')) { + console.error('--dspack requires a file path argument'); + process.exit(2); + } + options.dspackPath = next; + i++; } else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); @@ -205,13 +216,38 @@ function parseArgs(args: string[]): CliOptions { return options; } +/** + * Resolve the dspack contract path from CLI flag (first priority) or + * af.config.json `contract.dspackPath`. Relative paths are tried against + * the current working directory, then the repo root (matching how + * watchPaths resolve). Returns null when no contract is configured. + */ +function resolveContractPath( + options: CliOptions, + configPath: string | null, + repoRoot: string, +): string | null { + const raw = options.dspackPath ?? configPath; + if (!raw) return null; + + const candidates = [resolve(process.cwd(), raw), resolve(repoRoot, raw)]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + + console.error(`dspack contract file not found: ${raw}`); + console.error(` Tried: ${[...new Set(candidates)].join(', ')}`); + return null; +} + function printHelp(): void { console.log(`af design drift — Cross-surface drift analysis (read-only) Usage: af design drift [component-name] [options] -Compares component metadata across Figma, Storybook, and code to detect -parity gaps. This is a read-only analysis — it does not modify reconciliation. +Compares component metadata across Figma, Storybook, code, and an optional +dspack contract file to detect parity gaps. This is a read-only analysis — +it does not modify reconciliation, and the contract file is never written. Arguments: component-name Component to analyze (optional; analyzes all if omitted) @@ -220,17 +256,38 @@ Options: --json Output JSON format --verbose, -v Verbose output with trace details --include-uncorroborated Include uncorroborated story-derived variants + --dspack dspack contract file to compare against + (or set contract.dspackPath in af.config.json) -h, --help Show this help Examples: af design drift Button af design drift Card --json + af design drift Button --dspack ./my-system.dspack.json af design drift --include-uncorroborated`); } export async function main(args: string[]): Promise { const options = parseArgs(args); const config = loadAfConfig(); + const repoRootForContract = findRepoRoot(); + + // Load the dspack contract surface, if configured (read-only) + let contractDoc: DspackDocument | null = null; + let contractPath: string | null = null; + if (options.dspackPath || config.contract.dspackPath) { + contractPath = resolveContractPath(options, config.contract.dspackPath, repoRootForContract); + if (!contractPath) { + return 2; + } + try { + contractDoc = loadContract(contractPath); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(msg); + return 2; + } + } // Register Figma adapter if credentials are available if (process.env.FIGMA_ACCESS_TOKEN && process.env.FIGMA_FILE_KEY) { @@ -258,7 +315,7 @@ export async function main(args: string[]): Promise { ? `available (${storybookAdapter.operatingMode})` : 'unavailable'; - if (!storybookAvailable && !figmaAdapter) { + if (!storybookAvailable && !figmaAdapter && !contractDoc) { console.log(`\u2717 Cross-Surface Drift Analysis: aborted\n`); console.log(`Figma adapter: unavailable`); console.log(`Storybook adapter: unavailable`); @@ -267,22 +324,28 @@ export async function main(args: string[]): Promise { console.log(` \u2192 Start it with: pnpm dev:storybook`); console.log(` \u2192 Or configure a different URL in af.config.json \u2192 storybook.url`); } + console.log(` \u2192 Or compare against a dspack contract: --dspack `); console.log(`\nCannot run drift analysis without at least 2 surfaces. Exiting.`); return 2; } - if (!storybookAvailable) { + if (!storybookAvailable && !figmaAdapter && contractDoc) { + console.log(`Figma adapter: unavailable`); + console.log(`Storybook adapter: unavailable`); + console.log(`Continuing with Contract + Code only.\n`); + } else if (!storybookAvailable) { console.log(`Storybook adapter: unavailable`); if (storybookAdapter.unavailableReason) { console.log(` \u2192 ${storybookAdapter.unavailableReason}`); console.log(` \u2192 Start it with: pnpm dev:storybook`); } - console.log(`Continuing with Figma + Code only.\n`); + console.log(`Continuing with ${contractDoc ? 'Figma + Code + Contract' : 'Figma + Code'} only.\n`); } if (options.verbose) { console.log(`Figma adapter: ${figmaStatus}`); - console.log(`Storybook adapter: ${storybookStatus}\n`); + console.log(`Storybook adapter: ${storybookStatus}`); + console.log(`Contract: ${contractDoc ? `loaded (${contractPath})` : 'not configured'}\n`); } } @@ -295,12 +358,14 @@ export async function main(args: string[]): Promise { options.componentName, figmaAdapter, storybookAvailable ? storybookAdapter : null, + contractDoc, options, config, ); reports.push(report); } else { - // Analyze all components from Storybook inventory + // Analyze all components. Inventory comes from Storybook when available, + // otherwise from the contract's declared components. if (storybookAvailable) { const inventory = await storybookAdapter.getInventory(); for (const component of inventory.data.components) { @@ -308,6 +373,19 @@ export async function main(args: string[]): Promise { component.name, figmaAdapter, storybookAdapter, + contractDoc, + options, + config, + ); + reports.push(report); + } + } else if (contractDoc) { + for (const name of listContractComponentNames(contractDoc)) { + const report = await analyzeComponent( + name, + figmaAdapter, + null, + contractDoc, options, config, ); @@ -338,11 +416,12 @@ async function analyzeComponent( componentName: string, figmaAdapter: Awaited>, storybookAdapter: StorybookMCPAdapter | null, + contractDoc: DspackDocument | null, options: CliOptions, afConfig: ReturnType, ): Promise { // Track which surfaces we actually query - const queriedSurfaces: ('figma' | 'storybook' | 'code')[] = []; + const queriedSurfaces: DriftSurfaceId[] = []; // Fetch Figma data let figmaData = null; @@ -381,12 +460,19 @@ async function analyzeComponent( repoRoot, ); + // Look up the component in the dspack contract (read-only, in-memory) + let contractData = null; + if (contractDoc) { + queriedSurfaces.push('contract'); + contractData = findContractComponent(contractDoc, componentName); + } + return analyzeCrossSurfaceDrift( componentName, figmaData, storybookData, codeData, - { includeUncorroborated: options.includeUncorroborated, queriedSurfaces }, + { includeUncorroborated: options.includeUncorroborated, queriedSurfaces, contractData }, ); } @@ -414,6 +500,11 @@ function formatReport(report: CrossSurfaceDriftReport, verbose: boolean): void { else if (report.surfaces.code && (report.surfaces.code.props.length > 0 || report.surfaces.code.variants.length > 0)) surfaces.push('Code (AST) \u2713'); else surfaces.push('Code (AST) \u2717'); + if (queried.has('contract')) { + if (report.surfaces.contract && (report.surfaces.contract.props.length > 0 || report.surfaces.contract.variants.length > 0)) surfaces.push('Contract \u2713'); + else surfaces.push('Contract \u2717'); + } + console.log(`Surfaces: ${surfaces.join(' ')}`); console.log(''); @@ -430,6 +521,12 @@ function formatReport(report: CrossSurfaceDriftReport, verbose: boolean): void { console.log(` \u2192 Story ref: ${finding.storyRef}`); } } + + // One remediation hint per report when code-vs-contract staleness was found + if (report.findings.some(f => f.field.startsWith('contract-staleness:'))) { + console.log(''); + console.log(' \u2192 Contract may be stale. Regenerate the dspack snapshot with: dspack-export generate'); + } } console.log(''); diff --git a/packages/watcher/src/crossSurfaceDrift/normalize.ts b/packages/watcher/src/crossSurfaceDrift/normalize.ts index e83969f..6bcc461 100644 --- a/packages/watcher/src/crossSurfaceDrift/normalize.ts +++ b/packages/watcher/src/crossSurfaceDrift/normalize.ts @@ -18,6 +18,7 @@ import type { SurfaceSnapshot, SurfaceProp, + DriftSurfaceId, NormalizationConfig, NormalizationMetadata, } from '@aesthetic-function/shared/crossSurfaceDrift'; @@ -78,7 +79,7 @@ export interface NormalizeSnapshotResult { */ export function normalizeSnapshot( snapshot: SurfaceSnapshot, - surface: 'figma' | 'storybook' | 'code', + surface: DriftSurfaceId, config: NormalizationConfig = DEFAULT_NORMALIZATION_CONFIG, ): NormalizeSnapshotResult { const appliedRules: NormalizeSnapshotResult['appliedRules'] = []; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fa5cef..c3ba6e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.3.6) + ajv: + specifier: ^8.17.0 + version: 8.18.0 chokidar: specifier: ^3.5.3 version: 3.6.0