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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions VERIFICATION_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion packages/interfacectl-cli/dist/commands/validate.d.ts.map

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

133 changes: 114 additions & 19 deletions packages/interfacectl-cli/dist/commands/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -145,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,
Expand Down Expand Up @@ -188,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") {
Expand Down Expand Up @@ -272,8 +300,16 @@ 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",
"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) {
Expand Down Expand Up @@ -331,6 +367,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;
Expand All @@ -356,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;
}
Expand Down Expand Up @@ -442,7 +536,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`));
Expand Down

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

Loading
Loading