From b29485f79d2d82ec44f2305f65a86ba53db95ea9 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Thu, 15 Jan 2026 18:29:39 -0800 Subject: [PATCH 1/2] feat: validate layout.pageFrame via static analysis --- .../dist/commands/validate.d.ts.map | 2 +- .../dist/commands/validate.js | 56 ++- .../dist/descriptors/static-analysis.d.ts.map | 2 +- .../dist/descriptors/static-analysis.js | 326 +++++++++++++++- .../interfacectl-cli/docs/layout-pageframe.md | 264 +++++++++++++ .../interfacectl-cli/src/commands/validate.ts | 144 ++++++- .../src/descriptors/static-analysis.ts | 367 +++++++++++++++++- .../src/utils/violation-classifier.ts | 2 + .../clamp-padding/app/globals.css | 4 + .../clamp-padding/app/layout.tsx | 8 + .../fixtures/pageframe-static/contract.json | 37 ++ .../pageframe-static/css-rule/app/globals.css | 5 + .../pageframe-static/css-rule/app/layout.tsx | 7 + .../inline-styles/app/layout.tsx | 14 + .../maxwidth-fail/app/layout.tsx | 14 + .../missing-marker/app/layout.tsx | 7 + .../pageframe-static/tailwind/app/layout.tsx | 10 + .../test/fixtures/pageframe/contract.json | 37 ++ .../test/fixtures/pageframe/failing.html | 23 ++ .../test/fixtures/pageframe/passing.html | 29 ++ .../test/pageframe-static.test.mjs | 136 +++++++ .../test/pageframe-validator.test.mjs | 72 ++++ .../interfacectl-validator/dist/index.d.ts | 2 +- .../dist/index.d.ts.map | 2 +- packages/interfacectl-validator/dist/index.js | 156 +++++++- .../interfacectl-validator/dist/types.d.ts | 20 +- .../dist/types.d.ts.map | 2 +- 27 files changed, 1709 insertions(+), 39 deletions(-) create mode 100644 packages/interfacectl-cli/docs/layout-pageframe.md create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/globals.css create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/layout.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/contract.json create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/globals.css create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/layout.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/inline-styles/app/layout.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/maxwidth-fail/app/layout.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/missing-marker/app/layout.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/tailwind/app/layout.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe/contract.json create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe/failing.html create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe/passing.html create mode 100644 packages/interfacectl-cli/test/pageframe-static.test.mjs create mode 100644 packages/interfacectl-cli/test/pageframe-validator.test.mjs diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts.map b/packages/interfacectl-cli/dist/commands/validate.d.ts.map index e1d5039..bac0d41 100644 --- a/packages/interfacectl-cli/dist/commands/validate.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/validate.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAeA,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAmCpC,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAiQjB"} \ No newline at end of file +{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAeA,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAmCpC,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAwQjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate.js b/packages/interfacectl-cli/dist/commands/validate.js index ba4e870..0c5fb5e 100644 --- a/packages/interfacectl-cli/dist/commands/validate.js +++ b/packages/interfacectl-cli/dist/commands/validate.js @@ -126,15 +126,19 @@ export async function runValidateCommand(options) { }; if (!structureResult.ok || !structureResult.contract) { if (!isJson) { - printHeader(pc.red("✖ Contract structure validation failed"), textReporter); + printHeader(pc.red("✖ Contract schema validation failed (capability gap)"), textReporter); + textReporter.error(pc.dim("Schema validation errors indicate the contract structure is not supported by this version of interfacectl.")); for (const error of structureResult.errors) { textReporter.error(pc.red(` • ${error}`)); } } else { for (const error of structureResult.errors) { + // Check if this is an additionalProperties error (capability gap) + const isCapabilityGap = error.includes("Additional property") || + error.includes("is not allowed"); findings.push({ - code: "contract.schema-error", + code: isCapabilityGap ? "contract.schema-unsupported-field" : "contract.schema-error", severity: "error", category: "E0", message: error, @@ -272,6 +276,12 @@ function mapViolationsToFindings(summary) { "layout-width-exceeded": "layout.width-exceeded", "layout-width-undetermined": "layout.width-undetermined", "layout-container-missing": "layout.container-missing", + "layout-pageframe-container-not-found": "layout.pageframe.container-not-found", + "layout-pageframe-maxwidth-mismatch": "layout.pageframe.maxwidth-mismatch", + "layout-pageframe-padding-mismatch": "layout.pageframe.padding-mismatch", + "layout-pageframe-selector-unsupported": "layout.pageframe.selector-unsupported", + "layout-pageframe-non-deterministic-value": "layout.pageframe.non-deterministic-value", + "layout-pageframe-unextractable-value": "layout.pageframe.unextractable-value", "motion-duration-not-allowed": "motion.duration", "motion-timing-not-allowed": "motion.timing", }; @@ -331,6 +341,45 @@ function mapViolationsToFindings(summary) { details.missingContainers ?? details.containerSources; break; } + case "layout-pageframe-selector-unsupported": { + finding.expected = details.supportedSelectors; + finding.found = details.selector; + break; + } + case "layout-pageframe-container-not-found": { + finding.expected = details.selector; + finding.found = null; + break; + } + case "layout-pageframe-maxwidth-mismatch": { + finding.expected = details.expected; + finding.found = details.actual; + break; + } + case "layout-pageframe-padding-mismatch": { + finding.expected = details.expected; + finding.found = { + left: details.actualLeft, + right: details.actualRight, + }; + break; + } + case "layout-pageframe-non-deterministic-value": { + finding.expected = details.expected; + finding.found = details.actual ?? { + left: details.actualLeft, + right: details.actualRight, + }; + break; + } + case "layout-pageframe-unextractable-value": { + finding.expected = details.expected; + finding.found = details.actual ?? { + left: details.actualLeft, + right: details.actualRight, + }; + break; + } case "motion-duration-not-allowed": { finding.expected = details.allowedDurations; finding.found = details.durationMs; @@ -442,7 +491,8 @@ function printSummary(summary, output) { } return; } - printHeader(pc.red("✖ Contract violations detected"), output); + printHeader(pc.red("✖ Surface compliance violations detected"), output); + output.log(pc.dim("Compliance violations indicate surfaces do not match the contract requirements.")); for (const report of summary.surfaceReports) { if (report.violations.length === 0) { output.log(pc.green(` • ${report.surfaceId}: OK`)); diff --git a/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map b/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map index 989948e..52d29ba 100644 --- a/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map +++ b/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"static-analysis.d.ts","sourceRoot":"","sources":["../../src/descriptors/static-analysis.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EAMvB,MAAM,kCAAkC,CAAC;AAiC1C,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gCAAgC;IAC/C,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC5B,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,+BAA+B;IAC9C,WAAW,EAAE,iBAAiB,EAAE,CAAC;IACjC,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,gCAAgC,GACxC,OAAO,CAAC,+BAA+B,CAAC,CAyC1C"} \ No newline at end of file +{"version":3,"file":"static-analysis.d.ts","sourceRoot":"","sources":["../../src/descriptors/static-analysis.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EAOvB,MAAM,kCAAkC,CAAC;AA4D1C,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gCAAgC;IAC/C,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC5B,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,+BAA+B;IAC9C,WAAW,EAAE,iBAAiB,EAAE,CAAC;IACjC,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,gCAAgC,GACxC,OAAO,CAAC,+BAA+B,CAAC,CA0C1C"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/descriptors/static-analysis.js b/packages/interfacectl-cli/dist/descriptors/static-analysis.js index d71dd4a..28c3a91 100644 --- a/packages/interfacectl-cli/dist/descriptors/static-analysis.js +++ b/packages/interfacectl-cli/dist/descriptors/static-analysis.js @@ -4,6 +4,30 @@ import { globby } from "globby"; const SECTION_ATTRIBUTE_REGEX = /data-(?:contract-)?section\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; const CONTAINER_ATTRIBUTE_REGEX = /data-contract-container\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; const CONTRACT_CONTAINER_TOKEN = "contract-container"; +const PAGE_CONTAINER_ATTRIBUTE_REGEX = /data-contract\s*=\s*(?:"page-container"|'page-container'|{`page-container`}|{\s*["'`]page-container["'`]\s*})/g; +// Inline style extraction +const INLINE_STYLE_REGEX = /style\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const INLINE_MAX_WIDTH_REGEX = /max-width\s*:\s*([0-9.]+)\s*px/gi; +const INLINE_PADDING_LEFT_REGEX = /padding-left\s*:\s*([0-9.]+)\s*px/gi; +const INLINE_PADDING_RIGHT_REGEX = /padding-right\s*:\s*([0-9.]+)\s*px/gi; +const INLINE_PADDING_INLINE_REGEX = /padding-inline\s*:\s*([0-9.]+)\s*px/gi; +// CSS rule extraction for [data-contract="page-container"] +const CSS_SELECTOR_PAGE_CONTAINER_REGEX = /\[data-contract\s*=\s*["']page-container["']\]\s*\{([^}]+)\}/gi; +const CSS_MAX_WIDTH_REGEX = /max-width\s*:\s*([0-9.]+)\s*px/gi; +const CSS_PADDING_LEFT_REGEX = /padding-left\s*:\s*([0-9.]+)\s*px/gi; +const CSS_PADDING_RIGHT_REGEX = /padding-right\s*:\s*([0-9.]+)\s*px/gi; +const CSS_PADDING_INLINE_REGEX = /padding-inline\s*:\s*([0-9.]+)\s*px/gi; +// Tailwind class extraction (best-effort) +const TAILWIND_MAX_WIDTH_REGEX = /max-w-\[([0-9.]+)px\]/gi; +const TAILWIND_PADDING_X_REGEX = /px-\[([0-9.]+)px\]/gi; +const TAILWIND_PADDING_LEFT_REGEX = /pl-\[([0-9.]+)px\]/gi; +const TAILWIND_PADDING_RIGHT_REGEX = /pr-\[([0-9.]+)px\]/gi; +// Non-deterministic value detection +const CLAMP_REGEX = /clamp\s*\(/i; +const CALC_REGEX = /calc\s*\(/i; +// Optional CSS custom properties (fallback) +const PAGE_FRAME_MAX_WIDTH_VAR_REGEX = /--contract-page-frame-max-width\s*:\s*([0-9.]+)\s*px/i; +const PAGE_FRAME_PADDING_VAR_REGEX = /--contract-page-frame-padding-x\s*:\s*([0-9.]+)\s*px/i; const COMMON_GLOBBY_IGNORES = [ "**/node_modules/**", "**/.next/**", @@ -41,7 +65,7 @@ export async function collectSurfaceDescriptors(options) { }); continue; } - const descriptorResult = await extractSurfaceDescriptor(options.workspaceRoot, surfaceRoot, surface.id); + const descriptorResult = await extractSurfaceDescriptor(options.workspaceRoot, surfaceRoot, surface.id, surface); structuralDescriptors.push(descriptorResult.descriptor); warnings.push(...descriptorResult.warnings); errors.push(...descriptorResult.errors); @@ -55,7 +79,7 @@ function resolveSurfaceRoot(workspaceRoot, surface, surfaceRootMap) { } return path.join(workspaceRoot, "apps", surface.id); } -async function extractSurfaceDescriptor(workspaceRoot, surfaceRoot, surfaceId) { +async function extractSurfaceDescriptor(workspaceRoot, surfaceRoot, surfaceId, surface) { const warnings = []; const errors = []; const sectionFiles = await globby(["app/**/*.{ts,tsx,js,jsx}"], { @@ -79,7 +103,7 @@ async function extractSurfaceDescriptor(workspaceRoot, surfaceRoot, surfaceId) { gitignore: true, ignore: COMMON_GLOBBY_IGNORES, }); - const layout = await extractLayout(layoutCssFiles, sectionFiles, workspaceRoot, fileContentCache); + const layout = await extractLayout(layoutCssFiles, sectionFiles, workspaceRoot, fileContentCache, surface); const fonts = await extractFonts(surfaceRoot, sectionFiles, workspaceRoot, fileContentCache); if (fonts.length === 0) { const globalsPath = path.join(surfaceRoot, "app", "globals.css"); @@ -183,7 +207,7 @@ async function extractColors(surfaceRoot, cssFilePaths, sectionFiles, workspaceR } return [...colorValues.values()].sort((a, b) => a.value.localeCompare(b.value)); } -async function extractLayout(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache) { +async function extractLayout(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache, surface) { let maxWidth = null; let layoutSource; for (const cssPath of cssFilePaths) { @@ -211,11 +235,305 @@ async function extractLayout(cssFilePaths, sectionFiles, workspaceRoot, fileCont containerSources.add(path.relative(workspaceRoot, filePath)); } } + // Extract pageFrame layout if contract defines it + let pageFrame; + if (surface?.layout.pageFrame) { + pageFrame = await extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache, surface.layout.pageFrame); + } return { maxContentWidth: maxWidth, containers: [...containers].sort(), containerSources: [...containerSources].sort(), source: layoutSource, + pageFrame, + }; +} +async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache, pageFrameContract) { + if (!pageFrameContract) { + return undefined; + } + const containerSelector = pageFrameContract.containerSelector; + // Check if selector is supported (v1 only supports data-contract="page-container") + const isSupportedSelector = containerSelector === '[data-contract="page-container"]' || + containerSelector === "[data-contract='page-container']" || + containerSelector === '[data-contract={page-container}]'; + if (!isSupportedSelector) { + // Return undefined to trigger selectorUnsupported violation + return undefined; + } + // Check if page-container marker exists in source files + let containerFound = false; + let containerSource; + let containerFileContent; + for (const filePath of sectionFiles) { + const content = await readFileCached(filePath, fileContentCache); + // Reset regex state + PAGE_CONTAINER_ATTRIBUTE_REGEX.lastIndex = 0; + if (PAGE_CONTAINER_ATTRIBUTE_REGEX.test(content)) { + containerFound = true; + containerSource = path.relative(workspaceRoot, filePath); + containerFileContent = content; + break; + } + } + if (!containerFound) { + // Container marker not found - return partial descriptor + return { + containerSelector, + maxWidthPx: null, + paddingLeftPx: null, + paddingRightPx: null, + source: undefined, + maxWidthHasClampCalc: undefined, + paddingHasClampCalc: undefined, + }; + } + let maxWidthPx = null; + let paddingLeftPx = null; + let paddingRightPx = null; + let extractionSource; + let maxWidthHasClampCalc = false; + let paddingHasClampCalc = false; + // Strategy A: Extract from inline styles on the marked element + if (containerFileContent) { + INLINE_STYLE_REGEX.lastIndex = 0; + let styleMatch; + while ((styleMatch = INLINE_STYLE_REGEX.exec(containerFileContent)) !== null) { + const styleContent = styleMatch[1] ?? styleMatch[2] ?? styleMatch[3] ?? styleMatch[4] ?? ""; + // Extract max-width + if (maxWidthPx === null) { + INLINE_MAX_WIDTH_REGEX.lastIndex = 0; + const maxWidthMatch = INLINE_MAX_WIDTH_REGEX.exec(styleContent); + if (maxWidthMatch) { + const maxWidthValue = maxWidthMatch[0]; + // Check if this specific max-width value uses clamp/calc + if (CLAMP_REGEX.test(maxWidthValue) || CALC_REGEX.test(maxWidthValue)) { + maxWidthHasClampCalc = true; + } + else { + const value = Number.parseFloat(maxWidthMatch[1]); + if (Number.isFinite(value)) { + maxWidthPx = value; + extractionSource = containerSource; + } + } + } + } + // Extract padding + if (paddingLeftPx === null || paddingRightPx === null) { + INLINE_PADDING_INLINE_REGEX.lastIndex = 0; + const paddingInlineMatch = INLINE_PADDING_INLINE_REGEX.exec(styleContent); + if (paddingInlineMatch) { + const paddingValue = paddingInlineMatch[0]; + // Check if this specific padding value uses clamp/calc + if (CLAMP_REGEX.test(paddingValue) || CALC_REGEX.test(paddingValue)) { + paddingHasClampCalc = true; + } + else { + const value = Number.parseFloat(paddingInlineMatch[1]); + if (Number.isFinite(value)) { + paddingLeftPx = value; + paddingRightPx = value; + extractionSource = containerSource; + } + } + } + else { + INLINE_PADDING_LEFT_REGEX.lastIndex = 0; + INLINE_PADDING_RIGHT_REGEX.lastIndex = 0; + const leftMatch = INLINE_PADDING_LEFT_REGEX.exec(styleContent); + const rightMatch = INLINE_PADDING_RIGHT_REGEX.exec(styleContent); + if (leftMatch && rightMatch) { + const leftValueStr = leftMatch[0]; + const rightValueStr = rightMatch[0]; + // Check if padding values use clamp/calc + if (CLAMP_REGEX.test(leftValueStr) || + CALC_REGEX.test(leftValueStr) || + CLAMP_REGEX.test(rightValueStr) || + CALC_REGEX.test(rightValueStr)) { + paddingHasClampCalc = true; + } + else { + const leftValue = Number.parseFloat(leftMatch[1]); + const rightValue = Number.parseFloat(rightMatch[1]); + if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) { + paddingLeftPx = leftValue; + paddingRightPx = rightValue; + extractionSource = containerSource; + } + } + } + } + } + } + } + // Strategy B: Extract from CSS rules targeting [data-contract="page-container"] + if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) { + for (const cssPath of cssFilePaths) { + const cssContent = await readFileCached(cssPath, fileContentCache); + CSS_SELECTOR_PAGE_CONTAINER_REGEX.lastIndex = 0; + let selectorMatch; + while ((selectorMatch = CSS_SELECTOR_PAGE_CONTAINER_REGEX.exec(cssContent)) !== null) { + const ruleContent = selectorMatch[1]; + // Extract max-width (check for clamp/calc in max-width declaration) + if (maxWidthPx === null) { + // First check if max-width exists (even with clamp/calc) + const maxWidthDeclMatch = /max-width\s*:\s*([^;]+)/i.exec(ruleContent); + if (maxWidthDeclMatch) { + const maxWidthValue = maxWidthDeclMatch[1].trim(); + // Check if this specific max-width value uses clamp/calc + if (CLAMP_REGEX.test(maxWidthValue) || CALC_REGEX.test(maxWidthValue)) { + maxWidthHasClampCalc = true; + } + else { + // Try to extract px value + CSS_MAX_WIDTH_REGEX.lastIndex = 0; + const maxWidthMatch = CSS_MAX_WIDTH_REGEX.exec(ruleContent); + if (maxWidthMatch) { + const value = Number.parseFloat(maxWidthMatch[1]); + if (Number.isFinite(value)) { + maxWidthPx = value; + extractionSource = path.relative(workspaceRoot, cssPath); + } + } + } + } + } + // Extract padding (check for clamp/calc in padding declarations) + if (paddingLeftPx === null || paddingRightPx === null) { + // First check if padding-inline exists (even with clamp/calc) + const paddingInlineDeclMatch = /padding-inline\s*:\s*([^;]+)/i.exec(ruleContent); + if (paddingInlineDeclMatch) { + const paddingValue = paddingInlineDeclMatch[1].trim(); + // Check if this specific padding value uses clamp/calc + if (CLAMP_REGEX.test(paddingValue) || CALC_REGEX.test(paddingValue)) { + paddingHasClampCalc = true; + } + else { + // Try to extract px value + CSS_PADDING_INLINE_REGEX.lastIndex = 0; + const paddingInlineMatch = CSS_PADDING_INLINE_REGEX.exec(ruleContent); + if (paddingInlineMatch) { + const value = Number.parseFloat(paddingInlineMatch[1]); + if (Number.isFinite(value)) { + paddingLeftPx = value; + paddingRightPx = value; + extractionSource = path.relative(workspaceRoot, cssPath); + } + } + } + } + else { + CSS_PADDING_LEFT_REGEX.lastIndex = 0; + CSS_PADDING_RIGHT_REGEX.lastIndex = 0; + const leftMatch = CSS_PADDING_LEFT_REGEX.exec(ruleContent); + const rightMatch = CSS_PADDING_RIGHT_REGEX.exec(ruleContent); + if (leftMatch && rightMatch) { + const leftValueStr = leftMatch[0]; + const rightValueStr = rightMatch[0]; + // Check if padding values use clamp/calc + if (CLAMP_REGEX.test(leftValueStr) || + CALC_REGEX.test(leftValueStr) || + CLAMP_REGEX.test(rightValueStr) || + CALC_REGEX.test(rightValueStr)) { + paddingHasClampCalc = true; + } + else { + const leftValue = Number.parseFloat(leftMatch[1]); + const rightValue = Number.parseFloat(rightMatch[1]); + if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) { + paddingLeftPx = leftValue; + paddingRightPx = rightValue; + extractionSource = path.relative(workspaceRoot, cssPath); + } + } + } + } + } + } + } + } + // Strategy C: Extract from Tailwind bracket classes (best-effort, v1) + if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) { + for (const filePath of sectionFiles) { + const content = await readFileCached(filePath, fileContentCache); + // Extract max-width from max-w-[NNNpx] + if (maxWidthPx === null) { + TAILWIND_MAX_WIDTH_REGEX.lastIndex = 0; + const maxWidthMatch = TAILWIND_MAX_WIDTH_REGEX.exec(content); + if (maxWidthMatch) { + const value = Number.parseFloat(maxWidthMatch[1]); + if (Number.isFinite(value)) { + maxWidthPx = value; + extractionSource = path.relative(workspaceRoot, filePath); + } + } + } + // Extract padding from px-[NNpx] or pl-[NNpx]/pr-[NNpx] + if (paddingLeftPx === null || paddingRightPx === null) { + TAILWIND_PADDING_X_REGEX.lastIndex = 0; + const paddingXMatch = TAILWIND_PADDING_X_REGEX.exec(content); + if (paddingXMatch) { + const value = Number.parseFloat(paddingXMatch[1]); + if (Number.isFinite(value)) { + paddingLeftPx = value; + paddingRightPx = value; + extractionSource = path.relative(workspaceRoot, filePath); + } + } + else { + TAILWIND_PADDING_LEFT_REGEX.lastIndex = 0; + TAILWIND_PADDING_RIGHT_REGEX.lastIndex = 0; + const leftMatch = TAILWIND_PADDING_LEFT_REGEX.exec(content); + const rightMatch = TAILWIND_PADDING_RIGHT_REGEX.exec(content); + if (leftMatch && rightMatch) { + const leftValue = Number.parseFloat(leftMatch[1]); + const rightValue = Number.parseFloat(rightMatch[1]); + if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) { + paddingLeftPx = leftValue; + paddingRightPx = rightValue; + extractionSource = path.relative(workspaceRoot, filePath); + } + } + } + } + } + } + // Optional: CSS custom properties (fallback, not required) + if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) { + for (const cssPath of cssFilePaths) { + const cssContent = await readFileCached(cssPath, fileContentCache); + if (maxWidthPx === null) { + const varMatch = cssContent.match(PAGE_FRAME_MAX_WIDTH_VAR_REGEX); + if (varMatch) { + const value = Number.parseFloat(varMatch[1]); + if (Number.isFinite(value)) { + maxWidthPx = value; + extractionSource = path.relative(workspaceRoot, cssPath); + } + } + } + if (paddingLeftPx === null || paddingRightPx === null) { + const paddingXMatch = cssContent.match(PAGE_FRAME_PADDING_VAR_REGEX); + if (paddingXMatch) { + const value = Number.parseFloat(paddingXMatch[1]); + if (Number.isFinite(value)) { + paddingLeftPx = value; + paddingRightPx = value; + extractionSource = path.relative(workspaceRoot, cssPath); + } + } + } + } + } + return { + containerSelector, + maxWidthPx, + paddingLeftPx, + paddingRightPx, + source: extractionSource ?? containerSource, + maxWidthHasClampCalc: maxWidthHasClampCalc || undefined, + paddingHasClampCalc: paddingHasClampCalc || undefined, }; } async function extractMotion(cssFilePaths, workspaceRoot, fileContentCache) { diff --git a/packages/interfacectl-cli/docs/layout-pageframe.md b/packages/interfacectl-cli/docs/layout-pageframe.md new file mode 100644 index 0000000..253999b --- /dev/null +++ b/packages/interfacectl-cli/docs/layout-pageframe.md @@ -0,0 +1,264 @@ +# Layout Page Frame Validation + +The `layout.pageFrame` contract section enables validation of page container layout properties using static analysis of source files. + +## What It Validates + +The pageFrame validator checks: + +- **Container existence**: Verifies the element matching `containerSelector` exists in the DOM (via `data-contract="page-container"` marker) +- **Max-width**: Compares extracted `max-width` CSS property against `containerMaxWidthPx` +- **Horizontal padding**: Compares extracted `padding-left` and `padding-right` (or `padding-inline`) against `paddingXpx` + +## What It Does Not Validate + +- Framework-specific class names as the primary mechanism (Tailwind bracket classes are supported as a fallback) +- Visual appearance or styling beyond the specified properties +- Responsive breakpoints or media queries +- Animation or transition properties +- Content structure or semantic HTML +- Values expressed using `clamp()`, `calc()`, or other non-deterministic CSS functions + +## Static Analysis v1 + +The validator uses static analysis to extract deterministic pixel values from source files. It does not require runtime execution or special CSS custom properties. + +### Extraction Strategy + +The validator extracts values in this priority order: + +1. **Inline styles** on the marked element (`data-contract="page-container"`) + - Parses `style` attribute for `max-width`, `padding-left`, `padding-right`, or `padding-inline` + - Supports px values only + +2. **CSS rules** targeting the marker selector + - Detects rules like `[data-contract="page-container"] { max-width: ...; padding-inline: ...; }` + - Supports px values only + - If values use `clamp()` or `calc()`, returns `layout.pageFrame.unextractable-value` + +3. **Tailwind bracket classes** (best-effort, v1) + - Supports `max-w-[NNNpx]` and `px-[NNpx]` or `pl-[NNpx]/pr-[NNpx]` + - If only tokenized classes exist (e.g., `px-6`), returns `layout.pageFrame.unextractable-value` with guidance + +4. **CSS custom properties** (optional fallback) + - `--contract-page-frame-max-width` and `--contract-page-frame-padding-x` + - Not required, but supported if present + +### Limitations + +Static analysis v1: +- **Requires deterministic px values** - cannot validate `clamp()`, `calc()`, or responsive expressions +- **Requires container marker** - element must have `data-contract="page-container"` attribute +- **Selector support** - v1 only supports `[data-contract="page-container"]` selector +- **Tailwind classes** - only bracket notation (`max-w-[1200px]`) is supported, not tokenized classes + +If values cannot be extracted deterministically, the validator reports `layout.pageFrame.unextractable-value` with guidance on how to express the values. + +## Contract Schema + +Add `pageFrame` as an optional property within a surface's `layout` section: + +```json +{ + "surfaces": [ + { + "id": "my-surface", + "type": "web", + "layout": { + "maxContentWidth": 1200, + "pageFrame": { + "containerSelector": "[data-contract=\"page-container\"]", + "containerMaxWidthPx": 1200, + "paddingXpx": 24, + "alignment": "center", + "enforcement": "strict" + } + } + } + ] +} +``` + +### Schema Properties + +- `containerSelector` (required): CSS selector identifying the primary page container. v1 only supports `[data-contract="page-container"]` +- `containerMaxWidthPx` (required): Expected max-width in pixels +- `paddingXpx` (required): Expected horizontal padding (left and right) in pixels +- `alignment` (optional): `"center"` or `"left"`, defaults to `"center"` (not validated in v1) +- `enforcement` (optional): `"strict"` or `"warn"`, defaults to `"strict"` + - `strict`: Any mismatch causes validation failure (non-zero exit code) + - `warn`: Mismatches are reported but exit code is 0 + +## Implementation Examples + +### Inline Styles (Recommended) + +```tsx +
+ {children} +
+``` + +### CSS Rule on Marker Selector + +```css +[data-contract="page-container"] { + max-width: 1200px; + padding-left: 24px; + padding-right: 24px; +} +``` + +```tsx +
+ {children} +
+``` + +### Tailwind Bracket Classes (Best-Effort) + +```tsx +
+ {children} +
+``` + +## CLI Usage + +Validate pageFrame layouts using static analysis: + +```bash +interfacectl validate \ + --contract path/to/interface.contract.json \ + --root path/to/surface +``` + +Filter to specific surfaces: + +```bash +interfacectl validate \ + --contract path/to/interface.contract.json \ + --root path/to/surface \ + --surface my-surface +``` + +Output in JSON format: + +```bash +interfacectl validate \ + --contract path/to/interface.contract.json \ + --root path/to/surface \ + --format json +``` + +## Violation Types + +### `layout.pageFrame.selector-unsupported` + +The container selector in the contract is not supported in static analysis v1. + +**Resolution**: Use `[data-contract="page-container"]` as the selector. + +### `layout.pageFrame.container-not-found` + +The page container marker (`data-contract="page-container"`) was not found in source files. + +**Resolution**: Add `data-contract="page-container"` to the container element. + +### `layout.pageFrame.maxwidth-mismatch` + +Extracted max-width does not match the expected value. + +**Resolution**: Update the max-width to match the contract value. + +### `layout.pageFrame.padding-mismatch` + +Extracted padding does not match the expected value. + +**Resolution**: Update the padding to match the contract value. + +### `layout.pageFrame.unextractable-value` + +Values could not be extracted deterministically. This occurs when: +- Values use `clamp()`, `calc()`, or other non-deterministic CSS functions +- Only tokenized Tailwind classes are used (e.g., `px-6` instead of `px-[24px]`) +- No matching styles are found in the expected locations + +**Resolution**: Express values using: +- Inline styles with fixed px values +- CSS rules targeting `[data-contract="page-container"]` with fixed px values +- Tailwind bracket classes (e.g., `px-[24px]`) + +### `layout.pageFrame.non-deterministic-value` + +Values were found but use non-deterministic expressions like `clamp()` or `calc()`. + +**Resolution**: Replace with fixed px values or use inline styles. + +## Example Failure Output + +### Text Format + +``` +✖ Contract violations detected + • my-surface: + - Page frame max-width mismatch for surface "my-surface": expected 1200px, found 1400px. + - Page frame padding could not be extracted for surface "my-surface". Expected 24px. Static analysis requires deterministic px values. +``` + +### JSON Format + +```json +{ + "contractPath": "path/to/interface.contract.json", + "contractVersion": "1.0.0", + "summary": { + "errors": 2, + "warnings": 0 + }, + "findings": [ + { + "code": "layout.pageframe.maxwidth-mismatch", + "severity": "error", + "category": "E2", + "surface": "my-surface", + "message": "Page frame max-width mismatch for surface \"my-surface\": expected 1200px, found 1400px.", + "expected": 1200, + "found": 1400, + "location": "app/layout.tsx" + }, + { + "code": "layout.pageframe.unextractable-value", + "severity": "error", + "category": "E2", + "surface": "my-surface", + "message": "Page frame padding could not be extracted for surface \"my-surface\". Expected 24px. Static analysis requires deterministic px values.", + "expected": 24, + "found": { + "left": null, + "right": null + } + } + ] +} +``` + +## Exit Codes + +- `strict` mode with violations → exit code 1 (v1) or 30 (v2) +- `warn` mode with violations → exit code 0 +- No violations → exit code 0 + +## Future Enhancements + +Runtime computed validation may be added later via `--url` option, but is not part of v1. The current implementation focuses on deterministic static analysis that works reliably in CI without requiring a running server. diff --git a/packages/interfacectl-cli/src/commands/validate.ts b/packages/interfacectl-cli/src/commands/validate.ts index d05bb3e..36bd805 100644 --- a/packages/interfacectl-cli/src/commands/validate.ts +++ b/packages/interfacectl-cli/src/commands/validate.ts @@ -220,16 +220,24 @@ export async function runValidateCommand( if (!structureResult.ok || !structureResult.contract) { if (!isJson) { printHeader( - pc.red("✖ Contract structure validation failed"), + pc.red("✖ Contract schema validation failed (capability gap)"), textReporter, ); + textReporter.error( + pc.dim( + "Schema validation errors indicate the contract structure is not supported by this version of interfacectl.", + ), + ); for (const error of structureResult.errors) { textReporter.error(pc.red(` • ${error}`)); } } else { for (const error of structureResult.errors) { + // Check if this is an additionalProperties error (capability gap) + const isCapabilityGap = error.includes("Additional property") || + error.includes("is not allowed"); findings.push({ - code: "contract.schema-error", + code: isCapabilityGap ? "contract.schema-unsupported-field" : "contract.schema-error", severity: "error", category: "E0", message: error, @@ -242,6 +250,29 @@ export async function runValidateCommand( const contract = structureResult.contract; + // Check for deprecated allowedColors fields + for (let i = 0; i < contract.surfaces.length; i++) { + const surface = contract.surfaces[i]; + if (surface.allowedColors !== undefined) { + const finding: JsonFinding = { + code: "contract.deprecated-field", + severity: "warning", + category: "E0", + message: `allowedColors is deprecated. Migrate to color.sourceOfTruth + color.rawValues policy.`, + location: `/surfaces/${i}/allowedColors`, + }; + findings.push(finding); + if (!isJson) { + textReporter.warn( + pc.yellow( + ` • Surface "${surface.id}": allowedColors is deprecated (use color.sourceOfTruth + color.rawValues)`, + ), + ); + } + } + } + + const surfaceFilters = new Set( (options.surfaceFilters ?? []).map((value) => value.trim()), ); @@ -292,29 +323,34 @@ export async function runValidateCommand( if (!isJson) { printSummary(summary, textReporter); } - // Determine exit code based on violation categories let exitCode: number; if (violationFindings.length === 0) { exitCode = 0; } else { - // Find the highest severity category (E2 > E1) - let maxCategory: ViolationCategory | null = null; - for (const finding of violationFindings) { - const category = finding.category; - if (category === "E2") { - maxCategory = "E2"; - break; // E2 is highest, no need to continue - } else if (category === "E1" && (maxCategory === null || maxCategory === "E1")) { - maxCategory = "E1"; + // Filter to only error-level findings for exit code determination + const errorFindings = violationFindings.filter((f) => f.severity === "error"); + if (errorFindings.length === 0) { + exitCode = 0; // Only warnings, don't fail + } else { + // Find the highest severity category (E2 > E1) + let maxCategory: ViolationCategory | null = null; + for (const finding of errorFindings) { + const category = finding.category; + if (category === "E2") { + maxCategory = "E2"; + break; // E2 is highest, no need to continue + } else if (category === "E1" && (maxCategory === null || maxCategory === "E1")) { + maxCategory = "E1"; + } } - } - if (maxCategory) { - exitCode = getExitCodeForCategory(maxCategory, exitCodeVersion); - } else { - // Fallback (should not happen, but handle gracefully) - exitCode = exitCodeVersion === "v2" ? 30 : 1; + if (maxCategory) { + exitCode = getExitCodeForCategory(maxCategory, exitCodeVersion); + } else { + // Fallback (should not happen, but handle gracefully) + exitCode = exitCodeVersion === "v2" ? 30 : 1; + } } // Print deprecation warning for v1 @@ -410,8 +446,16 @@ function mapViolationsToFindings( "layout-width-exceeded": "layout.width-exceeded", "layout-width-undetermined": "layout.width-undetermined", "layout-container-missing": "layout.container-missing", + "layout-pageframe-container-not-found": "layout.pageframe.container-not-found", + "layout-pageframe-maxwidth-mismatch": "layout.pageframe.maxwidth-mismatch", + "layout-pageframe-padding-mismatch": "layout.pageframe.padding-mismatch", + "layout-pageframe-selector-unsupported": "layout.pageframe.selector-unsupported", + "layout-pageframe-non-deterministic-value": "layout.pageframe.non-deterministic-value", + "layout-pageframe-unextractable-value": "layout.pageframe.unextractable-value", "motion-duration-not-allowed": "motion.duration", "motion-timing-not-allowed": "motion.timing", + "color-raw-value-used": "color.raw-value.used", + "color-token-namespace-violation": "color.token.namespace.violation", }; for (const report of summary.surfaceReports) { @@ -473,6 +517,45 @@ function mapViolationsToFindings( details.missingContainers ?? details.containerSources; break; } + case "layout-pageframe-selector-unsupported": { + finding.expected = details.supportedSelectors; + finding.found = details.selector; + break; + } + case "layout-pageframe-container-not-found": { + finding.expected = details.selector; + finding.found = null; + break; + } + case "layout-pageframe-maxwidth-mismatch": { + finding.expected = details.expected; + finding.found = details.actual; + break; + } + case "layout-pageframe-padding-mismatch": { + finding.expected = details.expected; + finding.found = { + left: details.actualLeft, + right: details.actualRight, + }; + break; + } + case "layout-pageframe-non-deterministic-value": { + finding.expected = details.expected; + finding.found = details.actual ?? { + left: details.actualLeft, + right: details.actualRight, + }; + break; + } + case "layout-pageframe-unextractable-value": { + finding.expected = details.expected; + finding.found = details.actual ?? { + left: details.actualLeft, + right: details.actualRight, + }; + break; + } case "motion-duration-not-allowed": { finding.expected = details.allowedDurations; finding.found = details.durationMs; @@ -498,6 +581,26 @@ function mapViolationsToFindings( finding.found = violation.surfaceId; break; } + case "color-raw-value-used": { + finding.expected = details.allowedValues; + finding.found = details.colorValue; + // Set severity based on policy: "warn" -> warning, "strict" -> error + if (details.policy === "warn") { + finding.severity = "warning"; + } else if (details.policy === "strict") { + finding.severity = "error"; + } + break; + } + case "color-token-namespace-violation": { + finding.expected = details.allowedNamespaces; + finding.found = details.tokenName; + // Token namespace violations are warnings by default + finding.severity = "warning"; + break; + } + } + default: break; } @@ -614,7 +717,10 @@ function printSummary( return; } - printHeader(pc.red("✖ Contract violations detected"), output); + printHeader(pc.red("✖ Surface compliance violations detected"), output); + output.log( + pc.dim("Compliance violations indicate surfaces do not match the contract requirements."), + ); for (const report of summary.surfaceReports) { if (report.violations.length === 0) { diff --git a/packages/interfacectl-cli/src/descriptors/static-analysis.ts b/packages/interfacectl-cli/src/descriptors/static-analysis.ts index 60c94c9..9af1522 100644 --- a/packages/interfacectl-cli/src/descriptors/static-analysis.ts +++ b/packages/interfacectl-cli/src/descriptors/static-analysis.ts @@ -9,6 +9,7 @@ import { type SurfaceLayoutDescriptor, type SurfaceMotionDescriptor, type SurfaceSectionDescriptor, + type PageFrameLayoutDescriptor, } from "@surfaces/interfacectl-validator"; const SECTION_ATTRIBUTE_REGEX = @@ -17,6 +18,33 @@ const SECTION_ATTRIBUTE_REGEX = const CONTAINER_ATTRIBUTE_REGEX = /data-contract-container\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; const CONTRACT_CONTAINER_TOKEN = "contract-container"; +const PAGE_CONTAINER_ATTRIBUTE_REGEX = + /data-contract\s*=\s*(?:"page-container"|'page-container'|{`page-container`}|{\s*["'`]page-container["'`]\s*})/g; +// Inline style extraction +const INLINE_STYLE_REGEX = /style\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const INLINE_MAX_WIDTH_REGEX = /max-width\s*:\s*([0-9.]+)\s*px/gi; +const INLINE_PADDING_LEFT_REGEX = /padding-left\s*:\s*([0-9.]+)\s*px/gi; +const INLINE_PADDING_RIGHT_REGEX = /padding-right\s*:\s*([0-9.]+)\s*px/gi; +const INLINE_PADDING_INLINE_REGEX = /padding-inline\s*:\s*([0-9.]+)\s*px/gi; +// CSS rule extraction for [data-contract="page-container"] +const CSS_SELECTOR_PAGE_CONTAINER_REGEX = /\[data-contract\s*=\s*["']page-container["']\]\s*\{([^}]+)\}/gi; +const CSS_MAX_WIDTH_REGEX = /max-width\s*:\s*([0-9.]+)\s*px/gi; +const CSS_PADDING_LEFT_REGEX = /padding-left\s*:\s*([0-9.]+)\s*px/gi; +const CSS_PADDING_RIGHT_REGEX = /padding-right\s*:\s*([0-9.]+)\s*px/gi; +const CSS_PADDING_INLINE_REGEX = /padding-inline\s*:\s*([0-9.]+)\s*px/gi; +// Tailwind class extraction (best-effort) +const TAILWIND_MAX_WIDTH_REGEX = /max-w-\[([0-9.]+)px\]/gi; +const TAILWIND_PADDING_X_REGEX = /px-\[([0-9.]+)px\]/gi; +const TAILWIND_PADDING_LEFT_REGEX = /pl-\[([0-9.]+)px\]/gi; +const TAILWIND_PADDING_RIGHT_REGEX = /pr-\[([0-9.]+)px\]/gi; +// Non-deterministic value detection +const CLAMP_REGEX = /clamp\s*\(/i; +const CALC_REGEX = /calc\s*\(/i; +// Optional CSS custom properties (fallback) +const PAGE_FRAME_MAX_WIDTH_VAR_REGEX = + /--contract-page-frame-max-width\s*:\s*([0-9.]+)\s*px/i; +const PAGE_FRAME_PADDING_VAR_REGEX = + /--contract-page-frame-padding-x\s*:\s*([0-9.]+)\s*px/i; const COMMON_GLOBBY_IGNORES = [ "**/node_modules/**", "**/.next/**", @@ -93,11 +121,12 @@ export async function collectSurfaceDescriptors( continue; } - const descriptorResult = await extractSurfaceDescriptor( - options.workspaceRoot, - surfaceRoot, - surface.id, - ); + const descriptorResult = await extractSurfaceDescriptor( + options.workspaceRoot, + surfaceRoot, + surface.id, + surface, + ); structuralDescriptors.push(descriptorResult.descriptor); warnings.push(...descriptorResult.warnings); @@ -123,6 +152,7 @@ async function extractSurfaceDescriptor( workspaceRoot: string, surfaceRoot: string, surfaceId: string, + surface?: InterfaceContract["surfaces"][number], ): Promise<{ descriptor: SurfaceDescriptor; warnings: DescriptorIssue[]; @@ -165,6 +195,7 @@ async function extractSurfaceDescriptor( sectionFiles, workspaceRoot, fileContentCache, + surface, ); const fonts = await extractFonts( surfaceRoot, @@ -354,6 +385,7 @@ async function extractLayout( sectionFiles: string[], workspaceRoot: string, fileContentCache: Map, + surface?: InterfaceContract["surfaces"][number], ): Promise { let maxWidth: number | null = null; let layoutSource: string | undefined; @@ -385,11 +417,336 @@ async function extractLayout( } } + // Extract pageFrame layout if contract defines it + let pageFrame: PageFrameLayoutDescriptor | undefined; + if (surface?.layout.pageFrame) { + pageFrame = await extractPageFrameLayout( + cssFilePaths, + sectionFiles, + workspaceRoot, + fileContentCache, + surface.layout.pageFrame, + ); + } + return { maxContentWidth: maxWidth, containers: [...containers].sort(), containerSources: [...containerSources].sort(), source: layoutSource, + pageFrame, + }; +} + +async function extractPageFrameLayout( + cssFilePaths: string[], + sectionFiles: string[], + workspaceRoot: string, + fileContentCache: Map, + pageFrameContract: InterfaceContract["surfaces"][number]["layout"]["pageFrame"], +): Promise { + if (!pageFrameContract) { + return undefined; + } + + const containerSelector = pageFrameContract.containerSelector; + + // Check if selector is supported (v1 only supports data-contract="page-container") + const isSupportedSelector = + containerSelector === '[data-contract="page-container"]' || + containerSelector === "[data-contract='page-container']" || + containerSelector === '[data-contract={page-container}]'; + + if (!isSupportedSelector) { + // Return undefined to trigger selectorUnsupported violation + return undefined; + } + + // Check if page-container marker exists in source files + let containerFound = false; + let containerSource: string | undefined; + let containerFileContent: string | undefined; + for (const filePath of sectionFiles) { + const content = await readFileCached(filePath, fileContentCache); + // Reset regex state + PAGE_CONTAINER_ATTRIBUTE_REGEX.lastIndex = 0; + if (PAGE_CONTAINER_ATTRIBUTE_REGEX.test(content)) { + containerFound = true; + containerSource = path.relative(workspaceRoot, filePath); + containerFileContent = content; + break; + } + } + + if (!containerFound) { + // Container marker not found - return partial descriptor + return { + containerSelector, + maxWidthPx: null, + paddingLeftPx: null, + paddingRightPx: null, + source: undefined, + maxWidthHasClampCalc: undefined, + paddingHasClampCalc: undefined, + }; + } + + let maxWidthPx: number | null = null; + let paddingLeftPx: number | null = null; + let paddingRightPx: number | null = null; + let extractionSource: string | undefined; + let maxWidthHasClampCalc = false; + let paddingHasClampCalc = false; + + // Strategy A: Extract from inline styles on the marked element + if (containerFileContent) { + INLINE_STYLE_REGEX.lastIndex = 0; + let styleMatch: RegExpExecArray | null; + while ((styleMatch = INLINE_STYLE_REGEX.exec(containerFileContent)) !== null) { + const styleContent = + styleMatch[1] ?? styleMatch[2] ?? styleMatch[3] ?? styleMatch[4] ?? ""; + + // Extract max-width + if (maxWidthPx === null) { + INLINE_MAX_WIDTH_REGEX.lastIndex = 0; + const maxWidthMatch = INLINE_MAX_WIDTH_REGEX.exec(styleContent); + if (maxWidthMatch) { + const maxWidthValue = maxWidthMatch[0]; + // Check if this specific max-width value uses clamp/calc + if (CLAMP_REGEX.test(maxWidthValue) || CALC_REGEX.test(maxWidthValue)) { + maxWidthHasClampCalc = true; + } else { + const value = Number.parseFloat(maxWidthMatch[1]); + if (Number.isFinite(value)) { + maxWidthPx = value; + extractionSource = containerSource; + } + } + } + } + + // Extract padding + if (paddingLeftPx === null || paddingRightPx === null) { + INLINE_PADDING_INLINE_REGEX.lastIndex = 0; + const paddingInlineMatch = INLINE_PADDING_INLINE_REGEX.exec(styleContent); + if (paddingInlineMatch) { + const paddingValue = paddingInlineMatch[0]; + // Check if this specific padding value uses clamp/calc + if (CLAMP_REGEX.test(paddingValue) || CALC_REGEX.test(paddingValue)) { + paddingHasClampCalc = true; + } else { + const value = Number.parseFloat(paddingInlineMatch[1]); + if (Number.isFinite(value)) { + paddingLeftPx = value; + paddingRightPx = value; + extractionSource = containerSource; + } + } + } else { + INLINE_PADDING_LEFT_REGEX.lastIndex = 0; + INLINE_PADDING_RIGHT_REGEX.lastIndex = 0; + const leftMatch = INLINE_PADDING_LEFT_REGEX.exec(styleContent); + const rightMatch = INLINE_PADDING_RIGHT_REGEX.exec(styleContent); + if (leftMatch && rightMatch) { + const leftValueStr = leftMatch[0]; + const rightValueStr = rightMatch[0]; + // Check if padding values use clamp/calc + if ( + CLAMP_REGEX.test(leftValueStr) || + CALC_REGEX.test(leftValueStr) || + CLAMP_REGEX.test(rightValueStr) || + CALC_REGEX.test(rightValueStr) + ) { + paddingHasClampCalc = true; + } else { + const leftValue = Number.parseFloat(leftMatch[1]); + const rightValue = Number.parseFloat(rightMatch[1]); + if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) { + paddingLeftPx = leftValue; + paddingRightPx = rightValue; + extractionSource = containerSource; + } + } + } + } + } + } + } + + // Strategy B: Extract from CSS rules targeting [data-contract="page-container"] + if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) { + for (const cssPath of cssFilePaths) { + const cssContent = await readFileCached(cssPath, fileContentCache); + + CSS_SELECTOR_PAGE_CONTAINER_REGEX.lastIndex = 0; + let selectorMatch: RegExpExecArray | null; + while ((selectorMatch = CSS_SELECTOR_PAGE_CONTAINER_REGEX.exec(cssContent)) !== null) { + const ruleContent = selectorMatch[1]; + + // Extract max-width (check for clamp/calc in max-width declaration) + if (maxWidthPx === null) { + // First check if max-width exists (even with clamp/calc) + const maxWidthDeclMatch = /max-width\s*:\s*([^;]+)/i.exec(ruleContent); + if (maxWidthDeclMatch) { + const maxWidthValue = maxWidthDeclMatch[1].trim(); + // Check if this specific max-width value uses clamp/calc + if (CLAMP_REGEX.test(maxWidthValue) || CALC_REGEX.test(maxWidthValue)) { + maxWidthHasClampCalc = true; + } else { + // Try to extract px value + CSS_MAX_WIDTH_REGEX.lastIndex = 0; + const maxWidthMatch = CSS_MAX_WIDTH_REGEX.exec(ruleContent); + if (maxWidthMatch) { + const value = Number.parseFloat(maxWidthMatch[1]); + if (Number.isFinite(value)) { + maxWidthPx = value; + extractionSource = path.relative(workspaceRoot, cssPath); + } + } + } + } + } + + // Extract padding (check for clamp/calc in padding declarations) + if (paddingLeftPx === null || paddingRightPx === null) { + // First check if padding-inline exists (even with clamp/calc) + const paddingInlineDeclMatch = /padding-inline\s*:\s*([^;]+)/i.exec(ruleContent); + if (paddingInlineDeclMatch) { + const paddingValue = paddingInlineDeclMatch[1].trim(); + // Check if this specific padding value uses clamp/calc + if (CLAMP_REGEX.test(paddingValue) || CALC_REGEX.test(paddingValue)) { + paddingHasClampCalc = true; + } else { + // Try to extract px value + CSS_PADDING_INLINE_REGEX.lastIndex = 0; + const paddingInlineMatch = CSS_PADDING_INLINE_REGEX.exec(ruleContent); + if (paddingInlineMatch) { + const value = Number.parseFloat(paddingInlineMatch[1]); + if (Number.isFinite(value)) { + paddingLeftPx = value; + paddingRightPx = value; + extractionSource = path.relative(workspaceRoot, cssPath); + } + } + } + } else { + CSS_PADDING_LEFT_REGEX.lastIndex = 0; + CSS_PADDING_RIGHT_REGEX.lastIndex = 0; + const leftMatch = CSS_PADDING_LEFT_REGEX.exec(ruleContent); + const rightMatch = CSS_PADDING_RIGHT_REGEX.exec(ruleContent); + if (leftMatch && rightMatch) { + const leftValueStr = leftMatch[0]; + const rightValueStr = rightMatch[0]; + // Check if padding values use clamp/calc + if ( + CLAMP_REGEX.test(leftValueStr) || + CALC_REGEX.test(leftValueStr) || + CLAMP_REGEX.test(rightValueStr) || + CALC_REGEX.test(rightValueStr) + ) { + paddingHasClampCalc = true; + } else { + const leftValue = Number.parseFloat(leftMatch[1]); + const rightValue = Number.parseFloat(rightMatch[1]); + if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) { + paddingLeftPx = leftValue; + paddingRightPx = rightValue; + extractionSource = path.relative(workspaceRoot, cssPath); + } + } + } + } + } + } + } + } + + // Strategy C: Extract from Tailwind bracket classes (best-effort, v1) + if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) { + for (const filePath of sectionFiles) { + const content = await readFileCached(filePath, fileContentCache); + + // Extract max-width from max-w-[NNNpx] + if (maxWidthPx === null) { + TAILWIND_MAX_WIDTH_REGEX.lastIndex = 0; + const maxWidthMatch = TAILWIND_MAX_WIDTH_REGEX.exec(content); + if (maxWidthMatch) { + const value = Number.parseFloat(maxWidthMatch[1]); + if (Number.isFinite(value)) { + maxWidthPx = value; + extractionSource = path.relative(workspaceRoot, filePath); + } + } + } + + // Extract padding from px-[NNpx] or pl-[NNpx]/pr-[NNpx] + if (paddingLeftPx === null || paddingRightPx === null) { + TAILWIND_PADDING_X_REGEX.lastIndex = 0; + const paddingXMatch = TAILWIND_PADDING_X_REGEX.exec(content); + if (paddingXMatch) { + const value = Number.parseFloat(paddingXMatch[1]); + if (Number.isFinite(value)) { + paddingLeftPx = value; + paddingRightPx = value; + extractionSource = path.relative(workspaceRoot, filePath); + } + } else { + TAILWIND_PADDING_LEFT_REGEX.lastIndex = 0; + TAILWIND_PADDING_RIGHT_REGEX.lastIndex = 0; + const leftMatch = TAILWIND_PADDING_LEFT_REGEX.exec(content); + const rightMatch = TAILWIND_PADDING_RIGHT_REGEX.exec(content); + if (leftMatch && rightMatch) { + const leftValue = Number.parseFloat(leftMatch[1]); + const rightValue = Number.parseFloat(rightMatch[1]); + if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) { + paddingLeftPx = leftValue; + paddingRightPx = rightValue; + extractionSource = path.relative(workspaceRoot, filePath); + } + } + } + } + } + } + + // Optional: CSS custom properties (fallback, not required) + if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) { + for (const cssPath of cssFilePaths) { + const cssContent = await readFileCached(cssPath, fileContentCache); + + if (maxWidthPx === null) { + const varMatch = cssContent.match(PAGE_FRAME_MAX_WIDTH_VAR_REGEX); + if (varMatch) { + const value = Number.parseFloat(varMatch[1]); + if (Number.isFinite(value)) { + maxWidthPx = value; + extractionSource = path.relative(workspaceRoot, cssPath); + } + } + } + + if (paddingLeftPx === null || paddingRightPx === null) { + const paddingXMatch = cssContent.match(PAGE_FRAME_PADDING_VAR_REGEX); + if (paddingXMatch) { + const value = Number.parseFloat(paddingXMatch[1]); + if (Number.isFinite(value)) { + paddingLeftPx = value; + paddingRightPx = value; + extractionSource = path.relative(workspaceRoot, cssPath); + } + } + } + } + } + + return { + containerSelector, + maxWidthPx, + paddingLeftPx, + paddingRightPx, + source: extractionSource ?? containerSource, + maxWidthHasClampCalc: maxWidthHasClampCalc || undefined, + paddingHasClampCalc: paddingHasClampCalc || undefined, }; } diff --git a/packages/interfacectl-cli/src/utils/violation-classifier.ts b/packages/interfacectl-cli/src/utils/violation-classifier.ts index 52be6f0..90666ae 100644 --- a/packages/interfacectl-cli/src/utils/violation-classifier.ts +++ b/packages/interfacectl-cli/src/utils/violation-classifier.ts @@ -17,6 +17,8 @@ export function classifyViolationType( "color-not-allowed", "motion-duration-not-allowed", "motion-timing-not-allowed", + "color-raw-value-used", + "color-token-namespace-violation", ]; if (e1Types.includes(type)) { diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/globals.css b/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/globals.css new file mode 100644 index 0000000..1aff918 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/globals.css @@ -0,0 +1,4 @@ +[data-contract="page-container"] { + max-width: 1200px; + padding-inline: clamp(16px, 2vw, 24px); +} diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/layout.tsx new file mode 100644 index 0000000..d8ea5ba --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/layout.tsx @@ -0,0 +1,8 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+
Header
+ {children} +
+ ); +} diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.json b/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.json new file mode 100644 index 0000000..4efbeb8 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.json @@ -0,0 +1,37 @@ +{ + "contractId": "test-pageframe-static", + "version": "1.0.0", + "surfaces": [ + { + "id": "test-surface", + "displayName": "Test Surface", + "type": "web", + "requiredSections": ["header"], + "allowedFonts": ["Inter"], + "allowedColors": ["#000000"], + "layout": { + "maxContentWidth": 1200, + "pageFrame": { + "containerSelector": "[data-contract=\"page-container\"]", + "containerMaxWidthPx": 1200, + "paddingXpx": 24, + "alignment": "center", + "enforcement": "strict" + } + } + } + ], + "sections": [ + { + "id": "header", + "intent": "Page header", + "description": "Main page header section" + } + ], + "constraints": { + "motion": { + "allowedDurationsMs": [200, 300], + "allowedTimingFunctions": ["ease", "ease-in-out"] + } + } +} diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/globals.css b/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/globals.css new file mode 100644 index 0000000..68de65b --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/globals.css @@ -0,0 +1,5 @@ +[data-contract="page-container"] { + max-width: 1200px; + padding-left: 24px; + padding-right: 24px; +} diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/layout.tsx new file mode 100644 index 0000000..62612a3 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/inline-styles/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/inline-styles/app/layout.tsx new file mode 100644 index 0000000..86e4931 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/inline-styles/app/layout.tsx @@ -0,0 +1,14 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/maxwidth-fail/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/maxwidth-fail/app/layout.tsx new file mode 100644 index 0000000..099a154 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/maxwidth-fail/app/layout.tsx @@ -0,0 +1,14 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/missing-marker/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/missing-marker/app/layout.tsx new file mode 100644 index 0000000..8e125ef --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/missing-marker/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/tailwind/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/tailwind/app/layout.tsx new file mode 100644 index 0000000..c8b53cd --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/tailwind/app/layout.tsx @@ -0,0 +1,10 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/interfacectl-cli/test/fixtures/pageframe/contract.json b/packages/interfacectl-cli/test/fixtures/pageframe/contract.json new file mode 100644 index 0000000..cb5d44d --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe/contract.json @@ -0,0 +1,37 @@ +{ + "contractId": "test-pageframe", + "version": "1.0.0", + "surfaces": [ + { + "id": "test-surface", + "displayName": "Test Surface", + "type": "web", + "requiredSections": ["header"], + "allowedFonts": ["Inter"], + "allowedColors": ["#000000"], + "layout": { + "maxContentWidth": 1200, + "pageFrame": { + "containerSelector": ".container", + "containerMaxWidthPx": 1200, + "paddingXpx": 24, + "alignment": "center", + "enforcement": "strict" + } + } + } + ], + "sections": [ + { + "id": "header", + "intent": "Page header", + "description": "Main page header section" + } + ], + "constraints": { + "motion": { + "allowedDurationsMs": [200, 300], + "allowedTimingFunctions": ["ease", "ease-in-out"] + } + } +} diff --git a/packages/interfacectl-cli/test/fixtures/pageframe/failing.html b/packages/interfacectl-cli/test/fixtures/pageframe/failing.html new file mode 100644 index 0000000..4adff72 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe/failing.html @@ -0,0 +1,23 @@ + + + + + + Failing Page Frame Test + + + +
+

Test Page

+

This page should fail pageFrame validation.

+
+ + diff --git a/packages/interfacectl-cli/test/fixtures/pageframe/passing.html b/packages/interfacectl-cli/test/fixtures/pageframe/passing.html new file mode 100644 index 0000000..0798a85 --- /dev/null +++ b/packages/interfacectl-cli/test/fixtures/pageframe/passing.html @@ -0,0 +1,29 @@ + + + + + + Passing Page Frame Test + + + +
+

Test Page

+

This page should pass pageFrame validation with:

+
    +
  • containerSelector: ".container"
  • +
  • containerMaxWidthPx: 1200
  • +
  • paddingXpx: 24
  • +
  • alignment: "center"
  • +
+
+ + diff --git a/packages/interfacectl-cli/test/pageframe-static.test.mjs b/packages/interfacectl-cli/test/pageframe-static.test.mjs new file mode 100644 index 0000000..b1e239a --- /dev/null +++ b/packages/interfacectl-cli/test/pageframe-static.test.mjs @@ -0,0 +1,136 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { spawn } from "node:child_process"; +import { promisify } from "node:util"; +import path from "node:path"; +import url from "node:url"; +import { readFile } from "node:fs/promises"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +const fixturesDir = path.join(__dirname, "fixtures", "pageframe-static"); +const contractPath = path.join(fixturesDir, "contract.json"); + +async function runValidate(fixtureName) { + const fixtureRoot = path.join(fixturesDir, fixtureName); + return new Promise((resolve, reject) => { + const proc = spawn( + "node", + [ + path.join(__dirname, "..", "dist", "index.js"), + "validate", + "--contract", + contractPath, + "--root", + fixtureRoot, + "--format", + "json", + ], + { + cwd: path.join(__dirname, ".."), + }, + ); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + resolve({ code, stdout, stderr }); + }); + + proc.on("error", (error) => { + reject(error); + }); + }); +} + +test("pageFrame validation - inline styles (passing)", async () => { + const result = await runValidate("inline-styles"); + const output = JSON.parse(result.stdout); + + // Should pass - no pageFrame violations + const pageFrameViolations = output.findings.filter((f) => + f.code.startsWith("layout.pageframe."), + ); + assert.strictEqual(pageFrameViolations.length, 0, "Should have no pageFrame violations"); + assert.strictEqual(result.code, 0, "Should exit with code 0"); +}); + +test("pageFrame validation - CSS rule (passing)", async () => { + const result = await runValidate("css-rule"); + const output = JSON.parse(result.stdout); + + const pageFrameViolations = output.findings.filter((f) => + f.code.startsWith("layout.pageframe."), + ); + assert.strictEqual(pageFrameViolations.length, 0, "Should have no pageFrame violations"); + assert.strictEqual(result.code, 0, "Should exit with code 0"); +}); + +test("pageFrame validation - Tailwind classes (passing)", async () => { + const result = await runValidate("tailwind"); + const output = JSON.parse(result.stdout); + + const pageFrameViolations = output.findings.filter((f) => + f.code.startsWith("layout.pageframe."), + ); + assert.strictEqual(pageFrameViolations.length, 0, "Should have no pageFrame violations"); + assert.strictEqual(result.code, 0, "Should exit with code 0"); +}); + +test("pageFrame validation - clamp() padding (non-deterministic)", async () => { + const result = await runValidate("clamp-padding"); + const output = JSON.parse(result.stdout); + + const pageFrameViolations = output.findings.filter((f) => + f.code.startsWith("layout.pageframe."), + ); + + // Should have non-deterministic-value violation for padding (not unextractable-value) + const paddingViolation = pageFrameViolations.find( + (v) => v.code === "layout.pageframe.non-deterministic-value", + ); + assert.ok(paddingViolation, "Should have non-deterministic-value violation for padding"); + // Check that the violation details include the property + assert.ok(paddingViolation.expected !== undefined, "Violation should have expected value"); + assert.strictEqual(result.code, 1, "Should exit with code 1 (strict mode)"); +}); + +test("pageFrame validation - missing marker (container not found)", async () => { + const result = await runValidate("missing-marker"); + const output = JSON.parse(result.stdout); + + const pageFrameViolations = output.findings.filter((f) => + f.code.startsWith("layout.pageframe."), + ); + + const containerViolation = pageFrameViolations.find( + (v) => v.code === "layout.pageframe.container-not-found", + ); + assert.ok(containerViolation, "Should have container-not-found violation"); + assert.strictEqual(result.code, 1, "Should exit with code 1"); +}); + +test("pageFrame validation - max-width mismatch (failing)", async () => { + const result = await runValidate("maxwidth-fail"); + const output = JSON.parse(result.stdout); + + const pageFrameViolations = output.findings.filter((f) => + f.code.startsWith("layout.pageframe."), + ); + + const maxWidthViolation = pageFrameViolations.find( + (v) => v.code === "layout.pageframe.maxwidth-mismatch", + ); + assert.ok(maxWidthViolation, "Should have maxwidth-mismatch violation"); + assert.strictEqual(maxWidthViolation.expected, 1200); + assert.strictEqual(maxWidthViolation.found, 1400); + assert.strictEqual(result.code, 1, "Should exit with code 1"); +}); diff --git a/packages/interfacectl-cli/test/pageframe-validator.test.mjs b/packages/interfacectl-cli/test/pageframe-validator.test.mjs new file mode 100644 index 0000000..0a14989 --- /dev/null +++ b/packages/interfacectl-cli/test/pageframe-validator.test.mjs @@ -0,0 +1,72 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import url from "node:url"; +import { spawn } from "node:child_process"; +import { promisify } from "node:util"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +const fixturesDir = path.join(__dirname, "fixtures", "pageframe"); + +// Helper to parse px values (replicate logic from validator) +function parsePxValue(cssValue) { + if (cssValue === "none" || cssValue === "auto") { + return null; + } + const match = cssValue.match(/^([\d.]+)px$/); + if (match) { + return parseFloat(match[1]); + } + return null; +} + +test("parsePxValue - valid pixel values", () => { + assert.strictEqual(parsePxValue("1200px"), 1200); + assert.strictEqual(parsePxValue("0px"), 0); + assert.strictEqual(parsePxValue("24.5px"), 24.5); + assert.strictEqual(parsePxValue("1000px"), 1000); +}); + +test("parsePxValue - invalid values", () => { + assert.strictEqual(parsePxValue("auto"), null); + assert.strictEqual(parsePxValue("none"), null); + assert.strictEqual(parsePxValue("100%"), null); + assert.strictEqual(parsePxValue("100"), null); + assert.strictEqual(parsePxValue(""), null); +}); + +test("parsePxValue - style comparison tolerance", () => { + const expected = 1200; + const actual1 = 1200; + const actual2 = 1201; + const actual3 = 1199; + const tolerance = 0; + + assert.strictEqual(Math.abs(actual1 - expected) <= tolerance, true); + assert.strictEqual(Math.abs(actual2 - expected) <= tolerance, false); + assert.strictEqual(Math.abs(actual3 - expected) <= tolerance, false); + + const tolerance2 = 2; + assert.strictEqual(Math.abs(actual2 - expected) <= tolerance2, true); + assert.strictEqual(Math.abs(actual3 - expected) <= tolerance2, true); +}); + +// Integration test with HTML fixtures +test("pageFrame validation - integration with HTML fixture", async () => { + // Create a simple HTTP server fixture + const fixturePath = path.join(fixturesDir, "passing.html"); + + // Ensure fixtures directory exists + try { + await readFile(fixturePath); + } catch (error) { + // Skip test if fixture doesn't exist yet + console.log("Skipping integration test - fixtures not set up"); + return; + } + + // This test would require a running server, so we'll mark it as a placeholder + // for now. In a real scenario, we'd start a local server and test against it. + assert.ok(true, "Integration test placeholder"); +}); diff --git a/packages/interfacectl-validator/dist/index.d.ts b/packages/interfacectl-validator/dist/index.d.ts index c1534c2..8049b3e 100644 --- a/packages/interfacectl-validator/dist/index.d.ts +++ b/packages/interfacectl-validator/dist/index.d.ts @@ -8,6 +8,6 @@ export interface ContractStructureValidation { export declare function validateContractStructure(contractData: unknown, schema: object): ContractStructureValidation; export declare function evaluateSurfaceCompliance(contract: InterfaceContract, descriptor: SurfaceDescriptor): SurfaceReport; export declare function evaluateContractCompliance(contract: InterfaceContract, descriptors: SurfaceDescriptor[]): ValidationSummary; -export type { InterfaceContract, ContractSurface, ContractSection, ContractConstraints, SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, SurfaceColorDescriptor, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, SurfaceReport, DriftViolation, ValidationSummary, DriftViolationType, DiffOutput, DiffEntry, DiffChangeType, DriftRisk, Severity, SafetyLevel, EnforcementPolicy, EnforcementMode, AutofixRule, FixSummary, FixEntry, FixError, } from "./types.js"; +export type { InterfaceContract, ContractSurface, ContractSection, ContractConstraints, SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, SurfaceColorDescriptor, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, PageFrameLayoutDescriptor, SurfaceReport, DriftViolation, ValidationSummary, DriftViolationType, DiffOutput, DiffEntry, DiffChangeType, DriftRisk, Severity, SafetyLevel, EnforcementPolicy, EnforcementMode, AutofixRule, FixSummary, FixEntry, FixError, } from "./types.js"; export { getBundledDiffSchema, getBundledPolicySchema, getBundledFixSummarySchema, validateDiffOutput, validatePolicy, validateFixSummary, type ValidationResult, } from "./schema-validate.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/index.d.ts.map b/packages/interfacectl-validator/dist/index.d.ts.map index 120703b..a94b421 100644 --- a/packages/interfacectl-validator/dist/index.d.ts.map +++ b/packages/interfacectl-validator/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EAIlB,MAAM,YAAY,CAAC;AAOpB,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,OAAO,EACrB,MAAM,EAAE,MAAM,GACb,2BAA2B,CAyB7B;AAED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CAyKf;AAED,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,iBAAiB,EAAE,GAC/B,iBAAiB,CA6CnB;AA8BD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,uBAAuB,EACvB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,UAAU,EACV,SAAS,EACT,cAAc,EACd,SAAS,EACT,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,UAAU,EACV,QAAQ,EACR,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,0BAA0B,EAC1B,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,KAAK,gBAAgB,GACtB,MAAM,sBAAsB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EAKlB,MAAM,YAAY,CAAC;AAOpB,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,OAAO,EACrB,MAAM,EAAE,MAAM,GACb,2BAA2B,CAyB7B;AAED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CA6Tf;AAED,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,iBAAiB,EAAE,GAC/B,iBAAiB,CA6CnB;AAyCD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,UAAU,EACV,SAAS,EACT,cAAc,EACd,SAAS,EACT,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,UAAU,EACV,QAAQ,EACR,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,0BAA0B,EAC1B,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,KAAK,gBAAgB,GACtB,MAAM,sBAAsB,CAAC"} \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/index.js b/packages/interfacectl-validator/dist/index.js index e1c0eee..305b74a 100644 --- a/packages/interfacectl-validator/dist/index.js +++ b/packages/interfacectl-validator/dist/index.js @@ -170,6 +170,148 @@ export function evaluateSurfaceCompliance(contract, descriptor) { }); } } + // Validate pageFrame layout if contract defines it + if (surface.layout.pageFrame && descriptor.layout.pageFrame) { + const pageFrameContract = surface.layout.pageFrame; + const pageFrameDescriptor = descriptor.layout.pageFrame; + const enforcement = pageFrameContract.enforcement ?? "strict"; + // Check if selector is supported + const containerSelector = pageFrameContract.containerSelector; + const isSupportedSelector = containerSelector === '[data-contract="page-container"]' || + containerSelector === "[data-contract='page-container']" || + containerSelector === '[data-contract={page-container}]'; + if (!isSupportedSelector) { + const violation = { + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-selector-unsupported", + message: `Page frame container selector "${containerSelector}" is not supported in static analysis. Use '[data-contract="page-container"]' instead.`, + details: { + selector: containerSelector, + supportedSelectors: ['[data-contract="page-container"]'], + }, + }; + violations.push(violation); + } + else { + // Validate container exists + if (pageFrameDescriptor.maxWidthPx === null && + pageFrameDescriptor.paddingLeftPx === null && + pageFrameDescriptor.paddingRightPx === null) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-container-not-found", + message: `Page container with data-contract="page-container" not found for surface "${descriptor.surfaceId}".`, + details: { + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + else { + // Validate max-width + const expectedMaxWidth = pageFrameContract.containerMaxWidthPx; + const actualMaxWidth = pageFrameDescriptor.maxWidthPx; + if (actualMaxWidth === null) { + // Check if clamp/calc was detected + if (pageFrameDescriptor.maxWidthHasClampCalc) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-non-deterministic-value", + message: `Page frame max-width uses non-deterministic expression (clamp/calc) for surface "${descriptor.surfaceId}". Expected ${expectedMaxWidth}px. Static analysis requires deterministic px values. Use fixed px values in inline styles or CSS rules targeting [data-contract="page-container"].`, + details: { + property: "max-width", + expected: expectedMaxWidth, + actual: null, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + else { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-unextractable-value", + message: `Page frame max-width could not be extracted for surface "${descriptor.surfaceId}". Expected ${expectedMaxWidth}px. Use inline styles, CSS rules targeting [data-contract="page-container"], or Tailwind bracket classes (max-w-[${expectedMaxWidth}px]).`, + details: { + property: "max-width", + expected: expectedMaxWidth, + actual: null, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + } + else if (actualMaxWidth !== expectedMaxWidth) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-maxwidth-mismatch", + message: `Page frame max-width mismatch for surface "${descriptor.surfaceId}": expected ${expectedMaxWidth}px, found ${actualMaxWidth}px.`, + details: { + expected: expectedMaxWidth, + actual: actualMaxWidth, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + // Validate padding + const expectedPadding = pageFrameContract.paddingXpx; + const actualPaddingLeft = pageFrameDescriptor.paddingLeftPx; + const actualPaddingRight = pageFrameDescriptor.paddingRightPx; + if (actualPaddingLeft === null || actualPaddingRight === null) { + // Check if clamp/calc was detected + if (pageFrameDescriptor.paddingHasClampCalc) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-non-deterministic-value", + message: `Page frame padding uses non-deterministic expression (clamp/calc) for surface "${descriptor.surfaceId}". Expected ${expectedPadding}px. Static analysis requires deterministic px values. Use fixed px values in inline styles or CSS rules targeting [data-contract="page-container"].`, + details: { + property: "padding", + expected: expectedPadding, + actualLeft: actualPaddingLeft, + actualRight: actualPaddingRight, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + else { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-unextractable-value", + message: `Page frame padding could not be extracted for surface "${descriptor.surfaceId}". Expected ${expectedPadding}px. Static analysis requires deterministic px values. Use inline styles, CSS rules targeting [data-contract="page-container"], or Tailwind bracket classes (px-[${expectedPadding}px]).`, + details: { + property: "padding", + expected: expectedPadding, + actualLeft: actualPaddingLeft, + actualRight: actualPaddingRight, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + } + else if (actualPaddingLeft !== expectedPadding || + actualPaddingRight !== expectedPadding) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-padding-mismatch", + message: `Page frame padding mismatch for surface "${descriptor.surfaceId}": expected ${expectedPadding}px on both sides, found left=${actualPaddingLeft}px right=${actualPaddingRight}px.`, + details: { + expected: expectedPadding, + actualLeft: actualPaddingLeft, + actualRight: actualPaddingRight, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + } + } + // Apply enforcement mode: warn mode violations don't affect exit code + // This is handled at the CLI level by checking violation severity + } return { surfaceId: descriptor.surfaceId, violations, @@ -221,10 +363,20 @@ function formatAjvErrors(errors) { return errors.map((error) => { const dataPath = error.instancePath || error.schemaPath; const baseMessage = error.message ?? "Validation error"; + // Enhance error messages for common schema issues + let enhancedMessage = baseMessage; + if (error.keyword === "additionalProperties" && error.params?.additionalProperty) { + const prop = error.params.additionalProperty; + enhancedMessage = `Additional property "${prop}" is not allowed. This may indicate a capability gap - the field is not supported by the current schema version.`; + } + else if (error.keyword === "required" && error.params?.missingProperty) { + const prop = error.params.missingProperty; + enhancedMessage = `Required property "${prop}" is missing.`; + } if (error.params && Object.keys(error.params).length > 0) { - return `${dataPath}: ${baseMessage} (${JSON.stringify(error.params)})`; + return `${dataPath}: ${enhancedMessage} (${JSON.stringify(error.params)})`; } - return `${dataPath}: ${baseMessage}`; + return `${dataPath}: ${enhancedMessage}`; }); } function buildSectionIndex(sections) { diff --git a/packages/interfacectl-validator/dist/types.d.ts b/packages/interfacectl-validator/dist/types.d.ts index 2b9cc41..a7302d1 100644 --- a/packages/interfacectl-validator/dist/types.d.ts +++ b/packages/interfacectl-validator/dist/types.d.ts @@ -1,4 +1,11 @@ export type SurfaceType = "web" | "cli"; +export interface PageFrameLayout { + containerSelector: string; + containerMaxWidthPx: number; + paddingXpx: number; + alignment?: "center" | "left"; + enforcement?: "strict" | "warn"; +} export interface ContractSurface { id: string; displayName: string; @@ -9,6 +16,7 @@ export interface ContractSurface { layout: { maxContentWidth: number; requiredContainers?: string[]; + pageFrame?: PageFrameLayout; }; } export interface ContractSection { @@ -47,11 +55,21 @@ export interface SurfaceMotionDescriptor { timingFunction: string; source?: string; } +export interface PageFrameLayoutDescriptor { + containerSelector: string; + maxWidthPx?: number | null; + paddingLeftPx?: number | null; + paddingRightPx?: number | null; + source?: string; + maxWidthHasClampCalc?: boolean; + paddingHasClampCalc?: boolean; +} export interface SurfaceLayoutDescriptor { maxContentWidth?: number | null; containers?: string[]; containerSources?: string[]; source?: string; + pageFrame?: PageFrameLayoutDescriptor; } export interface SurfaceDescriptor { surfaceId: string; @@ -61,7 +79,7 @@ export interface SurfaceDescriptor { layout: SurfaceLayoutDescriptor; motion: SurfaceMotionDescriptor[]; } -export type DriftViolationType = "unknown-surface" | "missing-section" | "unknown-section" | "font-not-allowed" | "color-not-allowed" | "layout-width-exceeded" | "layout-width-undetermined" | "layout-container-missing" | "motion-duration-not-allowed" | "motion-timing-not-allowed" | "descriptor-missing" | "descriptor-unused"; +export type DriftViolationType = "unknown-surface" | "missing-section" | "unknown-section" | "font-not-allowed" | "color-not-allowed" | "layout-width-exceeded" | "layout-width-undetermined" | "layout-container-missing" | "layout-pageframe-selector-unsupported" | "layout-pageframe-container-not-found" | "layout-pageframe-maxwidth-mismatch" | "layout-pageframe-padding-mismatch" | "layout-pageframe-non-deterministic-value" | "layout-pageframe-unextractable-value" | "motion-duration-not-allowed" | "motion-timing-not-allowed" | "descriptor-missing" | "descriptor-unused"; export interface DriftViolation { surfaceId: string; type: DriftViolationType; diff --git a/packages/interfacectl-validator/dist/types.d.ts.map b/packages/interfacectl-validator/dist/types.d.ts.map index d5c9ce3..e56b3c8 100644 --- a/packages/interfacectl-validator/dist/types.d.ts.map +++ b/packages/interfacectl-validator/dist/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,KAAK,CAAC;AAExC,MAAM,WAAW,eAAe;IAC9B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC9B,WAAW,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;CACjC;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,WAAW,CAAC;IAClB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,MAAM,EAAE;QACN,eAAe,EAAE,MAAM,CAAC;QACxB,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;QAC9B,SAAS,CAAC,EAAE,eAAe,CAAC;KAC7B,CAAC;CACH;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE;QACN,kBAAkB,EAAE,MAAM,EAAE,CAAC;QAC7B,sBAAsB,EAAE,MAAM,EAAE,CAAC;KAClC,CAAC;CACH;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,wBAAwB;IACvC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,yBAAyB;IACxC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,uBAAuB;IACtC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,yBAAyB,CAAC;CACvC;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,wBAAwB,EAAE,CAAC;IACrC,KAAK,EAAE,qBAAqB,EAAE,CAAC;IAC/B,MAAM,EAAE,sBAAsB,EAAE,CAAC;IACjC,MAAM,EAAE,uBAAuB,CAAC;IAChC,MAAM,EAAE,uBAAuB,EAAE,CAAC;CACnC;AAED,MAAM,MAAM,kBAAkB,GAC1B,iBAAiB,GACjB,iBAAiB,GACjB,iBAAiB,GACjB,kBAAkB,GAClB,mBAAmB,GACnB,uBAAuB,GACvB,2BAA2B,GAC3B,0BAA0B,GAC1B,uCAAuC,GACvC,sCAAsC,GACtC,oCAAoC,GACpC,mCAAmC,GACnC,0CAA0C,GAC1C,sCAAsC,GACtC,6BAA6B,GAC7B,2BAA2B,GAC3B,oBAAoB,GACpB,mBAAmB,CAAC;AAExB,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,kBAAkB,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,cAAc,EAAE,aAAa,EAAE,CAAC;CACjC;AAID,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;AAC1E,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;AACpD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,YAAY,GAAG,UAAU,CAAC;AAC7D,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,KAAK,GAAG,IAAI,CAAC;AAEpD,MAAM,WAAW,SAAS;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,cAAc,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,EAAE,QAAQ,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,MAAM,CAAC,EAAE;QACP,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;CACH;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,EACJ,YAAY,GACZ,oBAAoB,GACpB,uBAAuB,GACvB,cAAc,GACd,oBAAoB,GACpB,sBAAsB,GACtB,kBAAkB,GAClB,qBAAqB,GACrB,oBAAoB,GACpB,mBAAmB,CAAC;IACxB,QAAQ,EAAE,QAAQ,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE;QAAE,IAAI,EAAE,cAAc,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,MAAM,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAClD,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC;IACrE,aAAa,EAAE;QACb,OAAO,EAAE,OAAO,CAAC;QACjB,cAAc,EAAE,MAAM,EAAE,CAAC;QACzB,aAAa,EAAE,MAAM,EAAE,CAAC;KACzB,CAAC;IACF,OAAO,EAAE;QACP,YAAY,EAAE,MAAM,CAAC;QACrB,MAAM,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC;QAC9E,UAAU,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KAC9D,CAAC;IACF,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB,UAAU,CAAC,EAAE,SAAS,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7B;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,WAAW,CAAC;IACzB,WAAW,CAAC,EAAE,QAAQ,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QACL,IAAI,EAAE;YAAE,SAAS,EAAE,OAAO,CAAC;YAAC,iBAAiB,EAAE,OAAO,GAAG,SAAS,CAAA;SAAE,CAAC;QACrE,GAAG,EAAE;YAAE,KAAK,EAAE,MAAM,EAAE,CAAC;YAAC,MAAM,EAAE,OAAO,CAAA;SAAE,CAAC;QAC1C,EAAE,EAAE;YAAE,WAAW,EAAE,SAAS,GAAG,MAAM,CAAC;YAAC,UAAU,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;KAC9D,CAAC;IACF,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,OAAO,CAAC,EAAE;QACR,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,aAAa,CAAC,EAAE;YAAE,KAAK,CAAC,EAAE,MAAM,CAAC;YAAC,OAAO,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;KACrE,CAAC;CACH;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,KAAK,GAAG,IAAI,CAAC;IACnB,MAAM,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD,OAAO,EAAE,QAAQ,EAAE,CAAC;IACpB,OAAO,EAAE,QAAQ,EAAE,CAAC;IACpB,MAAM,EAAE,QAAQ,EAAE,CAAC;CACpB"} \ No newline at end of file From 46d7167d561fb4ef7e0dc6d6cf364b3e9b4c78d8 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Thu, 15 Jan 2026 18:30:08 -0800 Subject: [PATCH 2/2] feat: add color policy + allowedColors deprecation + traceable findings --- VERIFICATION_SUMMARY.md | 124 +++++++ .../dist/commands/validate.d.ts.map | 2 +- .../dist/commands/validate.js | 77 +++- .../interfacectl-cli/docs/color-policy.md | 293 +++++++++++++++ .../interfacectl-cli/src/commands/validate.ts | 2 - .../interfacectl-cli/src/utils/normalize.ts | 10 +- .../test/color-deprecation.test.mjs | 188 ++++++++++ .../dist/index.d.ts.map | 2 +- packages/interfacectl-validator/dist/index.js | 143 +++++++- .../schema/surfaces.web.contract.schema.json | 36 ++ packages/interfacectl-validator/src/index.ts | 337 +++++++++++++++++- .../schema/surfaces.web.contract.schema.json | 193 +++++++++- packages/interfacectl-validator/src/types.ts | 71 +++- 13 files changed, 1422 insertions(+), 56 deletions(-) create mode 100644 VERIFICATION_SUMMARY.md create mode 100644 packages/interfacectl-cli/docs/color-policy.md create mode 100644 packages/interfacectl-cli/test/color-deprecation.test.mjs diff --git a/VERIFICATION_SUMMARY.md b/VERIFICATION_SUMMARY.md new file mode 100644 index 0000000..9307788 --- /dev/null +++ b/VERIFICATION_SUMMARY.md @@ -0,0 +1,124 @@ +# Color Policy Implementation Verification + +## ✅ Code Names Match Documentation + +All emitted codes match the documentation in `color-policy.md`: + +- ✅ `color.raw-value.used` - Emitted for raw color literals +- ✅ `color.token.namespace.violation` - Emitted for CSS variables not matching allowed namespaces +- ✅ `contract.deprecated-field` - Emitted when `allowedColors` is present + +## ✅ Severity Mapping Matches Documentation + +### `color.raw-value.used` +- **Policy: `"warn"`** → Severity: `"warning"` (does not fail validation) +- **Policy: `"strict"`** → Severity: `"error"` (fails validation with exit code) + +Implementation location: `packages/interfacectl-cli/src/commands/validate.ts:578-592` + +```typescript +case "color-raw-value-used": { + finding.expected = details.allowedValues; + finding.found = details.colorValue; + // Set severity based on policy: "warn" -> warning, "strict" -> error + if (details.policy === "warn") { + finding.severity = "warning"; + } else if (details.policy === "strict") { + finding.severity = "error"; + } + break; +} +``` + +### `color.token.namespace.violation` +- **Default**: Severity: `"warning"` (does not fail validation) + +Implementation location: `packages/interfacectl-cli/src/commands/validate.ts:595-601` + +```typescript +case "color-token-namespace-violation": { + finding.expected = details.allowedNamespaces; + finding.found = details.tokenName; + // Token namespace violations are warnings by default + finding.severity = "warning"; + break; +} +``` + +### `contract.deprecated-field` +- **Default**: Severity: `"warning"` (does not fail validation) + +Implementation location: `packages/interfacectl-cli/src/commands/validate.ts:258` + +## ✅ Exit Code Behavior + +**Warnings do not fail validation** - Only errors cause non-zero exit codes. + +Implementation location: `packages/interfacectl-cli/src/commands/validate.ts:330-348` + +```typescript +} else { + // Filter to only error-level findings for exit code determination + const errorFindings = violationFindings.filter((f) => f.severity === "error"); + if (errorFindings.length === 0) { + exitCode = 0; // Only warnings, don't fail + } else { + // Find the highest severity category (E2 > E1) + // ... exit code logic based on error findings only + } +} +``` + +## Test Coverage + +All scenarios are covered in tests: + +- ✅ Schema accepts contract without `allowedColors` +- ✅ Schema accepts contract with `color` policy +- ✅ Deprecation warning emitted for `allowedColors` +- ✅ Raw literal detection with `warn` policy (severity: warning) +- ✅ Raw literal detection with `strict` policy (severity: error) +- ✅ Allowlist/denylist behavior +- ✅ Token namespace validation +- ✅ Policy `off` skips checks + +## Ready for surfaces-monorepo Integration + +The implementation is ready for use in surfaces-monorepo: + +1. **Contracts with `allowedColors`** will: + - ✅ Pass schema validation + - ✅ Emit `contract.deprecated-field` warnings + - ✅ Continue to compliance checks (layout pageFrame drift will appear) + +2. **Contracts with `color` policy** will: + - ✅ Pass schema validation + - ✅ Emit `color.raw-value.used` findings based on policy (warn/strict) + - ✅ Emit `color.token.namespace.violation` warnings for invalid tokens + +3. **Exit codes**: + - ✅ `rawValues.policy: "warn"` → warnings only, exit code 0 + - ✅ `rawValues.policy: "strict"` → errors, exit code based on category (E1/E2) + +## Next Steps for surfaces-monorepo + +1. Pull updated interfacectl changes +2. Run `pnpm validate:ci` +3. Verify output includes: + - `contract.deprecated-field` warnings for legacy `allowedColors` + - Actual compliance checks (layout pageFrame drift) +4. (Optional) Add `color` policy section to contract: + ```json + { + "color": { + "sourceOfTruth": { + "type": "tokens", + "tokenNamespaces": ["--color-"] + }, + "rawValues": { + "policy": "warn" + } + } + } + ``` +5. Leave `allowedColors` on surfaces for one iteration to see both deprecation warnings and v1 color findings diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts.map b/packages/interfacectl-cli/dist/commands/validate.d.ts.map index bac0d41..9579669 100644 --- a/packages/interfacectl-cli/dist/commands/validate.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/validate.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAeA,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAmCpC,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAwQjB"} \ No newline at end of file +{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAeA,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAmCpC,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAqSjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate.js b/packages/interfacectl-cli/dist/commands/validate.js index 0c5fb5e..b4784b3 100644 --- a/packages/interfacectl-cli/dist/commands/validate.js +++ b/packages/interfacectl-cli/dist/commands/validate.js @@ -149,6 +149,23 @@ export async function runValidateCommand(options) { return finalize(e0ExitCode, initialContractVersion); } const contract = structureResult.contract; + // Check for deprecated allowedColors fields + for (let i = 0; i < contract.surfaces.length; i++) { + const surface = contract.surfaces[i]; + if (surface.allowedColors !== undefined) { + const finding = { + code: "contract.deprecated-field", + severity: "warning", + category: "E0", + message: `allowedColors is deprecated. Migrate to color.sourceOfTruth + color.rawValues policy.`, + location: `/surfaces/${i}/allowedColors`, + }; + findings.push(finding); + if (!isJson) { + textReporter.warn(pc.yellow(` • Surface "${surface.id}": allowedColors is deprecated (use color.sourceOfTruth + color.rawValues)`)); + } + } + } const surfaceFilters = new Set((options.surfaceFilters ?? []).map((value) => value.trim())); const structuralDescriptorResult = await collectSurfaceDescriptors({ workspaceRoot, @@ -192,24 +209,31 @@ export async function runValidateCommand(options) { exitCode = 0; } else { - // Find the highest severity category (E2 > E1) - let maxCategory = null; - for (const finding of violationFindings) { - const category = finding.category; - if (category === "E2") { - maxCategory = "E2"; - break; // E2 is highest, no need to continue - } - else if (category === "E1" && (maxCategory === null || maxCategory === "E1")) { - maxCategory = "E1"; - } - } - if (maxCategory) { - exitCode = getExitCodeForCategory(maxCategory, exitCodeVersion); + // Filter to only error-level findings for exit code determination + const errorFindings = violationFindings.filter((f) => f.severity === "error"); + if (errorFindings.length === 0) { + exitCode = 0; // Only warnings, don't fail } else { - // Fallback (should not happen, but handle gracefully) - exitCode = exitCodeVersion === "v2" ? 30 : 1; + // Find the highest severity category (E2 > E1) + let maxCategory = null; + for (const finding of errorFindings) { + const category = finding.category; + if (category === "E2") { + maxCategory = "E2"; + break; // E2 is highest, no need to continue + } + else if (category === "E1" && (maxCategory === null || maxCategory === "E1")) { + maxCategory = "E1"; + } + } + if (maxCategory) { + exitCode = getExitCodeForCategory(maxCategory, exitCodeVersion); + } + else { + // Fallback (should not happen, but handle gracefully) + exitCode = exitCodeVersion === "v2" ? 30 : 1; + } } // Print deprecation warning for v1 if (exitCodeVersion === "v1") { @@ -284,6 +308,8 @@ function mapViolationsToFindings(summary) { "layout-pageframe-unextractable-value": "layout.pageframe.unextractable-value", "motion-duration-not-allowed": "motion.duration", "motion-timing-not-allowed": "motion.timing", + "color-raw-value-used": "color.raw-value.used", + "color-token-namespace-violation": "color.token.namespace.violation", }; for (const report of summary.surfaceReports) { for (const violation of report.violations) { @@ -405,6 +431,25 @@ function mapViolationsToFindings(summary) { finding.found = violation.surfaceId; break; } + case "color-raw-value-used": { + finding.expected = details.allowedValues; + finding.found = details.colorValue; + // Set severity based on policy: "warn" -> warning, "strict" -> error + if (details.policy === "warn") { + finding.severity = "warning"; + } + else if (details.policy === "strict") { + finding.severity = "error"; + } + break; + } + case "color-token-namespace-violation": { + finding.expected = details.allowedNamespaces; + finding.found = details.tokenName; + // Token namespace violations are warnings by default + finding.severity = "warning"; + break; + } default: break; } diff --git a/packages/interfacectl-cli/docs/color-policy.md b/packages/interfacectl-cli/docs/color-policy.md new file mode 100644 index 0000000..247a0ac --- /dev/null +++ b/packages/interfacectl-cli/docs/color-policy.md @@ -0,0 +1,293 @@ +# Color Policy + +The color policy section provides a flexible, evolution-friendly way to enforce color usage across surfaces. It replaces the deprecated `allowedColors` field on individual surfaces with a centralized policy that supports static analysis. + +## Overview + +The color policy is defined at the contract level and applies to all surfaces. It consists of four main sections: + +- **`sourceOfTruth`**: Defines where colors should come from (tokens vs. none) +- **`rawValues`**: Controls detection and enforcement of raw color literals (hex, rgb, hsl) +- **`semantics`**: (Future) Semantic role enforcement (accent, text, background, border) +- **`consistency`**: (Future) Cross-surface consistency checks + +## Migration from `allowedColors` + +The `allowedColors` field on surfaces is **deprecated** and will be removed in a future version. Migrate to the new color policy: + +### Before (Deprecated) + +```json +{ + "surfaces": [ + { + "id": "web-app", + "allowedColors": ["var(--color-primary)", "var(--color-secondary)"] + } + ] +} +``` + +### After (Recommended) + +```json +{ + "color": { + "sourceOfTruth": { + "type": "tokens", + "tokenNamespaces": ["--color-"] + }, + "rawValues": { + "policy": "warn" + } + }, + "surfaces": [ + { + "id": "web-app" + } + ] +} +``` + +When `allowedColors` is present, interfacectl will emit a deprecation warning (`contract.deprecated-field`) but will not fail validation. The warning includes a JSON pointer to the deprecated field and guidance on migration. + +## Configuration + +### `sourceOfTruth` + +Defines the authoritative source for colors. + +```json +{ + "sourceOfTruth": { + "type": "tokens" | "none", + "tokenNamespaces": ["--color-", "--ds-color-"] // Required if type is "tokens" + } +} +``` + +- **`type: "tokens"`**: Colors must come from CSS variables (design tokens). Requires `tokenNamespaces`. +- **`type: "none"`**: No source-of-truth enforcement (useful for gradual migration). + +**Token Namespace Validation**: When `type` is `"tokens"`, interfacectl validates that all CSS variable references start with one of the configured namespaces. Variables that don't match trigger `color.token.namespace.violation`. + +Example violation: +- Contract: `tokenNamespaces: ["--color-"]` +- Code: `var(--custom-red)` → Violation (doesn't start with `--color-`) +- Code: `var(--color-primary)` → ✅ Valid + +### `rawValues` + +Controls detection and enforcement of raw color literals (hex, rgb, hsl, hsla). + +```json +{ + "rawValues": { + "policy": "off" | "warn" | "strict", + "allowlist": ["#ffffff", "#000000"], // Optional + "denylist": ["#ff0000"] // Optional + } +} +``` + +**Policy Levels:** +- **`"off"`**: No raw literal detection (except denylist violations) +- **`"warn"`**: Emit warnings for raw literals (does not fail validation) +- **`"strict"`**: Emit errors for raw literals (fails validation with exit code 1) + +**Allowlist & Denylist:** +- **Allowlist**: Raw literals that are explicitly permitted (e.g., `#ffffff`, `#000000` for base colors) +- **Denylist**: Raw literals that are always forbidden, regardless of policy level + +**Detection:** +- Hex colors: `#rgb`, `#rrggbb`, `#rrggbbaa` +- RGB/RGBA: `rgb(...)`, `rgba(...)` +- HSL/HSLA: `hsl(...)`, `hsla(...)` +- CSS variables (`var(--...)`) are **not** considered raw literals + +**Example:** +```json +{ + "rawValues": { + "policy": "warn", + "allowlist": ["#ffffff", "#000000"], + "denylist": ["#ff0000"] + } +} +``` + +- `#ff00aa` → Warning (not allowlisted) +- `#ffffff` → ✅ Allowed (in allowlist) +- `#ff0000` → Violation (in denylist, even if allowlisted) + +### `semantics` (Future) + +Semantic role enforcement for colors. Not yet enforced in v1, but schema accepts the structure for future use. + +```json +{ + "semantics": { + "roles": { + "accent": { "enforcement": "warn" }, + "text": { "enforcement": "strict" }, + "background": { "enforcement": "warn" }, + "border": { "enforcement": "off" } + } + } +} +``` + +### `consistency` (Future) + +Cross-surface consistency checks. Not yet enforced in v1, but schema accepts the structure for future use. + +```json +{ + "consistency": { + "acrossSurfaces": { + "enforcement": "warn", + "signals": ["token-name", "css-var-name", "class-fragment"] + } + } +} +``` + +## Validation Codes + +### `color.raw-value.used` + +Emitted when a raw color literal is detected and violates the policy. + +- **Severity**: `warn` or `error` (based on `rawValues.policy`) +- **Category**: `E1` (Token Policy) +- **Details**: + - `colorValue`: The detected raw literal + - `source`: File location (if available) + - `policy`: The policy level (`warn` or `strict`) + - `jsonPointer`: `/color/rawValues` + +**Example:** +```json +{ + "code": "color.raw-value.used", + "severity": "warn", + "category": "E1", + "message": "Raw color literal \"#ff00aa\" detected. Use design tokens instead.", + "location": "app/globals.css", + "found": "#ff00aa" +} +``` + +### `color.token.namespace.violation` + +Emitted when a CSS variable doesn't start with any allowed namespace. + +- **Severity**: `warn` (default) +- **Category**: `E1` (Token Policy) +- **Details**: + - `tokenName`: The variable name (e.g., `--not-allowed-token`) + - `allowedNamespaces`: Array of allowed namespace prefixes + - `source`: File location (if available) + - `jsonPointer`: `/color/sourceOfTruth/tokenNamespaces` + +**Example:** +```json +{ + "code": "color.token.namespace.violation", + "severity": "warn", + "category": "E1", + "message": "Color token \"--not-allowed-token\" does not start with any allowed namespace. Allowed namespaces: --color-, --ds-color-.", + "location": "app/components/Button.tsx", + "found": "--not-allowed-token", + "expected": ["--color-", "--ds-color-"] +} +``` + +### `contract.deprecated-field` + +Emitted when `allowedColors` is present on a surface. + +- **Severity**: `warning` +- **Category**: `E0` (Artifact Invalid) +- **Details**: + - `field`: `"allowedColors"` + - `location`: JSON pointer to the field (e.g., `/surfaces/0/allowedColors`) + - `replacement`: `["color.sourceOfTruth", "color.rawValues"]` + +**Example:** +```json +{ + "code": "contract.deprecated-field", + "severity": "warning", + "category": "E0", + "message": "allowedColors is deprecated. Migrate to color.sourceOfTruth + color.rawValues policy.", + "location": "/surfaces/0/allowedColors" +} +``` + +## Static Analysis + +v1 color validation uses static analysis to extract colors from: + +- CSS files (`.css`) +- Inline styles in component files (`.tsx`, `.jsx`, `.ts`, `.js`) +- CSS variable references (`var(--...)`) +- Raw color literals in CSS declarations + +**Limitations:** +- Does not validate runtime computed colors +- Does not parse complex CSS expressions (gradients, calc, etc.) +- Conservative detection to avoid false positives + +## Best Practices + +1. **Start with `warn`**: Use `policy: "warn"` initially to identify raw literals without blocking builds +2. **Use allowlists sparingly**: Only allowlist truly necessary base colors (e.g., `#ffffff`, `#000000`) +3. **Denylist dangerous colors**: Use denylist to explicitly forbid problematic colors +4. **Namespace tokens consistently**: Use a single namespace prefix (e.g., `--color-`) for all design tokens +5. **Migrate gradually**: Keep `allowedColors` during migration, then remove once all surfaces use tokens + +## Example Contract + +```json +{ + "contractId": "ui.contract", + "version": "1.0.0", + "color": { + "sourceOfTruth": { + "type": "tokens", + "tokenNamespaces": ["--color-"] + }, + "rawValues": { + "policy": "warn", + "allowlist": ["#ffffff", "#000000"], + "denylist": [] + } + }, + "surfaces": [ + { + "id": "web-app", + "displayName": "Web App", + "type": "web", + "requiredSections": ["header"], + "allowedFonts": ["var(--font-primary)"], + "layout": { + "maxContentWidth": 1200 + } + } + ], + "sections": [ + { + "id": "header", + "intent": "Page header", + "description": "Main header section" + } + ], + "constraints": { + "motion": { + "allowedDurationsMs": [200, 300], + "allowedTimingFunctions": ["ease", "ease-in-out"] + } + } +} +``` diff --git a/packages/interfacectl-cli/src/commands/validate.ts b/packages/interfacectl-cli/src/commands/validate.ts index 36bd805..c2a2f06 100644 --- a/packages/interfacectl-cli/src/commands/validate.ts +++ b/packages/interfacectl-cli/src/commands/validate.ts @@ -599,8 +599,6 @@ function mapViolationsToFindings( finding.severity = "warning"; break; } - } - default: break; } diff --git a/packages/interfacectl-cli/src/utils/normalize.ts b/packages/interfacectl-cli/src/utils/normalize.ts index 987f586..0c5b4fd 100644 --- a/packages/interfacectl-cli/src/utils/normalize.ts +++ b/packages/interfacectl-cli/src/utils/normalize.ts @@ -158,10 +158,12 @@ export function normalizeContract( if (SET_LIKE_FIELDS.has("allowedColors")) { const original = surface.allowedColors; - const sorted = normalizeSetField(original); - if (JSON.stringify(original) !== JSON.stringify(sorted)) { - metadata.reorderedPaths.push(`surfaces[${surfaceIdx}].allowedColors`); - normalizedSurface.allowedColors = sorted; + if (original) { + const sorted = normalizeSetField(original); + if (JSON.stringify(original) !== JSON.stringify(sorted)) { + metadata.reorderedPaths.push(`surfaces[${surfaceIdx}].allowedColors`); + normalizedSurface.allowedColors = sorted; + } } } diff --git a/packages/interfacectl-cli/test/color-deprecation.test.mjs b/packages/interfacectl-cli/test/color-deprecation.test.mjs new file mode 100644 index 0000000..c35a1f4 --- /dev/null +++ b/packages/interfacectl-cli/test/color-deprecation.test.mjs @@ -0,0 +1,188 @@ +import { test } from "node:test"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; +import os from "node:os"; +import { + mkdtemp, + mkdir, + writeFile, + rm, +} from "node:fs/promises"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const cliPackageDir = path.resolve(__dirname, ".."); +const cliExecutable = path.resolve(cliPackageDir, "dist", "index.js"); + +async function runCommand(command, args, options = {}) { + const proc = spawn(command, args, { + ...options, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + + proc.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const exitCode = await once(proc, "exit").then(([code]) => code ?? 1); + + return { exitCode, stdout, stderr }; +} + +test("validate emits deprecation warning for allowedColors", async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "interfacectl-deprecation-"), + ); + + try { + const contractPath = path.join(tempRoot, "contract.json"); + const contract = { + contractId: "test", + version: "1.0.0", + surfaces: [ + { + id: "test-surface", + displayName: "Test Surface", + type: "web", + requiredSections: ["header"], + allowedFonts: ["Inter"], + allowedColors: ["#000000", "#ffffff"], + layout: { + maxContentWidth: 1200, + }, + }, + ], + sections: [ + { + id: "header", + intent: "Page header", + description: "Main header", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [200], + allowedTimingFunctions: ["ease"], + }, + }, + }; + + await writeFile(contractPath, JSON.stringify(contract, null, 2), "utf-8"); + + // Create minimal surface directory structure with source files + const surfaceDir = path.join(tempRoot, "apps", "test-surface"); + await mkdir(surfaceDir, { recursive: true }); + await writeFile(path.join(surfaceDir, "package.json"), JSON.stringify({ name: "test-surface" }), "utf-8"); + // Create minimal app structure for Next.js surface + const appDir = path.join(surfaceDir, "app"); + await mkdir(appDir, { recursive: true }); + await writeFile(path.join(appDir, "layout.tsx"), `export default function Layout({ children }) { return
{children}
; }`, "utf-8"); + await writeFile(path.join(appDir, "page.tsx"), `export default function Page() { return
Test
; }`, "utf-8"); + + const result = await runCommand( + "node", + [cliExecutable, "validate", "--contract", contractPath, "--workspace-root", tempRoot, "--format", "json", "--exit-codes", "v2"], + { cwd: tempRoot }, + ); + + // Should pass schema validation (allowedColors is accepted but deprecated) + assert.equal(result.exitCode, 0, `Command failed: ${result.stderr}\n${result.stdout}`); + + const output = JSON.parse(result.stdout); + assert.ok(output.findings); + + // Should have deprecation warning + const deprecationFinding = output.findings.find( + (f) => f.code === "contract.deprecated-field", + ); + assert.ok(deprecationFinding, "Should emit contract.deprecated-field finding"); + assert.equal(deprecationFinding.severity, "warning"); + assert(deprecationFinding.message.includes("allowedColors")); + assert(deprecationFinding.message.includes("deprecated")); + assert(deprecationFinding.location, "Should include jsonPointer location"); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("validate accepts contract with color policy", async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), "interfacectl-color-policy-"), + ); + + try { + const contractPath = path.join(tempRoot, "contract.json"); + const contract = { + contractId: "test", + version: "1.0.0", + surfaces: [ + { + id: "test-surface", + displayName: "Test Surface", + type: "web", + requiredSections: ["header"], + allowedFonts: ["Inter"], + layout: { + maxContentWidth: 1200, + }, + }, + ], + sections: [ + { + id: "header", + intent: "Page header", + description: "Main header", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [200], + allowedTimingFunctions: ["ease"], + }, + }, + color: { + sourceOfTruth: { + type: "tokens", + tokenNamespaces: ["--color-"], + }, + rawValues: { + policy: "warn", + }, + }, + }; + + await writeFile(contractPath, JSON.stringify(contract, null, 2), "utf-8"); + + // Create minimal surface directory structure with source files + const surfaceDir = path.join(tempRoot, "apps", "test-surface"); + await mkdir(surfaceDir, { recursive: true }); + await writeFile(path.join(surfaceDir, "package.json"), JSON.stringify({ name: "test-surface" }), "utf-8"); + // Create minimal app structure for Next.js surface + const appDir = path.join(surfaceDir, "app"); + await mkdir(appDir, { recursive: true }); + await writeFile(path.join(appDir, "layout.tsx"), `export default function Layout({ children }) { return
{children}
; }`, "utf-8"); + await writeFile(path.join(appDir, "page.tsx"), `export default function Page() { return
Test
; }`, "utf-8"); + + const result = await runCommand( + "node", + [cliExecutable, "validate", "--contract", contractPath, "--workspace-root", tempRoot, "--format", "json", "--exit-codes", "v2"], + { cwd: tempRoot }, + ); + + // Should pass schema validation + assert.equal(result.exitCode, 0, `Command failed: ${result.stderr}\n${result.stdout}`); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/interfacectl-validator/dist/index.d.ts.map b/packages/interfacectl-validator/dist/index.d.ts.map index a94b421..b1790f1 100644 --- a/packages/interfacectl-validator/dist/index.d.ts.map +++ b/packages/interfacectl-validator/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EAKlB,MAAM,YAAY,CAAC;AAOpB,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,OAAO,EACrB,MAAM,EAAE,MAAM,GACb,2BAA2B,CAyB7B;AAED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CA6Tf;AAED,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,iBAAiB,EAAE,GAC/B,iBAAiB,CA6CnB;AAyCD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,UAAU,EACV,SAAS,EACT,cAAc,EACd,SAAS,EACT,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,UAAU,EACV,QAAQ,EACR,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,0BAA0B,EAC1B,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,KAAK,gBAAgB,GACtB,MAAM,sBAAsB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EAKlB,MAAM,YAAY,CAAC;AAOpB,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,OAAO,EACrB,MAAM,EAAE,MAAM,GACb,2BAA2B,CAyB7B;AA4ID,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CAqUf;AAED,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,iBAAiB,EAAE,GAC/B,iBAAiB,CA6CnB;AAyCD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,UAAU,EACV,SAAS,EACT,cAAc,EACd,SAAS,EACT,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,UAAU,EACV,QAAQ,EACR,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,0BAA0B,EAC1B,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,KAAK,gBAAgB,GACtB,MAAM,sBAAsB,CAAC"} \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/index.js b/packages/interfacectl-validator/dist/index.js index 305b74a..1494f2b 100644 --- a/packages/interfacectl-validator/dist/index.js +++ b/packages/interfacectl-validator/dist/index.js @@ -27,6 +27,116 @@ export function validateContractStructure(contractData, schema) { contract: contractData, }; } +function validateColorPolicy(contract, descriptor, violations) { + const colorPolicy = contract.color; + if (!colorPolicy) { + return; + } + // Validate raw color literals + if (colorPolicy.rawValues) { + const policy = colorPolicy.rawValues.policy; + const allowlist = new Set(colorPolicy.rawValues.allowlist ?? []); + const denylist = new Set(colorPolicy.rawValues.denylist ?? []); + for (const color of descriptor.colors) { + const colorValue = color.value; + // Skip CSS variables + if (colorValue.startsWith("var(")) { + continue; + } + // Check if it's a raw color literal (hex, rgb, hsl) + const isRawLiteral = isRawColorLiteral(colorValue); + if (!isRawLiteral) { + continue; + } + // Check denylist first (denylist takes precedence, even when policy is "off") + if (denylist.has(colorValue)) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "color-raw-value-used", + message: `Raw color literal "${colorValue}" is not allowed (denylist). Use design tokens instead.`, + details: { + colorValue, + source: color.source, + policy: policy ?? "off", + jsonPointer: "/color/rawValues", + }, + }); + continue; + } + // Only check allowlist and policy violations when policy is not "off" + if (policy !== "off") { + // Check allowlist + if (allowlist.has(colorValue)) { + continue; // Allowlisted, skip + } + // Emit violation based on policy + if (policy === "warn" || policy === "strict") { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "color-raw-value-used", + message: `Raw color literal "${colorValue}" detected. Use design tokens instead.`, + details: { + colorValue, + source: color.source, + policy, + jsonPointer: "/color/rawValues", + }, + }); + } + } + } + } + // Validate token namespaces + if (colorPolicy.sourceOfTruth && + colorPolicy.sourceOfTruth.type === "tokens" && + colorPolicy.sourceOfTruth.tokenNamespaces) { + const allowedNamespaces = colorPolicy.sourceOfTruth.tokenNamespaces; + for (const color of descriptor.colors) { + const colorValue = color.value; + // Only check CSS variables + if (!colorValue.startsWith("var(")) { + continue; + } + // Extract variable name from var(--name) + const varMatch = colorValue.match(/var\((--[^)]+)\)/); + if (!varMatch) { + continue; + } + const varName = varMatch[1]; + // Check if variable name starts with any allowed namespace + const hasAllowedNamespace = allowedNamespaces.some((namespace) => varName.startsWith(namespace)); + if (!hasAllowedNamespace) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "color-token-namespace-violation", + message: `Color token "${varName}" does not start with any allowed namespace. Allowed namespaces: ${allowedNamespaces.join(", ")}.`, + details: { + tokenName: varName, + allowedNamespaces, + source: color.source, + jsonPointer: "/color/sourceOfTruth/tokenNamespaces", + }, + }); + } + } + } +} +function isRawColorLiteral(value) { + const trimmed = value.trim().toLowerCase(); + // Hex colors (#rgb, #rrggbb, #rrggbbaa) + if (/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(trimmed)) { + return true; + } + // rgb/rgba colors + if (/^rgba?\s*\(/.test(trimmed)) { + return true; + } + // hsl/hsla colors + if (/^hsla?\s*\(/.test(trimmed)) { + return true; + } + return false; +} export function evaluateSurfaceCompliance(contract, descriptor) { const surface = findSurface(contract.surfaces, descriptor.surfaceId); const violations = []; @@ -81,21 +191,28 @@ export function evaluateSurfaceCompliance(contract, descriptor) { }); } } - const allowedColors = new Set(surface.allowedColors); - for (const color of descriptor.colors) { - if (!allowedColors.has(color.value)) { - violations.push({ - surfaceId: descriptor.surfaceId, - type: "color-not-allowed", - message: `Color "${color.value}" is not allowed for surface "${descriptor.surfaceId}".`, - details: { - color: color.value, - source: color.source, - allowedColors: [...allowedColors], - }, - }); + // Legacy allowedColors check (deprecated) + if (surface.allowedColors) { + const allowedColors = new Set(surface.allowedColors); + for (const color of descriptor.colors) { + if (!allowedColors.has(color.value)) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "color-not-allowed", + message: `Color "${color.value}" is not allowed for surface "${descriptor.surfaceId}".`, + details: { + color: color.value, + source: color.source, + allowedColors: [...allowedColors], + }, + }); + } } } + // New color policy validation + if (contract.color) { + validateColorPolicy(contract, descriptor, violations); + } const reportedWidth = descriptor.layout.maxContentWidth; if (reportedWidth === null || reportedWidth === undefined) { violations.push({ diff --git a/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json b/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json index 7a0a1f6..243f641 100644 --- a/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json +++ b/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json @@ -119,6 +119,9 @@ "minLength": 1 }, "uniqueItems": true + }, + "pageFrame": { + "$ref": "#/$defs/pageFrame" } } } @@ -183,6 +186,39 @@ } } } + }, + "pageFrame": { + "type": "object", + "additionalProperties": false, + "required": [ + "containerSelector", + "containerMaxWidthPx", + "paddingXpx" + ], + "properties": { + "containerSelector": { + "type": "string", + "minLength": 1 + }, + "containerMaxWidthPx": { + "type": "number", + "minimum": 0 + }, + "paddingXpx": { + "type": "number", + "minimum": 0 + }, + "alignment": { + "type": "string", + "enum": ["center", "left"], + "default": "center" + }, + "enforcement": { + "type": "string", + "enum": ["strict", "warn"], + "default": "strict" + } + } } } } diff --git a/packages/interfacectl-validator/src/index.ts b/packages/interfacectl-validator/src/index.ts index 4b8bb20..0238eee 100644 --- a/packages/interfacectl-validator/src/index.ts +++ b/packages/interfacectl-validator/src/index.ts @@ -8,6 +8,7 @@ import { DriftViolation, ContractSection, ContractSurface, + PageFrameLayoutDescriptor, } from "./types.js"; import bundledSchema from "./schema/surfaces.web.contract.schema.json" with { type: "json", @@ -55,6 +56,144 @@ export function validateContractStructure( }; } +function validateColorPolicy( + contract: InterfaceContract, + descriptor: SurfaceDescriptor, + violations: DriftViolation[], +): void { + const colorPolicy = contract.color; + if (!colorPolicy) { + return; + } + + // Validate raw color literals + if (colorPolicy.rawValues) { + const policy = colorPolicy.rawValues.policy; + const allowlist = new Set(colorPolicy.rawValues.allowlist ?? []); + const denylist = new Set(colorPolicy.rawValues.denylist ?? []); + + for (const color of descriptor.colors) { + const colorValue = color.value; + + // Skip CSS variables + if (colorValue.startsWith("var(")) { + continue; + } + + // Check if it's a raw color literal (hex, rgb, hsl) + const isRawLiteral = isRawColorLiteral(colorValue); + if (!isRawLiteral) { + continue; + } + + // Check denylist first (denylist takes precedence, even when policy is "off") + if (denylist.has(colorValue)) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "color-raw-value-used", + message: `Raw color literal "${colorValue}" is not allowed (denylist). Use design tokens instead.`, + details: { + colorValue, + source: color.source, + policy: policy ?? "off", + jsonPointer: "/color/rawValues", + }, + }); + continue; + } + + // Only check allowlist and policy violations when policy is not "off" + if (policy !== "off") { + // Check allowlist + if (allowlist.has(colorValue)) { + continue; // Allowlisted, skip + } + + // Emit violation based on policy + if (policy === "warn" || policy === "strict") { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "color-raw-value-used", + message: `Raw color literal "${colorValue}" detected. Use design tokens instead.`, + details: { + colorValue, + source: color.source, + policy, + jsonPointer: "/color/rawValues", + }, + }); + } + } + } + } + + // Validate token namespaces + if ( + colorPolicy.sourceOfTruth && + colorPolicy.sourceOfTruth.type === "tokens" && + colorPolicy.sourceOfTruth.tokenNamespaces + ) { + const allowedNamespaces = colorPolicy.sourceOfTruth.tokenNamespaces; + + for (const color of descriptor.colors) { + const colorValue = color.value; + + // Only check CSS variables + if (!colorValue.startsWith("var(")) { + continue; + } + + // Extract variable name from var(--name) + const varMatch = colorValue.match(/var\((--[^)]+)\)/); + if (!varMatch) { + continue; + } + + const varName = varMatch[1]; + + // Check if variable name starts with any allowed namespace + const hasAllowedNamespace = allowedNamespaces.some((namespace) => + varName.startsWith(namespace), + ); + + if (!hasAllowedNamespace) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "color-token-namespace-violation", + message: `Color token "${varName}" does not start with any allowed namespace. Allowed namespaces: ${allowedNamespaces.join(", ")}.`, + details: { + tokenName: varName, + allowedNamespaces, + source: color.source, + jsonPointer: "/color/sourceOfTruth/tokenNamespaces", + }, + }); + } + } + } +} + +function isRawColorLiteral(value: string): boolean { + const trimmed = value.trim().toLowerCase(); + + // Hex colors (#rgb, #rrggbb, #rrggbbaa) + if (/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(trimmed)) { + return true; + } + + // rgb/rgba colors + if (/^rgba?\s*\(/.test(trimmed)) { + return true; + } + + // hsl/hsla colors + if (/^hsla?\s*\(/.test(trimmed)) { + return true; + } + + return false; +} + export function evaluateSurfaceCompliance( contract: InterfaceContract, descriptor: SurfaceDescriptor, @@ -120,22 +259,30 @@ export function evaluateSurfaceCompliance( } } - const allowedColors = new Set(surface.allowedColors); - for (const color of descriptor.colors) { - if (!allowedColors.has(color.value)) { - violations.push({ - surfaceId: descriptor.surfaceId, - type: "color-not-allowed", - message: `Color "${color.value}" is not allowed for surface "${descriptor.surfaceId}".`, - details: { - color: color.value, - source: color.source, - allowedColors: [...allowedColors], - }, - }); + // Legacy allowedColors check (deprecated) + if (surface.allowedColors) { + const allowedColors = new Set(surface.allowedColors); + for (const color of descriptor.colors) { + if (!allowedColors.has(color.value)) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "color-not-allowed", + message: `Color "${color.value}" is not allowed for surface "${descriptor.surfaceId}".`, + details: { + color: color.value, + source: color.source, + allowedColors: [...allowedColors], + }, + }); + } } } + // New color policy validation + if (contract.color) { + validateColorPolicy(contract, descriptor, violations); + } + const reportedWidth = descriptor.layout.maxContentWidth; if (reportedWidth === null || reportedWidth === undefined) { violations.push({ @@ -223,6 +370,154 @@ export function evaluateSurfaceCompliance( } } + // Validate pageFrame layout if contract defines it + if (surface.layout.pageFrame && descriptor.layout.pageFrame) { + const pageFrameContract = surface.layout.pageFrame; + const pageFrameDescriptor = descriptor.layout.pageFrame; + const enforcement = pageFrameContract.enforcement ?? "strict"; + + // Check if selector is supported + const containerSelector = pageFrameContract.containerSelector; + const isSupportedSelector = + containerSelector === '[data-contract="page-container"]' || + containerSelector === "[data-contract='page-container']" || + containerSelector === '[data-contract={page-container}]'; + + if (!isSupportedSelector) { + const violation: DriftViolation = { + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-selector-unsupported", + message: `Page frame container selector "${containerSelector}" is not supported in static analysis. Use '[data-contract="page-container"]' instead.`, + details: { + selector: containerSelector, + supportedSelectors: ['[data-contract="page-container"]'], + }, + }; + violations.push(violation); + } else { + // Validate container exists + if ( + pageFrameDescriptor.maxWidthPx === null && + pageFrameDescriptor.paddingLeftPx === null && + pageFrameDescriptor.paddingRightPx === null + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-container-not-found", + message: `Page container with data-contract="page-container" not found for surface "${descriptor.surfaceId}".`, + details: { + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } else { + // Validate max-width + const expectedMaxWidth = pageFrameContract.containerMaxWidthPx; + const actualMaxWidth = pageFrameDescriptor.maxWidthPx; + + if (actualMaxWidth === null) { + // Check if clamp/calc was detected + if (pageFrameDescriptor.maxWidthHasClampCalc) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-non-deterministic-value", + message: `Page frame max-width uses non-deterministic expression (clamp/calc) for surface "${descriptor.surfaceId}". Expected ${expectedMaxWidth}px. Static analysis requires deterministic px values. Use fixed px values in inline styles or CSS rules targeting [data-contract="page-container"].`, + details: { + property: "max-width", + expected: expectedMaxWidth, + actual: null, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } else { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-unextractable-value", + message: `Page frame max-width could not be extracted for surface "${descriptor.surfaceId}". Expected ${expectedMaxWidth}px. Use inline styles, CSS rules targeting [data-contract="page-container"], or Tailwind bracket classes (max-w-[${expectedMaxWidth}px]).`, + details: { + property: "max-width", + expected: expectedMaxWidth, + actual: null, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + } else if (actualMaxWidth !== expectedMaxWidth) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-maxwidth-mismatch", + message: `Page frame max-width mismatch for surface "${descriptor.surfaceId}": expected ${expectedMaxWidth}px, found ${actualMaxWidth}px.`, + details: { + expected: expectedMaxWidth, + actual: actualMaxWidth, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + + // Validate padding + const expectedPadding = pageFrameContract.paddingXpx; + const actualPaddingLeft = pageFrameDescriptor.paddingLeftPx; + const actualPaddingRight = pageFrameDescriptor.paddingRightPx; + + if (actualPaddingLeft === null || actualPaddingRight === null) { + // Check if clamp/calc was detected + if (pageFrameDescriptor.paddingHasClampCalc) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-non-deterministic-value", + message: `Page frame padding uses non-deterministic expression (clamp/calc) for surface "${descriptor.surfaceId}". Expected ${expectedPadding}px. Static analysis requires deterministic px values. Use fixed px values in inline styles or CSS rules targeting [data-contract="page-container"].`, + details: { + property: "padding", + expected: expectedPadding, + actualLeft: actualPaddingLeft, + actualRight: actualPaddingRight, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } else { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-unextractable-value", + message: `Page frame padding could not be extracted for surface "${descriptor.surfaceId}". Expected ${expectedPadding}px. Static analysis requires deterministic px values. Use inline styles, CSS rules targeting [data-contract="page-container"], or Tailwind bracket classes (px-[${expectedPadding}px]).`, + details: { + property: "padding", + expected: expectedPadding, + actualLeft: actualPaddingLeft, + actualRight: actualPaddingRight, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + } else if ( + actualPaddingLeft !== expectedPadding || + actualPaddingRight !== expectedPadding + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "layout-pageframe-padding-mismatch", + message: `Page frame padding mismatch for surface "${descriptor.surfaceId}": expected ${expectedPadding}px on both sides, found left=${actualPaddingLeft}px right=${actualPaddingRight}px.`, + details: { + expected: expectedPadding, + actualLeft: actualPaddingLeft, + actualRight: actualPaddingRight, + selector: containerSelector, + source: pageFrameDescriptor.source, + }, + }); + } + } + } + + // Apply enforcement mode: warn mode violations don't affect exit code + // This is handled at the CLI level by checking violation severity + } + return { surfaceId: descriptor.surfaceId, violations, @@ -287,10 +582,21 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] { return errors.map((error) => { const dataPath = error.instancePath || error.schemaPath; const baseMessage = error.message ?? "Validation error"; + + // Enhance error messages for common schema issues + let enhancedMessage = baseMessage; + if (error.keyword === "additionalProperties" && error.params?.additionalProperty) { + const prop = error.params.additionalProperty; + enhancedMessage = `Additional property "${prop}" is not allowed. This may indicate a capability gap - the field is not supported by the current schema version.`; + } else if (error.keyword === "required" && error.params?.missingProperty) { + const prop = error.params.missingProperty; + enhancedMessage = `Required property "${prop}" is missing.`; + } + if (error.params && Object.keys(error.params).length > 0) { - return `${dataPath}: ${baseMessage} (${JSON.stringify(error.params)})`; + return `${dataPath}: ${enhancedMessage} (${JSON.stringify(error.params)})`; } - return `${dataPath}: ${baseMessage}`; + return `${dataPath}: ${enhancedMessage}`; }); } @@ -318,6 +624,7 @@ export type { SurfaceColorDescriptor, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, + PageFrameLayoutDescriptor, SurfaceReport, DriftViolation, ValidationSummary, diff --git a/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json b/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json index 7a0a1f6..65c36ba 100644 --- a/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json +++ b/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json @@ -43,6 +43,9 @@ }, "constraints": { "$ref": "#/$defs/constraints" + }, + "color": { + "$ref": "#/$defs/color" } }, "$defs": { @@ -55,7 +58,6 @@ "type", "requiredSections", "allowedFonts", - "allowedColors", "layout" ], "properties": { @@ -99,7 +101,8 @@ "type": "string", "minLength": 1 }, - "uniqueItems": true + "uniqueItems": true, + "description": "Deprecated: Use color.sourceOfTruth and color.rawValues instead. This field will be removed in a future version." }, "layout": { "type": "object", @@ -119,6 +122,9 @@ "minLength": 1 }, "uniqueItems": true + }, + "pageFrame": { + "$ref": "#/$defs/pageFrame" } } } @@ -183,6 +189,189 @@ } } } + }, + "pageFrame": { + "type": "object", + "additionalProperties": false, + "required": [ + "containerSelector", + "containerMaxWidthPx", + "paddingXpx" + ], + "properties": { + "containerSelector": { + "type": "string", + "minLength": 1 + }, + "containerMaxWidthPx": { + "type": "number", + "minimum": 0 + }, + "paddingXpx": { + "type": "number", + "minimum": 0 + }, + "alignment": { + "type": "string", + "enum": ["center", "left"], + "default": "center" + }, + "enforcement": { + "type": "string", + "enum": ["strict", "warn"], + "default": "strict" + } + } + }, + "color": { + "type": "object", + "additionalProperties": false, + "properties": { + "sourceOfTruth": { + "$ref": "#/$defs/colorSourceOfTruth" + }, + "rawValues": { + "$ref": "#/$defs/colorRawValues" + }, + "semantics": { + "$ref": "#/$defs/colorSemantics" + }, + "consistency": { + "$ref": "#/$defs/colorConsistency" + } + } + }, + "colorSourceOfTruth": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["tokens", "none"] + }, + "tokenNamespaces": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, + "if": { + "properties": { + "type": { + "const": "tokens" + } + } + }, + "then": { + "required": ["tokenNamespaces"] + } + }, + "colorRawValues": { + "type": "object", + "additionalProperties": false, + "required": ["policy"], + "properties": { + "policy": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "allowlist": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "denylist": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + }, + "colorSemantics": { + "type": "object", + "additionalProperties": false, + "properties": { + "roles": { + "type": "object", + "additionalProperties": false, + "properties": { + "accent": { + "type": "object", + "additionalProperties": false, + "properties": { + "enforcement": { + "type": "string", + "enum": ["off", "warn", "strict"] + } + } + }, + "text": { + "type": "object", + "additionalProperties": false, + "properties": { + "enforcement": { + "type": "string", + "enum": ["off", "warn", "strict"] + } + } + }, + "background": { + "type": "object", + "additionalProperties": false, + "properties": { + "enforcement": { + "type": "string", + "enum": ["off", "warn", "strict"] + } + } + }, + "border": { + "type": "object", + "additionalProperties": false, + "properties": { + "enforcement": { + "type": "string", + "enum": ["off", "warn", "strict"] + } + } + } + } + } + } + }, + "colorConsistency": { + "type": "object", + "additionalProperties": false, + "properties": { + "acrossSurfaces": { + "type": "object", + "additionalProperties": false, + "properties": { + "enforcement": { + "type": "string", + "enum": ["off", "warn", "strict"] + }, + "signals": { + "type": "array", + "items": { + "type": "string", + "enum": ["token-name", "css-var-name", "class-fragment"] + }, + "uniqueItems": true + } + } + } + } } } } diff --git a/packages/interfacectl-validator/src/types.ts b/packages/interfacectl-validator/src/types.ts index 1746757..339e7f3 100644 --- a/packages/interfacectl-validator/src/types.ts +++ b/packages/interfacectl-validator/src/types.ts @@ -1,15 +1,24 @@ export type SurfaceType = "web" | "cli"; +export interface PageFrameLayout { + containerSelector: string; + containerMaxWidthPx: number; + paddingXpx: number; + alignment?: "center" | "left"; + enforcement?: "strict" | "warn"; +} + export interface ContractSurface { id: string; displayName: string; type: SurfaceType; requiredSections: string[]; allowedFonts: string[]; - allowedColors: string[]; + allowedColors?: string[]; // Deprecated: use color.sourceOfTruth and color.rawValues layout: { maxContentWidth: number; requiredContainers?: string[]; + pageFrame?: PageFrameLayout; }; } @@ -26,6 +35,44 @@ export interface ContractConstraints { }; } +export interface ColorSourceOfTruth { + type: "tokens" | "none"; + tokenNamespaces?: string[]; +} + +export interface ColorRawValues { + policy: "off" | "warn" | "strict"; + allowlist?: string[]; + denylist?: string[]; +} + +export interface ColorRoleEnforcement { + enforcement: "off" | "warn" | "strict"; +} + +export interface ColorSemantics { + roles?: { + accent?: ColorRoleEnforcement; + text?: ColorRoleEnforcement; + background?: ColorRoleEnforcement; + border?: ColorRoleEnforcement; + }; +} + +export interface ColorConsistency { + acrossSurfaces?: { + enforcement: "off" | "warn" | "strict"; + signals?: ("token-name" | "css-var-name" | "class-fragment")[]; + }; +} + +export interface ColorPolicy { + sourceOfTruth?: ColorSourceOfTruth; + rawValues?: ColorRawValues; + semantics?: ColorSemantics; + consistency?: ColorConsistency; +} + export interface InterfaceContract { contractId: string; version: string; @@ -33,6 +80,7 @@ export interface InterfaceContract { surfaces: ContractSurface[]; sections: ContractSection[]; constraints: ContractConstraints; + color?: ColorPolicy; } export interface SurfaceSectionDescriptor { @@ -56,11 +104,22 @@ export interface SurfaceMotionDescriptor { source?: string; } +export interface PageFrameLayoutDescriptor { + containerSelector: string; + maxWidthPx?: number | null; + paddingLeftPx?: number | null; + paddingRightPx?: number | null; + source?: string; + maxWidthHasClampCalc?: boolean; + paddingHasClampCalc?: boolean; +} + export interface SurfaceLayoutDescriptor { maxContentWidth?: number | null; containers?: string[]; containerSources?: string[]; source?: string; + pageFrame?: PageFrameLayoutDescriptor; } export interface SurfaceDescriptor { @@ -81,10 +140,18 @@ export type DriftViolationType = | "layout-width-exceeded" | "layout-width-undetermined" | "layout-container-missing" + | "layout-pageframe-selector-unsupported" + | "layout-pageframe-container-not-found" + | "layout-pageframe-maxwidth-mismatch" + | "layout-pageframe-padding-mismatch" + | "layout-pageframe-non-deterministic-value" + | "layout-pageframe-unextractable-value" | "motion-duration-not-allowed" | "motion-timing-not-allowed" | "descriptor-missing" - | "descriptor-unused"; + | "descriptor-unused" + | "color-raw-value-used" + | "color-token-namespace-violation"; export interface DriftViolation { surfaceId: string;