Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -421,6 +422,7 @@ interfacectl generate-contract --app-root <path> --surface <id> [--out <path>] [

**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/<surfaceId>.contract.json`) and an extraction report (default `contracts/generated/<surfaceId>.extraction.json`).
- Validates the generated contract against the schema before writing. Running the command twice produces identical output (stable key order, no timestamps).

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -86,7 +86,7 @@ interfacectl compile --contract <path> --out <dir>

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.

Expand Down
18 changes: 18 additions & 0 deletions docs/contract-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions docs/plans/web-icon-contract-increment-status.md
Original file line number Diff line number Diff line change
@@ -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/<surfaceId>/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.
1 change: 1 addition & 0 deletions packages/interfacectl-cli/dist/commands/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,5 +154,6 @@ function surfaceToBundlePayload(surface) {
requiredSections: surface.requiredSections,
allowedFonts: surface.allowedFonts,
layout: surface.layout,
...(surface.icons ? { icons: surface.icons } : {}),
};
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions packages/interfacectl-cli/dist/commands/generate-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/interfacectl-cli/dist/utils/compare.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions packages/interfacectl-cli/dist/utils/compare.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions packages/interfacectl-cli/dist/utils/icon-policy-seeding.d.ts
Original file line number Diff line number Diff line change
@@ -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<IconPolicySeedingResult>;
//# sourceMappingURL=icon-policy-seeding.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 56 additions & 0 deletions packages/interfacectl-cli/dist/utils/icon-policy-seeding.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
2 changes: 1 addition & 1 deletion packages/interfacectl-cli/dist/utils/normalize.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions packages/interfacectl-cli/dist/utils/normalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const SET_LIKE_FIELDS = new Set([
"allowedDurationsMs",
"allowedTimingFunctions",
"containers",
"allowedSources",
]);
/**
* Fields to strip from observed artifacts (ephemeral metadata)
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/interfacectl-cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,5 +211,6 @@ function surfaceToBundlePayload(surface: ContractSurface): Record<string, unknow
requiredSections: surface.requiredSections,
allowedFonts: surface.allowedFonts,
layout: surface.layout,
...(surface.icons ? { icons: surface.icons } : {}),
};
}
Loading