Skip to content
Open
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ The tokens are the normative values. The prose provides context for how to apply
version: <string> # optional, current: "alpha"
name: <string>
description: <string> # optional
omitted: <string[] | OmittedSection[]> # optional, list of sections to intentionally omit
colors:
<token-name>: <Color>
typography:
Expand Down Expand Up @@ -308,7 +309,7 @@ npx @google/design.md spec --rules-only --format json

## Linting Rules

The linter runs nine rules against a parsed DESIGN.md. Each rule produces findings at a fixed severity level.
The linter runs eleven rules against a parsed DESIGN.md. Each rule produces findings at a fixed severity level.

| Rule | Severity | What it checks |
|:-----|:---------|:---------------|
Expand All @@ -321,6 +322,8 @@ The linter runs nine rules against a parsed DESIGN.md. Each rule produces findin
| `missing-typography` | warning | Colors are defined but no typography tokens exist — agents will use default fonts |
| `section-order` | warning | Sections appear out of the canonical order defined by the spec |
| `unknown-key` | warning | A top-level YAML key looks like a typo of a known schema key (e.g. `colours:` → `colors:`); custom extension keys stay silent |
| `token-like-ignored` | warning | An unknown top-level key has token-like values (e.g. hex colors, font families, dimensions) suggesting it was dropped or misspelled |
| `omitted-rules` | info | Validates the `omitted` configuration mapping for unknown or redundant sections |

### Programmatic API

Expand Down
12 changes: 12 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Below is the schema for the design tokens defined in the front matter:
version: <string> # optional, current version: "alpha"
name: <string>
description: <string> # optional
omitted: <string[]|OmittedSection[]> # optional
colors:
<token-name>: <Color>
typography:
Expand Down Expand Up @@ -83,6 +84,17 @@ Hex notation (`#RRGGBB`) remains the recommended default for simplicity and broa

**Dimension**: A dimension value is a string with a unit suffix. Valid units are: px, em, rem.

**Omitted**: An array of sections that are intentionally omitted from the design system. This suppresses linter warnings for missing sections (e.g. colors, typography, spacing, rounded, components). Each entry can be:

* A string representing the section name (e.g., `spacing`)
* An object of the form `{ section: string, reason?: string }` mapping a section to a documented reason for its omission:
```yaml
omitted:
- spacing
- section: rounded
reason: "No rounded corners defined in brand book"
```

**Token References**: A token reference must be wrapped in curly braces, and contain an object path to another value in the YAML tree. For most token groups, the reference must point to a primitive value (e.g., `colors.primary-60`), not a group (e.g., `colors`). Within the `components` section, references to composite values (e.g., `{typography.label-md}`) are permitted.

# Sections
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('export command', () => {
let errorSpy: any;

beforeEach(() => {
process.exitCode = undefined;
process.exitCode = 0;
logSpy = spyOn(console, 'log').mockImplementation(() => {});
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
});
Expand Down Expand Up @@ -66,7 +66,7 @@ describe('export command', () => {
expect(logSpy.mock.calls.length).toBe(0);
expect(errorSpy.mock.calls.length).toBe(1);
const error = JSON.parse(errorSpy.mock.calls[0][0]);
expect(error.error).toContain('Invalid format "not-a-format"');
expect(error.message).toContain('Invalid format "not-a-format"');
expect(process.exitCode).toBe(1);
});
});
2 changes: 1 addition & 1 deletion packages/cli/src/commands/spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,6 @@ describe('spec command', () => {
const output = JSON.parse(outputStr);
expect(output.spec).toBeDefined();
expect(output.rules).toBeDefined();
expect(output.rules.length).toBe(10);
expect(output.rules.length).toBe(11);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/linter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export {
missingTypography,
unknownKey,
tokenLikeIgnored,
omitted,
} from './linter/rules/index.js';
export { contrastRatio } from './model/handler.js';
export { TailwindEmitterHandler } from './tailwind/handler.js';
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/linter/linter/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { sectionOrderRule } from './section-order.js';
import { missingTypographyRule } from './missing-typography.js';
import { unknownKeyRule } from './unknown-key.js';
import { tokenLikeIgnoredRule } from './token-like-ignored.js';
import { omittedRule } from './omitted.js';

/** The default set of lint rule descriptors, in order. */
export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
Expand All @@ -38,6 +39,7 @@ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
sectionOrderRule,
unknownKeyRule,
tokenLikeIgnoredRule,
omittedRule,
];

/** Converts a RuleDescriptor into a LintRule by injecting severity into findings. */
Expand All @@ -47,6 +49,7 @@ function toLintRule(descriptor: RuleDescriptor): LintRule {
severity: finding.severity ?? descriptor.severity,
path: finding.path,
message: finding.message,
rule: finding.rule ?? descriptor.name,
}));
}

Expand All @@ -64,4 +67,5 @@ export { missingTypography } from './missing-typography.js';
export { unknownKey } from './unknown-key.js';
export { sectionOrder } from './section-order.js';
export { tokenLikeIgnored } from './token-like-ignored.js';
export { omittedRule as omitted } from './omitted.js';
export type { LintRule } from './types.js';
17 changes: 17 additions & 0 deletions packages/cli/src/linter/linter/rules/missing-sections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ describe('missingSections', () => {
expect(missingSections(state)).toEqual([]);
});

it('returns empty for missing sections listed as omitted', () => {
const state = buildState({
omitted: [{ section: 'spacing' }, { section: 'rounded' }],
colors: { primary: '#ff0000' },
});
expect(missingSections(state)).toEqual([]);
});

it('still reports sections not listed as omitted', () => {
const state = buildState({
omitted: [{ section: 'spacing' }],
colors: { primary: '#ff0000' },
});
const findings = missingSections(state);
expect(findings.map(d => d.path)).toEqual(['rounded']);
});

it('returns empty when no colors exist (nothing to compare against)', () => {
const state = buildState({});
expect(missingSections(state)).toEqual([]);
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/linter/linter/rules/missing-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ describe('missingTypography', () => {
expect(missingTypography(state)).toEqual([]);
});

it('returns empty when typography is listed as omitted', () => {
const state = buildState({
omitted: [{ section: 'typography' }],
colors: { primary: '#ff0000' },
});
expect(missingTypography(state)).toEqual([]);
});

it('returns empty when no colors defined (nothing to compare against)', () => {
const state = buildState({});
expect(missingTypography(state)).toEqual([]);
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/linter/linter/rules/missing-typography.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.section.toLowerCase()));
if (omitted.has('typography')) {
return [];
}

if (state.typography.size === 0 && state.colors.size > 0) {
return [{
path: 'typography',
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/src/linter/linter/rules/omitted.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { describe, it, expect } from 'bun:test';
import { omittedRules } from './omitted.js';
import { buildState } from './test-helpers.js';

describe('omittedRules', () => {
it('returns declared-omission info finding when a section is intentionally absent', () => {
const state = buildState({
omitted: [{ section: 'spacing' }],
colors: { primary: '#ff0000' },
});

const findings = omittedRules(state);
expect(findings.length).toBe(1);
expect(findings[0]).toEqual({
severity: 'info',
rule: 'declared-omission',
path: 'omitted.spacing',
message: 'spacing intentionally omitted — no spacing tokens will be validated',
});
});

it('returns redundant-omission warning when an omitted section actually has tokens defined', () => {
const state = buildState({
omitted: [{ section: 'spacing' }],
colors: { primary: '#ff0000' },
spacing: { unit: '8px' },
});

const findings = omittedRules(state);
expect(findings.length).toBe(1);
expect(findings[0]).toEqual({
severity: 'warning',
rule: 'redundant-omission',
path: 'omitted',
message: 'spacing listed in omitted but spacing tokens are defined — omitted declaration has no effect',
});
});

it('returns unknown-omission warning when an unknown section name is listed', () => {
const state = buildState({
omitted: [{ section: 'iconography', reason: 'No icons needed' }],
colors: { primary: '#ff0000' },
});

const findings = omittedRules(state);
expect(findings.length).toBe(1);
expect(findings[0]).toEqual({
severity: 'warning',
rule: 'unknown-omission',
path: 'omitted',
message: "unknown section name 'iconography' in omitted key",
});
});

it('handles mixed valid, redundant, and unknown entries', () => {
const state = buildState({
omitted: [
{ section: 'spacing' },
{ section: 'colors' },
{ section: 'iconography' },
],
colors: { primary: '#ff0000' },
});

const findings = omittedRules(state);
expect(findings.length).toBe(3);

// spacing (declared-omission info)
expect(findings.find(f => f.message.includes('spacing'))).toEqual({
severity: 'info',
rule: 'declared-omission',
path: 'omitted.spacing',
message: 'spacing intentionally omitted — no spacing tokens will be validated',
});

// colors (redundant-omission warning)
expect(findings.find(f => f.message.includes('colors'))).toEqual({
severity: 'warning',
rule: 'redundant-omission',
path: 'omitted',
message: 'colors listed in omitted but colors tokens are defined — omitted declaration has no effect',
});

// iconography (unknown-omission warning)
expect(findings.find(f => f.message.includes('iconography'))).toEqual({
severity: 'warning',
rule: 'unknown-omission',
path: 'omitted',
message: "unknown section name 'iconography' in omitted key",
});
});
});
73 changes: 73 additions & 0 deletions packages/cli/src/linter/linter/rules/omitted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';

/**
* Validates omitted sections and alerts on redundant or unknown entries.
*/
export function omittedRules(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
if (!state.omitted) return findings;

const validSections = new Set(['colors', 'typography', 'spacing', 'rounded', 'components']);

for (const item of state.omitted) {
const sectionName = item.section.toLowerCase();

if (!validSections.has(sectionName)) {
findings.push({
severity: 'warning',
rule: 'unknown-omission',
path: 'omitted',
message: `unknown section name '${item.section}' in omitted key`,
});
continue;
}

// Check if it's redundant (meaning it's defined and has tokens)
let isPresent = false;
if (sectionName === 'colors' && state.colors && state.colors.size > 0) isPresent = true;
if (sectionName === 'typography' && state.typography && state.typography.size > 0) isPresent = true;
if (sectionName === 'spacing' && state.spacing && state.spacing.size > 0) isPresent = true;
if (sectionName === 'rounded' && state.rounded && state.rounded.size > 0) isPresent = true;
if (sectionName === 'components' && state.components && state.components.size > 0) isPresent = true;

if (isPresent) {
findings.push({
severity: 'warning',
rule: 'redundant-omission',
path: 'omitted',
message: `${sectionName} listed in omitted but ${sectionName} tokens are defined — omitted declaration has no effect`,
});
} else {
findings.push({
severity: 'info',
rule: 'declared-omission',
path: `omitted.${sectionName}`,
message: `${sectionName} intentionally omitted — no ${sectionName} tokens will be validated`,
});
}
}

return findings;
}

export const omittedRule: RuleDescriptor = {
name: 'omitted-rules',
severity: 'info',
description: 'Validates omitted sections and alerts on redundant or unknown entries.',
run: omittedRules,
};
2 changes: 1 addition & 1 deletion packages/cli/src/linter/linter/rules/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('LintRule type', () => {
});

it('has all rules in DEFAULT_RULE_DESCRIPTORS', () => {
expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(10);
expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(11);
DEFAULT_RULE_DESCRIPTORS.forEach((rule: RuleDescriptor) => {
expect(rule.name).toBeTruthy();
expect(rule.severity).toBeTruthy();
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/linter/linter/rules/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface RuleFinding {
message: string;
/** Optional override of the descriptor's default severity. */
severity?: Severity;
rule?: string;
}

/** A pure lint rule: takes immutable state, returns findings. No side effects. */
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/linter/model/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export class ModelHandler implements ModelSpec {
designSystem: {
name: input.name,
description: input.description,
omitted: input.omitted,
colors,
typography,
rounded,
Expand Down
Loading