From 69f94dcf5b3ec4a3b83a9177f8281f30ab61ba73 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Tue, 17 Feb 2026 20:47:39 -0800 Subject: [PATCH] feat: complete warn-first web icon policy flow --- API.md | 6 +- README.md | 4 +- docs/contract-baseline.md | 18 ++ .../web-icon-contract-increment-status.md | 21 +++ .../interfacectl-cli/dist/commands/compile.js | 1 + .../dist/commands/generate-contract.d.ts.map | 2 +- .../dist/commands/generate-contract.js | 11 +- .../dist/utils/compare.d.ts.map | 2 +- .../interfacectl-cli/dist/utils/compare.js | 19 ++ .../dist/utils/icon-policy-seeding.d.ts | 16 ++ .../dist/utils/icon-policy-seeding.d.ts.map | 1 + .../dist/utils/icon-policy-seeding.js | 56 ++++++ .../dist/utils/normalize.d.ts.map | 2 +- .../interfacectl-cli/dist/utils/normalize.js | 25 +++ .../interfacectl-cli/src/commands/compile.ts | 1 + .../src/commands/generate-contract.ts | 11 +- .../interfacectl-cli/src/utils/compare.ts | 26 +++ .../src/utils/icon-policy-seeding.ts | 91 +++++++++ .../interfacectl-cli/src/utils/normalize.ts | 36 ++++ .../interfacectl-cli/test/compare.test.mjs | 107 +++++++++++ .../interfacectl-cli/test/compile.test.mjs | 60 ++++++ .../test/icon-policy-seeding.test.mjs | 139 ++++++++++++++ .../test/icon-policy.test.mjs | 173 ++++++++++++++++++ .../interfacectl-cli/test/normalize.test.mjs | 10 + .../test/static-analysis.test.mjs | 124 +++++++++++++ .../test/violation-classifier.test.mjs | 1 + .../test/contract-structure.test.mjs | 94 ++++++++++ .../test/evaluate-contract.test.mjs | 88 +++++++++ 28 files changed, 1134 insertions(+), 11 deletions(-) create mode 100644 docs/plans/web-icon-contract-increment-status.md create mode 100644 packages/interfacectl-cli/dist/utils/icon-policy-seeding.d.ts create mode 100644 packages/interfacectl-cli/dist/utils/icon-policy-seeding.d.ts.map create mode 100644 packages/interfacectl-cli/dist/utils/icon-policy-seeding.js create mode 100644 packages/interfacectl-cli/src/utils/icon-policy-seeding.ts create mode 100644 packages/interfacectl-cli/test/icon-policy-seeding.test.mjs create mode 100644 packages/interfacectl-cli/test/icon-policy.test.mjs create mode 100644 packages/interfacectl-validator/test/contract-structure.test.mjs diff --git a/API.md b/API.md index 4a8ad20..a5db3ef 100644 --- a/API.md +++ b/API.md @@ -23,7 +23,7 @@ interfacectl validate [options] Performs comprehensive validation of surface implementations against an interface contract. This includes: - Contract structure validation against schema - Surface descriptor collection from codebase -- Compliance checking for fonts, colors, layout, motion, and sections +- Compliance checking for fonts, colors, icon sources, layout, motion, and sections - Generation of structured validation reports **Options:** @@ -51,7 +51,7 @@ Performs comprehensive validation of surface implementations against an interfac **v2 (new contract, opt-in via `--exit-codes v2` or `INTERFACECTL_EXIT_CODES=v2`):** - `0`: All surfaces comply with the contract - `10`: E0 - Artifact invalid (config/contract load failures, schema parse errors, internal errors) -- `20`: E1 - Token policy violation (font/color/motion not allowed) +- `20`: E1 - Token policy violation (font/color/icon/motion not allowed) - `30`: E2 - Interface contract violation (layout/section violations) **Note:** v1 internal errors use exit code `2`. v2 unifies all E0 conditions to exit code `10`. Use `--exit-codes v2` to opt into the new exit code contract. A deprecation warning is printed in v1 mode when violations occur. @@ -64,6 +64,7 @@ Performs comprehensive validation of surface implementations against an interfac - `section.unexpected`: Unknown section in surface - `font.disallowed`: Font not allowed by contract - `color.disallowed`: Color not allowed by contract +- `icon.source-disallowed`: Icon source library not allowed by contract - `layout.width-exceeded`: Layout width exceeds contract maximum - `layout.width-undetermined`: Layout width cannot be determined - `layout.container-missing`: Required container missing from layout @@ -421,6 +422,7 @@ interfacectl generate-contract --app-root --surface [--out ] [ **Description:** - Scans the app directory for routes (app router), layout shell (`app/layout.tsx` or `app/(shell)/layout.tsx`), `@surfaces/ui` component imports, and `/auth` routes. +- Seeds `color.allowedValues` from observed descriptors and seeds `surfaces[*].icons` for web surfaces with `policy: "warn"` plus discovered icon source libraries. - Writes a contract JSON (default `contracts/generated/.contract.json`) and an extraction report (default `contracts/generated/.extraction.json`). - Validates the generated contract against the schema before writing. Running the command twice produces identical output (stable key order, no timestamps). diff --git a/README.md b/README.md index c717470..5098eb0 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The CLI provides four main commands: ### `validate` -Validates configured surfaces against a shared interface contract. Performs comprehensive validation including contract structure validation, surface descriptor collection, and compliance checking for fonts, colors, layout, motion, and sections. +Validates configured surfaces against a shared interface contract. Performs comprehensive validation including contract structure validation, surface descriptor collection, and compliance checking for fonts, colors, icon sources, layout, motion, and sections. ```bash interfacectl validate [options] @@ -86,7 +86,7 @@ interfacectl compile --contract --out Extracts a **deterministic contract artifact** from a Next.js app by analyzing app code and config. This is **contract extraction only** — no enforcement, no network calls. -**Phase 0 scope:** Routes (app router), layout shell presence (`app/layout.tsx` or `app/(shell)/layout.tsx`), design system usage (`@surfaces/ui` component imports), and auth posture (`/auth` routes). Values that cannot be extracted safely are omitted and reported as warnings in the extraction report. +**Phase 0 scope:** Routes (app router), layout shell presence (`app/layout.tsx` or `app/(shell)/layout.tsx`), design system usage (`@surfaces/ui` component imports), and auth posture (`/auth` routes). The command also seeds `color.allowedValues` and web-surface `icons.allowedSources` from observed descriptors (default `icons.policy: "warn"`). Values that cannot be extracted safely are omitted and reported as warnings in the extraction report. **Phase 0 guardrails:** No Babel or heavy AST frameworks. Extraction uses filesystem walks and regex for determinism, debuggability, and minimal dependency surface. See [docs/plans/phase-0-extraction-guardrails.md](docs/plans/phase-0-extraction-guardrails.md) for what we extract, what we omit, and when AST tooling may be added in a later phase. diff --git a/docs/contract-baseline.md b/docs/contract-baseline.md index 18bcd42..022a9fc 100644 --- a/docs/contract-baseline.md +++ b/docs/contract-baseline.md @@ -29,6 +29,11 @@ Optional surface layout fields: - `layout.requiredContainers` (array of strings) - `layout.pageFrame` (when present, enforced by validator) +Optional per-surface icon policy (web surfaces): + +- `icons.policy`: one of `"off"`, `"warn"`, `"strict"`. +- `icons.allowedSources`: array of allowed icon source libraries (for example `lucide-react`, `@heroicons/react/24/outline`). + ## Section required fields Each item in `sections` must include: @@ -55,6 +60,19 @@ Violation contract: - Validator violation type: `color-not-allowed` - CLI finding code: `color.disallowed` +## Web icon source policy (optional) + +When `surfaces[*].icons` is present for a web surface: + +- `off`: skip icon source enforcement. +- `warn`: emit warning findings for disallowed icon sources. +- `strict`: emit error findings for disallowed icon sources. + +Violation contract: + +- Validator violation type: `icon-source-not-allowed` +- CLI finding code: `icon.source-disallowed` + ## Shell ownership boundary (optional) When a shell owns global primitives: diff --git a/docs/plans/web-icon-contract-increment-status.md b/docs/plans/web-icon-contract-increment-status.md new file mode 100644 index 0000000..e6a9630 --- /dev/null +++ b/docs/plans/web-icon-contract-increment-status.md @@ -0,0 +1,21 @@ +# Web Icon Contract Increment Status + +## Status +This increment is feature complete for warn-first rollout. + +## Scope in this increment +- Per-surface web icon source policy definition for web surfaces. +- End-to-end warn-mode implementation across extraction, validation, diff, normalization, and compile. +- Integration framing for downstream consumers at CI/CD time. + +## Completed in this increment +- `generate-contract` seeds web-surface `icons` policy from observed icon source imports. +- `validate` emits `icon.source-disallowed` findings with policy-driven severity (`warn` non-blocking, `strict` blocking). +- `diff` includes icon-source drift entries at `surfaces//icons.allowedSources`. +- Normalize/compile paths preserve deterministic icon policy payloads. +- Core docs and tests are aligned to the icon policy contract. + +## Deferred intentionally +- Strict-mode rollout across canonical consumer contracts. +- Runtime (edge) icon-source enforcement. +- Governance/workflow automation beyond contract validation signals. diff --git a/packages/interfacectl-cli/dist/commands/compile.js b/packages/interfacectl-cli/dist/commands/compile.js index b910289..25d7522 100644 --- a/packages/interfacectl-cli/dist/commands/compile.js +++ b/packages/interfacectl-cli/dist/commands/compile.js @@ -154,5 +154,6 @@ function surfaceToBundlePayload(surface) { requiredSections: surface.requiredSections, allowedFonts: surface.allowedFonts, layout: surface.layout, + ...(surface.icons ? { icons: surface.icons } : {}), }; } diff --git a/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map b/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map index 742c749..a6485c0 100644 --- a/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"generate-contract.d.ts","sourceRoot":"","sources":["../../src/commands/generate-contract.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,MAAM,CAAC,CA2EjB"} \ No newline at end of file +{"version":3,"file":"generate-contract.d.ts","sourceRoot":"","sources":["../../src/commands/generate-contract.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,MAAM,CAAC,CAiFjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/generate-contract.js b/packages/interfacectl-cli/dist/commands/generate-contract.js index 029646c..7b123eb 100644 --- a/packages/interfacectl-cli/dist/commands/generate-contract.js +++ b/packages/interfacectl-cli/dist/commands/generate-contract.js @@ -3,6 +3,7 @@ import { writeFile, mkdir, readFile } from "node:fs/promises"; import { extractContractFromNextApp, stableStringify, } from "@surfaces/interfacectl-extractor"; import { getBundledContractSchema, validateContractStructure, } from "@surfaces/interfacectl-validator"; import { seedColorPolicyFromObservedDescriptors } from "../utils/color-policy-seeding.js"; +import { seedIconPolicyFromObservedDescriptors } from "../utils/icon-policy-seeding.js"; const DEFAULT_OUT_DIR = "contracts/generated"; export async function runGenerateContractCommand(options) { const cwd = process.cwd(); @@ -27,10 +28,16 @@ export async function runGenerateContractCommand(options) { surfaceId, contract: extractedContract, }); - const contract = seeded.contract; + const iconSeeded = await seedIconPolicyFromObservedDescriptors({ + workspaceRoot: cwd, + appRoot, + surfaceId, + contract: seeded.contract, + }); + const contract = iconSeeded.contract; const reportWithSeedWarnings = { ...report, - warnings: [...report.warnings, ...seeded.warnings], + warnings: [...report.warnings, ...seeded.warnings, ...iconSeeded.warnings], }; let schema; if (options.schemaPath) { diff --git a/packages/interfacectl-cli/dist/utils/compare.d.ts.map b/packages/interfacectl-cli/dist/utils/compare.d.ts.map index 6d73727..a8626f2 100644 --- a/packages/interfacectl-cli/dist/utils/compare.d.ts.map +++ b/packages/interfacectl-cli/dist/utils/compare.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"compare.d.ts","sourceRoot":"","sources":["../../src/utils/compare.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAIV,SAAS,EACT,cAAc,EAEf,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAE/E;;GAEG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAKR;AA8CD;;GAEG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,OAAO,EACpB,WAAW,EAAE,OAAO,GACnB,cAAc,CAgBhB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,MAAM,EAAE,EAClB,QAAQ,EAAE,MAAM,EAAE,EAClB,SAAS,GAAE,MAAY,GACtB,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CA+BzD;AAiDD;;GAEG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,kBAAkB,EAC5B,UAAU,EAAE,oBAAoB,EAChC,SAAS,EAAE,MAAM,GAChB,SAAS,EAAE,CAuKb"} \ No newline at end of file +{"version":3,"file":"compare.d.ts","sourceRoot":"","sources":["../../src/utils/compare.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAIV,SAAS,EACT,cAAc,EAEf,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAE/E;;GAEG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAKR;AA8CD;;GAEG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,OAAO,EACpB,WAAW,EAAE,OAAO,GACnB,cAAc,CAgBhB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,MAAM,EAAE,EAClB,QAAQ,EAAE,MAAM,EAAE,EAClB,SAAS,GAAE,MAAY,GACtB,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CA+BzD;AAiDD;;GAEG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,kBAAkB,EAC5B,UAAU,EAAE,oBAAoB,EAChC,SAAS,EAAE,MAAM,GAChB,SAAS,EAAE,CAiMb"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/compare.js b/packages/interfacectl-cli/dist/utils/compare.js index de0236c..7fb4dea 100644 --- a/packages/interfacectl-cli/dist/utils/compare.js +++ b/packages/interfacectl-cli/dist/utils/compare.js @@ -209,6 +209,25 @@ export function compareContractToDescriptor(contract, descriptor, surfaceId) { }); } } + // Compare icon source policy for web surfaces + const iconPolicy = surface.icons; + if (surface.type === "web" && iconPolicy && iconPolicy.policy !== "off") { + const iconSeverity = iconPolicy.policy === "warn" ? "warning" : "error"; + const allowedSources = new Set(iconPolicy.allowedSources); + const observedSources = new Set((desc.icons ?? []).map((icon) => icon.value.trim()).filter(Boolean)); + for (const observedSource of [...observedSources].sort((a, b) => a.localeCompare(b))) { + if (!allowedSources.has(observedSource)) { + entries.push({ + surfaceId, + type: "added", + path: buildDiffPath(`surfaces/${surfaceId}`, "icons.allowedSources"), + observedValue: observedSource, + severity: iconSeverity, + rule: `contract.surfaces[${surfaceId}].icons.allowedSources`, + }); + } + } + } // Compare layout maxContentWidth const contractWidth = surface.layout.maxContentWidth; const observedWidth = desc.layout.maxContentWidth; diff --git a/packages/interfacectl-cli/dist/utils/icon-policy-seeding.d.ts b/packages/interfacectl-cli/dist/utils/icon-policy-seeding.d.ts new file mode 100644 index 0000000..440a86f --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/icon-policy-seeding.d.ts @@ -0,0 +1,16 @@ +import type { InterfaceContract } from "@surfaces/interfacectl-validator"; +export interface IconPolicySeedingInput { + workspaceRoot: string; + appRoot: string; + surfaceId: string; + contract: InterfaceContract; +} +export interface IconPolicySeedingResult { + contract: InterfaceContract; + warnings: Array<{ + code: string; + message: string; + }>; +} +export declare function seedIconPolicyFromObservedDescriptors(input: IconPolicySeedingInput): Promise; +//# sourceMappingURL=icon-policy-seeding.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/icon-policy-seeding.d.ts.map b/packages/interfacectl-cli/dist/utils/icon-policy-seeding.d.ts.map new file mode 100644 index 0000000..eb28f36 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/icon-policy-seeding.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"icon-policy-seeding.d.ts","sourceRoot":"","sources":["../../src/utils/icon-policy-seeding.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AAG1E,MAAM,WAAW,sBAAsB;IACrC,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,iBAAiB,CAAC;CAC7B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACpD;AAQD,wBAAsB,qCAAqC,CACzD,KAAK,EAAE,sBAAsB,GAC5B,OAAO,CAAC,uBAAuB,CAAC,CAmElC"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/icon-policy-seeding.js b/packages/interfacectl-cli/dist/utils/icon-policy-seeding.js new file mode 100644 index 0000000..8af906b --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/icon-policy-seeding.js @@ -0,0 +1,56 @@ +import { collectSurfaceDescriptors } from "../descriptors/static-analysis.js"; +function normalizeIconSources(values) { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)); +} +export async function seedIconPolicyFromObservedDescriptors(input) { + const warnings = []; + const surfaceRootMap = new Map([[input.surfaceId, input.appRoot]]); + const descriptorResult = await collectSurfaceDescriptors({ + workspaceRoot: input.workspaceRoot, + contract: input.contract, + surfaceFilters: new Set([input.surfaceId]), + surfaceRootMap, + }); + for (const warning of descriptorResult.warnings) { + warnings.push({ + code: `icon-seed.${warning.code}`, + message: `Descriptor warning during icon seed: ${warning.message}`, + }); + } + for (const error of descriptorResult.errors) { + warnings.push({ + code: `icon-seed.${error.code}`, + message: `Descriptor error during icon seed: ${error.message}`, + }); + } + const targetSurface = input.contract.surfaces.find((surface) => surface.id === input.surfaceId); + if (!targetSurface || targetSurface.type !== "web") { + return { + contract: input.contract, + warnings, + }; + } + const descriptor = descriptorResult.descriptors.find((entry) => entry.surfaceId === input.surfaceId); + const discoveredSources = normalizeIconSources(descriptor?.icons?.map((icon) => icon.value) ?? []); + if (discoveredSources.length === 0) { + warnings.push({ + code: "icon-seed.none-detected", + message: "Icon source policy was not seeded from extraction because no icon sources were detected.", + }); + } + return { + contract: { + ...input.contract, + surfaces: input.contract.surfaces.map((surface) => surface.id === input.surfaceId && surface.type === "web" + ? { + ...surface, + icons: { + policy: "warn", + allowedSources: discoveredSources, + }, + } + : surface), + }, + warnings, + }; +} diff --git a/packages/interfacectl-cli/dist/utils/normalize.d.ts.map b/packages/interfacectl-cli/dist/utils/normalize.d.ts.map index d8284ac..2388350 100644 --- a/packages/interfacectl-cli/dist/utils/normalize.d.ts.map +++ b/packages/interfacectl-cli/dist/utils/normalize.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../../src/utils/normalize.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EAEjB,iBAAiB,EAIlB,MAAM,kCAAkC,CAAC;AAG1C,MAAM,WAAW,qBAAqB;IACpC,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,QAAQ,EAAE,qBAAqB,CAAC;CACjC;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,iBAAiB,CAAC;IAC9B,QAAQ,EAAE,qBAAqB,CAAC;CACjC;AA4BD;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,CAclD;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,EACZ,UAAU,SAAK,EACf,aAAa,GAAE,MAAM,EAAO,GAC3B;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,aAAa,EAAE,MAAM,EAAE,CAAA;CAAE,CA0C9C;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,iBAAiB,GAC1B,kBAAkB,CA8FpB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,iBAAiB,GAC5B,oBAAoB,CA4FtB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAevD"} \ No newline at end of file +{"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../../src/utils/normalize.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EAEjB,iBAAiB,EAIlB,MAAM,kCAAkC,CAAC;AAG1C,MAAM,WAAW,qBAAqB;IACpC,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,QAAQ,EAAE,qBAAqB,CAAC;CACjC;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,iBAAiB,CAAC;IAC9B,QAAQ,EAAE,qBAAqB,CAAC;CACjC;AA6BD;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,CAclD;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,EACZ,UAAU,SAAK,EACf,aAAa,GAAE,MAAM,EAAO,GAC3B;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,aAAa,EAAE,MAAM,EAAE,CAAA;CAAE,CA0C9C;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,iBAAiB,GAC1B,kBAAkB,CA+GpB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,iBAAiB,GAC5B,oBAAoB,CA8GtB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAevD"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/normalize.js b/packages/interfacectl-cli/dist/utils/normalize.js index 778f5fe..60b94d1 100644 --- a/packages/interfacectl-cli/dist/utils/normalize.js +++ b/packages/interfacectl-cli/dist/utils/normalize.js @@ -10,6 +10,7 @@ const SET_LIKE_FIELDS = new Set([ "allowedDurationsMs", "allowedTimingFunctions", "containers", + "allowedSources", ]); /** * Fields to strip from observed artifacts (ephemeral metadata) @@ -124,6 +125,18 @@ export function normalizeContract(contract) { }; } } + if (surface.icons?.allowedSources && + SET_LIKE_FIELDS.has("allowedSources")) { + const original = surface.icons.allowedSources; + const sorted = normalizeSetField(original); + if (JSON.stringify(original) !== JSON.stringify(sorted)) { + metadata.reorderedPaths.push(`surfaces[${surfaceIdx}].icons.allowedSources`); + normalizedSurface.icons = { + ...surface.icons, + allowedSources: sorted, + }; + } + } return normalizedSurface; }), sections: contract.sections.map((section, sectionIdx) => { @@ -208,6 +221,18 @@ export function normalizeDescriptor(descriptor) { } return sorted; })(), + icons: (() => { + if (!strippedDescriptor.icons) { + return undefined; + } + const original = strippedDescriptor.icons.map((icon) => icon.value); + const sorted = normalizeSetField(strippedDescriptor.icons.map((icon) => ({ ...icon }))); + if (JSON.stringify(original) !== + JSON.stringify(sorted.map((icon) => icon.value))) { + metadata.reorderedPaths.push(`descriptors[${descriptor.surfaceId}].icons`); + } + return sorted; + })(), layout: { ...strippedDescriptor.layout, containers: strippedDescriptor.layout.containers diff --git a/packages/interfacectl-cli/src/commands/compile.ts b/packages/interfacectl-cli/src/commands/compile.ts index 10531af..1102020 100644 --- a/packages/interfacectl-cli/src/commands/compile.ts +++ b/packages/interfacectl-cli/src/commands/compile.ts @@ -211,5 +211,6 @@ function surfaceToBundlePayload(surface: ContractSurface): Record icon.value.trim()).filter(Boolean), + ); + + for (const observedSource of [...observedSources].sort((a, b) => + a.localeCompare(b), + )) { + if (!allowedSources.has(observedSource)) { + entries.push({ + surfaceId, + type: "added", + path: buildDiffPath(`surfaces/${surfaceId}`, "icons.allowedSources"), + observedValue: observedSource, + severity: iconSeverity, + rule: `contract.surfaces[${surfaceId}].icons.allowedSources`, + }); + } + } + } + // Compare layout maxContentWidth const contractWidth = surface.layout.maxContentWidth; const observedWidth = desc.layout.maxContentWidth; diff --git a/packages/interfacectl-cli/src/utils/icon-policy-seeding.ts b/packages/interfacectl-cli/src/utils/icon-policy-seeding.ts new file mode 100644 index 0000000..437f059 --- /dev/null +++ b/packages/interfacectl-cli/src/utils/icon-policy-seeding.ts @@ -0,0 +1,91 @@ +import type { InterfaceContract } from "@surfaces/interfacectl-validator"; +import { collectSurfaceDescriptors } from "../descriptors/static-analysis.js"; + +export interface IconPolicySeedingInput { + workspaceRoot: string; + appRoot: string; + surfaceId: string; + contract: InterfaceContract; +} + +export interface IconPolicySeedingResult { + contract: InterfaceContract; + warnings: Array<{ code: string; message: string }>; +} + +function normalizeIconSources(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort( + (a, b) => a.localeCompare(b), + ); +} + +export async function seedIconPolicyFromObservedDescriptors( + input: IconPolicySeedingInput, +): Promise { + const warnings: Array<{ code: string; message: string }> = []; + const surfaceRootMap = new Map([[input.surfaceId, input.appRoot]]); + + const descriptorResult = await collectSurfaceDescriptors({ + workspaceRoot: input.workspaceRoot, + contract: input.contract, + surfaceFilters: new Set([input.surfaceId]), + surfaceRootMap, + }); + + for (const warning of descriptorResult.warnings) { + warnings.push({ + code: `icon-seed.${warning.code}`, + message: `Descriptor warning during icon seed: ${warning.message}`, + }); + } + for (const error of descriptorResult.errors) { + warnings.push({ + code: `icon-seed.${error.code}`, + message: `Descriptor error during icon seed: ${error.message}`, + }); + } + + const targetSurface = input.contract.surfaces.find( + (surface) => surface.id === input.surfaceId, + ); + if (!targetSurface || targetSurface.type !== "web") { + return { + contract: input.contract, + warnings, + }; + } + + const descriptor = descriptorResult.descriptors.find( + (entry) => entry.surfaceId === input.surfaceId, + ); + + const discoveredSources = normalizeIconSources( + descriptor?.icons?.map((icon) => icon.value) ?? [], + ); + + if (discoveredSources.length === 0) { + warnings.push({ + code: "icon-seed.none-detected", + message: + "Icon source policy was not seeded from extraction because no icon sources were detected.", + }); + } + + return { + contract: { + ...input.contract, + surfaces: input.contract.surfaces.map((surface) => + surface.id === input.surfaceId && surface.type === "web" + ? { + ...surface, + icons: { + policy: "warn", + allowedSources: discoveredSources, + }, + } + : surface, + ), + }, + warnings, + }; +} diff --git a/packages/interfacectl-cli/src/utils/normalize.ts b/packages/interfacectl-cli/src/utils/normalize.ts index fc39536..dcf02d3 100644 --- a/packages/interfacectl-cli/src/utils/normalize.ts +++ b/packages/interfacectl-cli/src/utils/normalize.ts @@ -34,6 +34,7 @@ const SET_LIKE_FIELDS = new Set([ "allowedDurationsMs", "allowedTimingFunctions", "containers", + "allowedSources", ]); /** @@ -174,6 +175,23 @@ export function normalizeContract( } } + if ( + surface.icons?.allowedSources && + SET_LIKE_FIELDS.has("allowedSources") + ) { + const original = surface.icons.allowedSources; + const sorted = normalizeSetField(original); + if (JSON.stringify(original) !== JSON.stringify(sorted)) { + metadata.reorderedPaths.push( + `surfaces[${surfaceIdx}].icons.allowedSources`, + ); + normalizedSurface.icons = { + ...surface.icons, + allowedSources: sorted, + }; + } + } + return normalizedSurface; }), sections: contract.sections.map((section, sectionIdx) => { @@ -289,6 +307,24 @@ export function normalizeDescriptor( } return sorted; })(), + icons: (() => { + if (!strippedDescriptor.icons) { + return undefined; + } + const original = strippedDescriptor.icons.map((icon) => icon.value); + const sorted = normalizeSetField( + strippedDescriptor.icons.map((icon) => ({ ...icon })), + ); + if ( + JSON.stringify(original) !== + JSON.stringify(sorted.map((icon) => icon.value)) + ) { + metadata.reorderedPaths.push( + `descriptors[${descriptor.surfaceId}].icons`, + ); + } + return sorted; + })(), layout: { ...strippedDescriptor.layout, containers: strippedDescriptor.layout.containers diff --git a/packages/interfacectl-cli/test/compare.test.mjs b/packages/interfacectl-cli/test/compare.test.mjs index 4090d03..8efaebb 100644 --- a/packages/interfacectl-cli/test/compare.test.mjs +++ b/packages/interfacectl-cli/test/compare.test.mjs @@ -4,6 +4,7 @@ import { classifyChange, detectRename, buildDiffPath, + compareContractToDescriptor, } from "../dist/utils/compare.js"; test("classifyChange identifies added entries", () => { @@ -43,3 +44,109 @@ test("buildDiffPath creates stable paths", () => { const path2 = buildDiffPath("surfaces/test", "sections", 0); assert.equal(path2, "surfaces/test/sections/0"); }); + +test("compareContractToDescriptor emits warning drift for icon policy warn", () => { + const contract = { + contract: { + contractId: "test", + version: "1.0.0", + surfaces: [ + { + id: "demo", + displayName: "Demo", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter"], + layout: { maxContentWidth: 960 }, + icons: { + policy: "warn", + allowedSources: ["lucide-react"], + }, + }, + ], + sections: [{ id: "main.hero", intent: "Hero", description: "Hero section" }], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "off", + allowedValues: [], + }, + }, + metadata: { reorderedPaths: [], strippedPaths: [] }, + }; + + const descriptor = { + descriptor: { + surfaceId: "demo", + sections: [{ id: "main.hero" }], + fonts: [{ value: "Inter" }], + colors: [], + icons: [{ value: "@heroicons/react/24/outline" }], + layout: { maxContentWidth: 960, containers: ["contract-container"] }, + motion: [], + }, + metadata: { reorderedPaths: [], strippedPaths: [] }, + }; + + const entries = compareContractToDescriptor(contract, descriptor, "demo"); + const iconEntry = entries.find((entry) => entry.path.includes("icons.allowedSources")); + assert.ok(iconEntry); + assert.equal(iconEntry.severity, "warning"); +}); + +test("compareContractToDescriptor emits error drift for icon policy strict", () => { + const contract = { + contract: { + contractId: "test", + version: "1.0.0", + surfaces: [ + { + id: "demo", + displayName: "Demo", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter"], + layout: { maxContentWidth: 960 }, + icons: { + policy: "strict", + allowedSources: ["lucide-react"], + }, + }, + ], + sections: [{ id: "main.hero", intent: "Hero", description: "Hero section" }], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "off", + allowedValues: [], + }, + }, + metadata: { reorderedPaths: [], strippedPaths: [] }, + }; + + const descriptor = { + descriptor: { + surfaceId: "demo", + sections: [{ id: "main.hero" }], + fonts: [{ value: "Inter" }], + colors: [], + icons: [{ value: "@heroicons/react/24/outline" }], + layout: { maxContentWidth: 960, containers: ["contract-container"] }, + motion: [], + }, + metadata: { reorderedPaths: [], strippedPaths: [] }, + }; + + const entries = compareContractToDescriptor(contract, descriptor, "demo"); + const iconEntry = entries.find((entry) => entry.path.includes("icons.allowedSources")); + assert.ok(iconEntry); + assert.equal(iconEntry.severity, "error"); +}); diff --git a/packages/interfacectl-cli/test/compile.test.mjs b/packages/interfacectl-cli/test/compile.test.mjs index a12b52e..a8a63b2 100644 --- a/packages/interfacectl-cli/test/compile.test.mjs +++ b/packages/interfacectl-cli/test/compile.test.mjs @@ -147,6 +147,66 @@ test("compile: golden - generated contract.normalized.json and surface/constrain } }); +test("compile: includes surface icons policy when present in contract", async () => { + const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-icons-")); + const contractWithIconsPath = path.join(outDir, "contract-with-icons.json"); + try { + const { writeFile } = await import("node:fs/promises"); + await writeFile( + contractWithIconsPath, + JSON.stringify( + { + contractId: "demo-ui-icons", + version: "1.0.0", + surfaces: [ + { + id: "demo-surface", + displayName: "Demo Surface", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter"], + layout: { maxContentWidth: 960 }, + icons: { + policy: "warn", + allowedSources: ["lucide-react"], + }, + }, + ], + sections: [ + { id: "main.hero", intent: "Hero", description: "Main hero" }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "off", + allowedValues: [], + }, + }, + null, + 2, + ), + "utf8", + ); + + const result = await runCompile(contractWithIconsPath, outDir); + assert.equal(result.exitCode, 0, `compile should exit 0: ${result.stderr}`); + + const generatedSurface = await readJson( + path.join(outDir, "surfaces", "demo-surface.json"), + ); + assert.deepEqual(generatedSurface.icons, { + policy: "warn", + allowedSources: ["lucide-react"], + }); + } finally { + await rm(outDir, { recursive: true, force: true }); + } +}); + test("compile: invalid contract fails with non-zero exit", async () => { const outDir = await mkdtemp(path.join(os.tmpdir(), "interfacectl-compile-fail-")); const invalidContract = path.join(outDir, "invalid.json"); diff --git a/packages/interfacectl-cli/test/icon-policy-seeding.test.mjs b/packages/interfacectl-cli/test/icon-policy-seeding.test.mjs new file mode 100644 index 0000000..32e8b40 --- /dev/null +++ b/packages/interfacectl-cli/test/icon-policy-seeding.test.mjs @@ -0,0 +1,139 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { + mkdtemp, + mkdir, + rm, + writeFile, +} from "node:fs/promises"; +import { seedIconPolicyFromObservedDescriptors } from "../dist/utils/icon-policy-seeding.js"; + +function baseContract(surfaceId) { + return { + contractId: "icon-seed-test", + version: "1.0.0", + surfaces: [ + { + id: surfaceId, + displayName: "Seed Test", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter"], + layout: { maxContentWidth: 960 }, + }, + ], + sections: [{ id: "main.hero", intent: "Hero", description: "Hero section" }], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "off", + allowedValues: [], + }, + }; +} + +test("seedIconPolicyFromObservedDescriptors seeds warn policy with discovered icon sources", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-seed-icons-")); + const surfaceId = "demo-surface"; + const appRoot = path.join(tempRoot, "apps", surfaceId); + + try { + const appDir = path.join(appRoot, "app"); + await mkdir(appDir, { recursive: true }); + + await writeFile( + path.join(appDir, "layout.tsx"), + ` + export default function RootLayout({ children }) { + return {children}; + } + `, + "utf8", + ); + + await writeFile( + path.join(appDir, "page.tsx"), + ` + import { House } from "lucide-react"; + import { FiUser } from "react-icons/fi"; + export default function Page() { + return
; + } + `, + "utf8", + ); + + const seeded = await seedIconPolicyFromObservedDescriptors({ + workspaceRoot: tempRoot, + appRoot, + surfaceId, + contract: baseContract(surfaceId), + }); + + assert.deepEqual(seeded.contract.surfaces[0].icons, { + policy: "warn", + allowedSources: ["lucide-react", "react-icons/fi"], + }); + assert.equal( + seeded.warnings.some((warning) => warning.code === "icon-seed.none-detected"), + false, + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("seedIconPolicyFromObservedDescriptors warns when no icon sources are detected", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-seed-icons-empty-")); + const surfaceId = "demo-surface"; + const appRoot = path.join(tempRoot, "apps", surfaceId); + + try { + const appDir = path.join(appRoot, "app"); + await mkdir(appDir, { recursive: true }); + + await writeFile( + path.join(appDir, "layout.tsx"), + ` + export default function RootLayout({ children }) { + return {children}; + } + `, + "utf8", + ); + + await writeFile( + path.join(appDir, "page.tsx"), + ` + export default function Page() { + return
Hello
; + } + `, + "utf8", + ); + + const seeded = await seedIconPolicyFromObservedDescriptors({ + workspaceRoot: tempRoot, + appRoot, + surfaceId, + contract: baseContract(surfaceId), + }); + + assert.deepEqual(seeded.contract.surfaces[0].icons, { + policy: "warn", + allowedSources: [], + }); + assert.ok( + seeded.warnings.some((warning) => warning.code === "icon-seed.none-detected"), + `Expected icon-seed.none-detected warning, got ${JSON.stringify(seeded.warnings)}`, + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/interfacectl-cli/test/icon-policy.test.mjs b/packages/interfacectl-cli/test/icon-policy.test.mjs new file mode 100644 index 0000000..a516ade --- /dev/null +++ b/packages/interfacectl-cli/test/icon-policy.test.mjs @@ -0,0 +1,173 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { + mkdtemp, + mkdir, + rm, + writeFile, +} from "node:fs/promises"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliExecutable = path.resolve(__dirname, "..", "dist", "index.js"); + +async function runValidate(workspaceRoot, contractPath) { + const child = spawn( + "node", + [ + cliExecutable, + "validate", + "--contract", + contractPath, + "--workspace-root", + workspaceRoot, + "--format", + "json", + "--exit-codes", + "v2", + ], + { cwd: workspaceRoot }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const [exitCode] = await once(child, "exit"); + return { + exitCode: Number(exitCode), + stdout, + stderr, + }; +} + +async function createWorkspace(policy) { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-icon-policy-")); + const surfaceId = "test-surface"; + const appDir = path.join(tempRoot, "apps", surfaceId, "app"); + const contractPath = path.join(tempRoot, "contract.json"); + + await mkdir(appDir, { recursive: true }); + + await writeFile( + path.join(appDir, "layout.tsx"), + ` + import "./globals.css"; + export default function Layout({ children }) { + return {children}; + } + `, + "utf-8", + ); + + await writeFile( + path.join(appDir, "page.tsx"), + ` + import { BellIcon } from "@heroicons/react/24/outline"; + export default function Page() { + return ( +
+ +
+ ); + } + `, + "utf-8", + ); + + await writeFile( + path.join(appDir, "globals.css"), + ` + :root { + --contract-max-width: 960px; + --contract-motion-duration: 120ms; + --contract-motion-timing: linear; + } + body { + font-family: sans-serif; + } + .contract-container { + transition: opacity var(--contract-motion-duration) var(--contract-motion-timing); + } + `, + "utf-8", + ); + + const contract = { + contractId: "icon-policy-test", + version: "1.0.0", + surfaces: [ + { + id: surfaceId, + displayName: "Test Surface", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["sans-serif"], + layout: { maxContentWidth: 960 }, + icons: { + policy, + allowedSources: ["lucide-react"], + }, + }, + ], + sections: [{ id: "main.hero", intent: "Hero", description: "Main hero section" }], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "off", + allowedValues: [], + }, + }; + + await writeFile(contractPath, JSON.stringify(contract, null, 2), "utf-8"); + return { tempRoot, contractPath }; +} + +test("validate emits icon.source-disallowed as warning when policy is warn", async () => { + const { tempRoot, contractPath } = await createWorkspace("warn"); + try { + const result = await runValidate(tempRoot, contractPath); + assert.equal(result.exitCode, 0, result.stderr); + const payload = JSON.parse(result.stdout); + const finding = payload.findings.find( + (entry) => entry.code === "icon.source-disallowed", + ); + assert.ok(finding, `missing icon finding: ${result.stdout}`); + assert.equal(finding.severity, "warning"); + assert.equal(finding.category, "E1"); + assert.deepEqual(finding.expected, ["lucide-react"]); + assert.equal(finding.found, "@heroicons/react/24/outline"); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("validate emits icon.source-disallowed as error when policy is strict", async () => { + const { tempRoot, contractPath } = await createWorkspace("strict"); + try { + const result = await runValidate(tempRoot, contractPath); + assert.equal(result.exitCode, 20, result.stderr); + const payload = JSON.parse(result.stdout); + const finding = payload.findings.find( + (entry) => entry.code === "icon.source-disallowed", + ); + assert.ok(finding, `missing icon finding: ${result.stdout}`); + assert.equal(finding.severity, "error"); + assert.equal(finding.category, "E1"); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/interfacectl-cli/test/normalize.test.mjs b/packages/interfacectl-cli/test/normalize.test.mjs index 1949a66..f12e3d9 100644 --- a/packages/interfacectl-cli/test/normalize.test.mjs +++ b/packages/interfacectl-cli/test/normalize.test.mjs @@ -52,6 +52,10 @@ test("normalizeContract sorts set-like arrays", () => { type: "web", requiredSections: ["b", "a", "c"], allowedFonts: ["font2", "font1"], + icons: { + policy: "warn", + allowedSources: ["react-icons/fi", "lucide-react"], + }, layout: { maxContentWidth: 1000 }, }, ], @@ -76,6 +80,10 @@ test("normalizeContract sorts set-like arrays", () => { normalized.contract.surfaces[0].allowedFonts, ["font1", "font2"], ); + assert.deepEqual( + normalized.contract.surfaces[0].icons.allowedSources, + ["lucide-react", "react-icons/fi"], + ); assert.deepEqual( normalized.contract.color.allowedValues, ["color1", "color2"], @@ -88,11 +96,13 @@ test("normalizeDescriptor strips ephemeral fields", () => { sections: [{ id: "section1", source: "file.ts" }], fonts: [{ value: "font1", source: "file.ts" }], colors: [{ value: "color1", source: "file.ts" }], + icons: [{ value: "react-icons/fi", source: "file.ts" }], layout: { maxContentWidth: 1000, source: "file.ts" }, motion: [], }; const normalized = normalizeDescriptor(descriptor); assert.equal(normalized.descriptor.sections[0].source, undefined); assert.equal(normalized.descriptor.fonts[0].source, undefined); + assert.equal(normalized.descriptor.icons[0].source, undefined); assert.equal(normalized.descriptor.layout.source, undefined); }); diff --git a/packages/interfacectl-cli/test/static-analysis.test.mjs b/packages/interfacectl-cli/test/static-analysis.test.mjs index dd9d69d..25c62ed 100644 --- a/packages/interfacectl-cli/test/static-analysis.test.mjs +++ b/packages/interfacectl-cli/test/static-analysis.test.mjs @@ -273,3 +273,127 @@ test("detects shell-owned primitives (navigation/footer/auth-shell) in descripto await rm(tempRoot, { recursive: true, force: true }); } }); + +test("collectSurfaceDescriptors captures deterministic icon sources from surface and shared UI imports", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-icons-static-")); + const surfaceId = "demo-icons"; + const surfaceRoot = path.join(tempRoot, "apps", surfaceId); + const sharedUiRoot = path.join(tempRoot, "packages", "ui", "src", "components"); + + try { + await mkdir(path.join(surfaceRoot, "app"), { recursive: true }); + await mkdir(sharedUiRoot, { recursive: true }); + + await writeFile( + path.join(surfaceRoot, "app", "layout.tsx"), + ` + export default function RootLayout({ children }) { + return {children}; + } + `, + "utf-8", + ); + + await writeFile( + path.join(surfaceRoot, "app", "page.tsx"), + ` + import { House } from "lucide-react"; + import IconButton from "@surfaces/ui/components/IconButton"; + import { IconBadge } from "./IconBadge"; + + export default function Page() { + return ( +
+ + + +
+ ); + } + `, + "utf-8", + ); + + await writeFile( + path.join(surfaceRoot, "app", "IconBadge.tsx"), + ` + import { AlertCircle } from "lucide-react"; + export function IconBadge() { + return ; + } + `, + "utf-8", + ); + + await writeFile( + path.join(sharedUiRoot, "IconButton.tsx"), + ` + import { BellIcon } from "@heroicons/react/24/outline"; + export { UiIcon } from "./UiIcon"; + export default function IconButton() { + return ; + } + `, + "utf-8", + ); + + await writeFile( + path.join(sharedUiRoot, "UiIcon.tsx"), + ` + import { FiUser } from "react-icons/fi"; + export function UiIcon() { + return ; + } + `, + "utf-8", + ); + + const contract = { + contractId: "test.contract", + version: "1.0.0", + sections: [{ id: "main.hero", intent: "Hero", description: "Hero section" }], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + surfaces: [ + { + id: surfaceId, + displayName: "Demo Icons", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter"], + layout: { maxContentWidth: 960 }, + }, + ], + color: { + policy: "off", + allowedValues: [], + }, + }; + + const result = await collectSurfaceDescriptors({ + workspaceRoot: tempRoot, + contract, + surfaceFilters: new Set(), + surfaceRootMap: new Map(), + }); + + assert.equal(result.errors.length, 0); + const iconResolutionWarnings = result.warnings.filter( + (warning) => warning.code === "icons.shared-ui-unresolved", + ); + assert.equal(iconResolutionWarnings.length, 0); + const descriptor = result.descriptors[0]; + assert.ok(descriptor); + + assert.deepEqual( + (descriptor.icons ?? []).map((icon) => icon.value), + ["@heroicons/react/24/outline", "lucide-react", "react-icons/fi"], + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/interfacectl-cli/test/violation-classifier.test.mjs b/packages/interfacectl-cli/test/violation-classifier.test.mjs index cd30db7..c57e23a 100644 --- a/packages/interfacectl-cli/test/violation-classifier.test.mjs +++ b/packages/interfacectl-cli/test/violation-classifier.test.mjs @@ -9,6 +9,7 @@ import { test("classifyViolationType: E1 violations", () => { assert.equal(classifyViolationType("font-not-allowed"), "E1"); assert.equal(classifyViolationType("color-not-allowed"), "E1"); + assert.equal(classifyViolationType("icon-source-not-allowed"), "E1"); assert.equal(classifyViolationType("motion-duration-not-allowed"), "E1"); assert.equal(classifyViolationType("motion-timing-not-allowed"), "E1"); }); diff --git a/packages/interfacectl-validator/test/contract-structure.test.mjs b/packages/interfacectl-validator/test/contract-structure.test.mjs new file mode 100644 index 0000000..f88ffee --- /dev/null +++ b/packages/interfacectl-validator/test/contract-structure.test.mjs @@ -0,0 +1,94 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + validateContractStructure, + getBundledContractSchema, +} from "../dist/index.js"; + +function buildContract(surfaceOverrides = {}, extra = {}) { + return { + contractId: "icon-structure-test", + version: "1.0.0", + surfaces: [ + { + id: "web-surface", + displayName: "Web Surface", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter"], + layout: { + maxContentWidth: 1200, + }, + ...surfaceOverrides, + }, + ], + sections: [ + { + id: "main.hero", + intent: "Hero", + description: "Main hero section", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "off", + allowedValues: [], + }, + ...extra, + }; +} + +test("validateContractStructure remains backward compatible when icons are omitted", () => { + const schema = getBundledContractSchema(); + const contract = buildContract(); + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, true); +}); + +test("validateContractStructure rejects invalid surface.icons.policy", () => { + const schema = getBundledContractSchema(); + const contract = buildContract({ + icons: { + policy: "invalid", + allowedSources: ["lucide-react"], + }, + }); + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((error) => error.includes("policy")), + `expected policy validation error, got ${JSON.stringify(result.errors)}`, + ); +}); + +test("validateContractStructure rejects malformed surface.icons.allowedSources", () => { + const schema = getBundledContractSchema(); + const contract = buildContract({ + icons: { + policy: "warn", + allowedSources: ["lucide-react", 42], + }, + }); + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((error) => error.includes("allowedSources")), + `expected allowedSources validation error, got ${JSON.stringify(result.errors)}`, + ); +}); + +test("validateContractStructure accepts x_extracted.iconSources", () => { + const schema = getBundledContractSchema(); + const contract = buildContract({}, { + x_extracted: { + iconSources: ["lucide-react", "@heroicons/react/24/outline"], + }, + }); + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, true); +}); diff --git a/packages/interfacectl-validator/test/evaluate-contract.test.mjs b/packages/interfacectl-validator/test/evaluate-contract.test.mjs index 21b85f4..eee74ef 100644 --- a/packages/interfacectl-validator/test/evaluate-contract.test.mjs +++ b/packages/interfacectl-validator/test/evaluate-contract.test.mjs @@ -251,6 +251,94 @@ test("policy warn emits color-not-allowed with warn metadata", () => { assert.equal(colorViolation.details?.policy, "warn"); }); +test("icon policy strict reports disallowed icon sources", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-icons-strict", + displayName: "Surface Icons Strict", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-icons)"], + layout: { + maxContentWidth: 1200, + requiredContainers: [], + }, + icons: { + policy: "strict", + allowedSources: ["lucide-react"], + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-icons-strict", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-icons)" }], + colors: [{ value: "var(--color-primary)" }], + icons: [{ value: "lucide-react" }, { value: "@heroicons/react/24/outline" }], + layout: { + maxContentWidth: 1000, + containers: [], + }, + motion: [], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const iconViolation = report.violations.find( + (violation) => violation.type === "icon-source-not-allowed", + ); + assert.ok(iconViolation, "expected icon-source-not-allowed violation"); + assert.equal(iconViolation.details?.iconSource, "@heroicons/react/24/outline"); + assert.equal(iconViolation.details?.policy, "strict"); + assert.deepEqual(iconViolation.details?.allowedSources, ["lucide-react"]); +}); + +test("icon policy warn emits policy metadata for warn-level handling", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-icons-warn", + displayName: "Surface Icons Warn", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-icons)"], + layout: { + maxContentWidth: 1200, + requiredContainers: [], + }, + icons: { + policy: "warn", + allowedSources: [], + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-icons-warn", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-icons)" }], + colors: [{ value: "var(--color-primary)" }], + icons: [{ value: "lucide-react" }], + layout: { + maxContentWidth: 1000, + containers: [], + }, + motion: [], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const iconViolation = report.violations.find( + (violation) => violation.type === "icon-source-not-allowed", + ); + assert.ok(iconViolation); + assert.equal(iconViolation.details?.policy, "warn"); +}); + test("mixed allowed values pass exact matching through one path", () => { const contract = { ...baseContract,