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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ npx @google/design.md spec --rules-only --format json

## Linting Rules

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

| Rule | Severity | What it checks |
|:-----|:---------|:---------------|
Expand All @@ -305,6 +305,7 @@ The linter runs seven rules against a parsed DESIGN.md. Each rule produces findi
| `missing-sections` | info | Optional sections (spacing, rounded) absent when other tokens exist |
| `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 |

### Programmatic API

Expand Down
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(8);
expect(output.rules.length).toBe(9);
});
});
34 changes: 34 additions & 0 deletions packages/cli/src/linter/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,38 @@ components:
// Should have errors: invalid color + broken reference
expect(result.summary.errors).toBeGreaterThanOrEqual(2);
});

it('warns on a misspelled top-level key via the default rule set', () => {
const content = `---
name: Example
colours:
primary: "#647D66"
---`;

const result = lint(content);

const finding = result.findings.find(
f => f.message === 'Unknown key "colours" — did you mean "colors"?'
);
expect(finding).toBeDefined();
expect(finding!.severity).toBe('warning');
expect(finding!.path).toBe('colours');
});

it('stays silent for custom extension keys that are not close to any known key', () => {
const content = `---
name: Example
icons:
search: "search-icon.svg"
motion:
fast: "100ms"
---`;

const result = lint(content);

const unknownKeyFindings = result.findings.filter(f =>
f.message.startsWith('Unknown key ')
);
expect(unknownKeyFindings).toEqual([]);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/linter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export {
tokenSummary,
missingSections,
missingTypography,
unknownKey,
} from './linter/rules/index.js';
export { contrastRatio } from './model/handler.js';
export { TailwindEmitterHandler } from './tailwind/handler.js';
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/linter/linter/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { tokenSummaryRule } from './token-summary.js';
import { missingSectionsRule } from './missing-sections.js';
import { sectionOrderRule } from './section-order.js';
import { missingTypographyRule } from './missing-typography.js';
import { unknownKeyRule } from './unknown-key.js';

/** The default set of lint rule descriptors, in order. */
export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
Expand All @@ -34,6 +35,7 @@ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
missingSectionsRule,
missingTypographyRule,
sectionOrderRule,
unknownKeyRule,
];

/** Converts a RuleDescriptor into a LintRule by injecting severity into findings. */
Expand All @@ -57,5 +59,6 @@ export { orphanedTokens } from './orphaned-tokens.js';
export { tokenSummary } from './token-summary.js';
export { missingSections } from './missing-sections.js';
export { missingTypography } from './missing-typography.js';
export { unknownKey } from './unknown-key.js';
export { sectionOrder } from './section-order.js';
export type { LintRule } from './types.js';
60 changes: 60 additions & 0 deletions packages/cli/src/linter/linter/rules/levenshtein.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// 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 { levenshtein } from './levenshtein.js';

describe('levenshtein', () => {
it('returns 0 for identical strings', () => {
expect(levenshtein('colors', 'colors')).toBe(0);
});

it('returns 0 when both strings are empty', () => {
expect(levenshtein('', '')).toBe(0);
});

it('returns the length of the other string when one is empty', () => {
expect(levenshtein('', 'colors')).toBe(6);
expect(levenshtein('colors', '')).toBe(6);
});

it('counts a single substitution as distance 1', () => {
expect(levenshtein('cat', 'bat')).toBe(1);
});

it('counts a single insertion as distance 1', () => {
expect(levenshtein('cat', 'cats')).toBe(1);
});

it('counts a single deletion as distance 1', () => {
expect(levenshtein('cats', 'cat')).toBe(1);
});

it('is symmetric: levenshtein(a, b) === levenshtein(b, a)', () => {
expect(levenshtein('typografy', 'typography')).toBe(levenshtein('typography', 'typografy'));
expect(levenshtein('kitten', 'sitting')).toBe(levenshtein('sitting', 'kitten'));
});

it('matches the classic kitten/sitting example (distance 3)', () => {
expect(levenshtein('kitten', 'sitting')).toBe(3);
});

it('computes distance for the schema-key typo cases used by unknown-key', () => {
expect(levenshtein('colours', 'colors')).toBe(1);
expect(levenshtein('typografy', 'typography')).toBe(2);
expect(levenshtein('nam', 'name')).toBe(1);
expect(levenshtein('rounding', 'rounded')).toBe(3);
expect(levenshtein('icons', 'colors')).toBe(4);
});
});
30 changes: 30 additions & 0 deletions packages/cli/src/linter/linter/rules/levenshtein.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// 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.

/** Levenshtein edit distance between two strings. */
export function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i]![j] = a[i - 1] === b[j - 1]
? dp[i - 1]![j - 1]!
: 1 + Math.min(dp[i - 1]![j]!, dp[i]![j - 1]!, dp[i - 1]![j - 1]!);
}
}
return dp[m]![n]!;
}
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(8);
expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(9);
DEFAULT_RULE_DESCRIPTORS.forEach((rule: RuleDescriptor) => {
expect(rule.name).toBeTruthy();
expect(rule.severity).toBeTruthy();
Expand Down
114 changes: 114 additions & 0 deletions packages/cli/src/linter/linter/rules/unknown-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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 { unknownKey } from './unknown-key.js';
import { buildState } from './test-helpers.js';
import type { SourceLocation } from '../../parser/spec.js';

const loc: SourceLocation = { line: 1, column: 0, block: 'frontmatter' };

describe('unknownKey', () => {
it('warns and suggests "colors" for "colours" (distance 1)', () => {
const state = buildState({
sourceMap: new Map([
['name', loc],
['colours', loc],
]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('colours');
expect(findings[0]!.message).toBe('Unknown key "colours" — did you mean "colors"?');
});

it('warns and suggests "typography" for "typografy" (distance 2)', () => {
const state = buildState({
sourceMap: new Map([['typografy', loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toBe('Unknown key "typografy" — did you mean "typography"?');
});

it('warns and suggests "name" for "nam" (distance 1)', () => {
const state = buildState({
sourceMap: new Map([['nam', loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toBe('Unknown key "nam" — did you mean "name"?');
});

it('matches case-insensitively (e.g. "Colors" is treated as known)', () => {
const state = buildState({
sourceMap: new Map([['Colors', loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toBe('Unknown key "Colors" — did you mean "colors"?');
});

it('stays silent for far-from-any-key extension keys', () => {
const state = buildState({
sourceMap: new Map([
['icons', loc],
['motion', loc],
['brand', loc],
]),
});
expect(unknownKey(state)).toEqual([]);
});

it('stays silent for "rounding" (distance 3 from "rounded")', () => {
const state = buildState({
sourceMap: new Map([['rounding', loc]]),
});
expect(unknownKey(state)).toEqual([]);
});

it('returns empty when all top-level keys are known', () => {
const state = buildState({
sourceMap: new Map([
['version', loc],
['name', loc],
['description', loc],
['colors', loc],
['typography', loc],
['rounded', loc],
['spacing', loc],
['components', loc],
]),
});
expect(unknownKey(state)).toEqual([]);
});

it('returns empty when there are no top-level keys', () => {
const state = buildState({});
expect(unknownKey(state)).toEqual([]);
});

it('emits one finding per misspelled key and ignores unrelated extension keys', () => {
const state = buildState({
sourceMap: new Map([
['colors', loc],
['colours', loc],
['typografy', loc],
['icons', loc],
]),
});
const findings = unknownKey(state);
expect(findings.map(f => f.path).sort()).toEqual(['colours', 'typografy']);
});
});
60 changes: 60 additions & 0 deletions packages/cli/src/linter/linter/rules/unknown-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// 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 { SCHEMA_KEYS } from '../../parser/spec.js';
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
import { levenshtein } from './levenshtein.js';

/** Max edit distance to consider a typo (not a custom key). */
const MAX_TYPO_DISTANCE = 2;

/**
* Unknown key — warns when a top-level YAML key looks like a typo of a known
* schema key. The DESIGN.md schema is intentionally extensible (custom keys
* are allowed), so only close matches to known keys are reported; unrelated
* extension keys stay silent.
*/
export function unknownKey(state: DesignSystemState): RuleFinding[] {
const knownSet = new Set<string>(SCHEMA_KEYS);
return (state.unknownKeys ?? []).flatMap(key => {
if (knownSet.has(key)) return [];

let bestMatch: string | undefined;
let bestDist = Infinity;
for (const known of SCHEMA_KEYS) {
const dist = levenshtein(key.toLowerCase(), known.toLowerCase());
if (dist < bestDist) {
bestDist = dist;
bestMatch = known;
}
}

if (bestDist <= MAX_TYPO_DISTANCE && bestMatch) {
return [{
path: key,
message: `Unknown key "${key}" — did you mean "${bestMatch}"?`,
}];
}

return [];
});
}

export const unknownKeyRule: RuleDescriptor = {
name: 'unknown-key',
severity: 'warning',
description: 'Unknown key — warns when a top-level YAML key looks like a typo of a known schema key.',
run: unknownKey,
};
Loading
Loading