diff --git a/packages/cli/src/linter/linter/rules/missing-sections.test.ts b/packages/cli/src/linter/linter/rules/missing-sections.test.ts index dd3c393..40390d5 100644 --- a/packages/cli/src/linter/linter/rules/missing-sections.test.ts +++ b/packages/cli/src/linter/linter/rules/missing-sections.test.ts @@ -38,6 +38,45 @@ describe('missingSections', () => { expect(missingSections(state)).toEqual([]); }); + it('returns empty for missing sections listed as omitted', () => { + const state = buildState({ + omitted: ['spacing', 'rounded'], + colors: { primary: '#ff0000' }, + }); + + expect(missingSections(state)).toEqual([]); + }); + + it('still reports sections not listed as omitted', () => { + const state = buildState({ + omitted: ['spacing'], + colors: { primary: '#ff0000' }, + }); + + const findings = missingSections(state); + expect(findings.map(d => d.path)).toEqual(['rounded']); + }); + + it('matches omitted section names case-insensitively', () => { + const state = buildState({ + omitted: ['Spacing'], + colors: { primary: '#ff0000' }, + rounded: { regular: '4px' }, + }); + + expect(missingSections(state)).toEqual([]); + }); + + it('treats empty omitted lists like no omitted key', () => { + const state = buildState({ + omitted: [], + colors: { primary: '#ff0000' }, + rounded: { regular: '4px' }, + }); + + expect(missingSections(state).map(d => d.path)).toEqual(['spacing']); + }); + it('returns empty when no colors exist (nothing to compare against)', () => { const state = buildState({}); expect(missingSections(state)).toEqual([]); diff --git a/packages/cli/src/linter/linter/rules/missing-sections.ts b/packages/cli/src/linter/linter/rules/missing-sections.ts index 7fcc205..4ae7908 100644 --- a/packages/cli/src/linter/linter/rules/missing-sections.ts +++ b/packages/cli/src/linter/linter/rules/missing-sections.ts @@ -20,12 +20,15 @@ import type { RuleDescriptor, RuleFinding } from './types.js'; */ export function missingSections(state: DesignSystemState): RuleFinding[] { const findings: RuleFinding[] = []; + const omitted = new Set((state.omitted ?? []).map(section => section.toLowerCase())); const sections = [ { map: state.spacing, name: 'spacing', fallback: 'Layout spacing will fall back to agent defaults.' }, { map: state.rounded, name: 'rounded', fallback: 'Corner rounding will fall back to agent defaults.' }, ]; for (const { map, name, fallback } of sections) { + if (omitted.has(name)) continue; + if (map.size === 0 && state.colors.size > 0) { findings.push({ path: name, diff --git a/packages/cli/src/linter/linter/rules/missing-typography.test.ts b/packages/cli/src/linter/linter/rules/missing-typography.test.ts index b76022d..7f23801 100644 --- a/packages/cli/src/linter/linter/rules/missing-typography.test.ts +++ b/packages/cli/src/linter/linter/rules/missing-typography.test.ts @@ -41,6 +41,33 @@ describe('missingTypography', () => { expect(missingTypography(state)).toEqual([]); }); + it('returns empty when typography is listed as omitted', () => { + const state = buildState({ + omitted: ['typography'], + colors: { primary: '#ff0000' }, + }); + + expect(missingTypography(state)).toEqual([]); + }); + + it('matches omitted typography case-insensitively', () => { + const state = buildState({ + omitted: ['Typography'], + colors: { primary: '#ff0000' }, + }); + + expect(missingTypography(state)).toEqual([]); + }); + + it('treats empty omitted lists like no omitted key', () => { + const state = buildState({ + omitted: [], + colors: { primary: '#ff0000' }, + }); + + expect(missingTypography(state).map(d => d.path)).toEqual(['typography']); + }); + it('returns empty when no colors defined (nothing to compare against)', () => { const state = buildState({}); expect(missingTypography(state)).toEqual([]); diff --git a/packages/cli/src/linter/linter/rules/missing-typography.ts b/packages/cli/src/linter/linter/rules/missing-typography.ts index 03ba9f5..ed6048d 100644 --- a/packages/cli/src/linter/linter/rules/missing-typography.ts +++ b/packages/cli/src/linter/linter/rules/missing-typography.ts @@ -21,6 +21,11 @@ import type { RuleDescriptor, RuleFinding } from './types.js'; * reducing the author's control over the design system's typographic identity. */ export function missingTypography(state: DesignSystemState): RuleFinding[] { + const omitted = new Set((state.omitted ?? []).map(section => section.toLowerCase())); + if (omitted.has('typography')) { + return []; + } + if (state.typography.size === 0 && state.colors.size > 0) { return [{ path: 'typography', diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index bee269f..8421844 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -229,6 +229,7 @@ export class ModelHandler implements ModelSpec { designSystem: { name: input.name, description: input.description, + omitted: input.omitted, colors, typography, rounded, @@ -438,4 +439,4 @@ function forEachLeaf( fn(fullPath, value); } } -} \ No newline at end of file +} diff --git a/packages/cli/src/linter/model/spec.test.ts b/packages/cli/src/linter/model/spec.test.ts index 01a0a33..45cb13e 100644 --- a/packages/cli/src/linter/model/spec.test.ts +++ b/packages/cli/src/linter/model/spec.test.ts @@ -14,6 +14,17 @@ import { describe, it, expect } from 'bun:test'; import { isValidColor, isStandardDimension, isParseableDimension, parseDimensionParts, isTokenReference } from './spec.js'; +import { ModelHandler } from './handler.js'; +import type { ParsedDesignSystem } from '../parser/spec.js'; + +const handler = new ModelHandler(); + +function makeParsed(overrides: Partial = {}): ParsedDesignSystem { + return { + sourceMap: new Map(), + ...overrides, + }; +} describe('isValidColor', () => { const validColors = ['#ff0000', '#FF0000', '#abc', '#ABC', '#647D66', '#000', '#fff', 'red', 'blue']; @@ -95,3 +106,24 @@ describe('isTokenReference', () => { expect(isTokenReference('{ colors.primary }')).toBe(false); }); }); + +describe('omitted model metadata', () => { + it('passes omitted through to the design system state', () => { + const result = handler.execute(makeParsed({ + omitted: ['spacing', 'rounded'], + })); + + expect(result.designSystem.omitted).toEqual(['spacing', 'rounded']); + }); + + it('treats omitted as a known top-level key', () => { + const result = handler.execute(makeParsed({ + omitted: ['typography'], + sourceMap: new Map([ + ['omitted', { line: 1, column: 0, block: 'frontmatter' as const }], + ]), + })); + + expect(result.designSystem.unknownKeys).toEqual([]); + }); +}); diff --git a/packages/cli/src/linter/model/spec.ts b/packages/cli/src/linter/model/spec.ts index 115ee47..701553e 100644 --- a/packages/cli/src/linter/model/spec.ts +++ b/packages/cli/src/linter/model/spec.ts @@ -71,6 +71,7 @@ export const VALID_COMPONENT_SUB_TOKENS = _VALID_COMPONENT_SUB_TOKENS; export interface DesignSystemState { name?: string | undefined; description?: string | undefined; + omitted?: string[] | undefined; colors: Map; typography: Map; rounded: Map; diff --git a/packages/cli/src/linter/parser/handler.ts b/packages/cli/src/linter/parser/handler.ts index 4475aab..d1c0faa 100644 --- a/packages/cli/src/linter/parser/handler.ts +++ b/packages/cli/src/linter/parser/handler.ts @@ -194,6 +194,7 @@ export class ParserHandler implements ParserSpec { version: typeof raw['version'] === 'string' ? raw['version'] : undefined, name: typeof raw['name'] === 'string' ? raw['name'] : undefined, description: typeof raw['description'] === 'string' ? raw['description'] : undefined, + omitted: Array.isArray(raw['omitted']) ? raw['omitted'].filter(v => typeof v === 'string') : undefined, colors: raw['colors'] as Record | undefined, typography: raw['typography'] as Record> | undefined, rounded: raw['rounded'] as Record | undefined, diff --git a/packages/cli/src/linter/parser/spec.test.ts b/packages/cli/src/linter/parser/spec.test.ts index 28f6250..c393db6 100644 --- a/packages/cli/src/linter/parser/spec.test.ts +++ b/packages/cli/src/linter/parser/spec.test.ts @@ -13,7 +13,8 @@ // limitations under the License. import { describe, it, expect } from 'bun:test'; -import { ParserInputSchema } from './spec.js'; +import { ParserHandler } from './handler.js'; +import { ParserInputSchema, SCHEMA_KEYS } from './spec.js'; describe('ParserInputSchema', () => { it('rejects empty content', () => { @@ -31,3 +32,62 @@ describe('ParserInputSchema', () => { expect(result.success).toBe(false); }); }); + +describe('SCHEMA_KEYS', () => { + it('includes omitted as a known top-level key', () => { + expect(SCHEMA_KEYS).toContain('omitted'); + }); +}); + +describe('omitted frontmatter parsing', () => { + const handler = new ParserHandler(); + + it('extracts omitted string arrays', () => { + const result = handler.execute({ + content: `--- +omitted: + - spacing + - rounded +colors: + primary: "#ff0000" +---`, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.omitted).toEqual(['spacing', 'rounded']); + } + }); + + it('ignores non-array omitted values', () => { + const result = handler.execute({ + content: `--- +omitted: typography +colors: + primary: "#ff0000" +---`, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.omitted).toBeUndefined(); + } + }); + + it('filters non-string omitted entries', () => { + const result = handler.execute({ + content: `--- +omitted: + - spacing + - 42 + - true + - rounded +---`, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.omitted).toEqual(['spacing', 'rounded']); + } + }); +}); diff --git a/packages/cli/src/linter/parser/spec.ts b/packages/cli/src/linter/parser/spec.ts index 6e84d12..b3878f3 100644 --- a/packages/cli/src/linter/parser/spec.ts +++ b/packages/cli/src/linter/parser/spec.ts @@ -42,6 +42,7 @@ export interface ParsedDesignSystem { version?: string | undefined; name?: string | undefined; description?: string | undefined; + omitted?: string[] | undefined; colors?: Record | undefined; typography?: Record> | undefined; rounded?: Record | undefined; @@ -61,6 +62,7 @@ export const SCHEMA_KEYS = [ 'version', 'name', 'description', + 'omitted', 'colors', 'typography', 'rounded',