diff --git a/README.md b/README.md index 3cea273..e9c8836 100644 --- a/README.md +++ b/README.md @@ -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 | |:-----|:---------|:---------------| @@ -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 diff --git a/packages/cli/src/commands/spec.test.ts b/packages/cli/src/commands/spec.test.ts index 29f4ada..7a978c5 100644 --- a/packages/cli/src/commands/spec.test.ts +++ b/packages/cli/src/commands/spec.test.ts @@ -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); }); }); diff --git a/packages/cli/src/linter/index.test.ts b/packages/cli/src/linter/index.test.ts index 0145d74..ba12e98 100644 --- a/packages/cli/src/linter/index.test.ts +++ b/packages/cli/src/linter/index.test.ts @@ -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([]); + }); }); diff --git a/packages/cli/src/linter/index.ts b/packages/cli/src/linter/index.ts index 6dccb43..e91bab5 100644 --- a/packages/cli/src/linter/index.ts +++ b/packages/cli/src/linter/index.ts @@ -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'; diff --git a/packages/cli/src/linter/linter/rules/index.ts b/packages/cli/src/linter/linter/rules/index.ts index c3b2d9a..3b1ba26 100644 --- a/packages/cli/src/linter/linter/rules/index.ts +++ b/packages/cli/src/linter/linter/rules/index.ts @@ -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[] = [ @@ -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. */ @@ -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'; diff --git a/packages/cli/src/linter/linter/rules/levenshtein.test.ts b/packages/cli/src/linter/linter/rules/levenshtein.test.ts new file mode 100644 index 0000000..bc6f068 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/levenshtein.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/levenshtein.ts b/packages/cli/src/linter/linter/rules/levenshtein.ts new file mode 100644 index 0000000..0203b50 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/levenshtein.ts @@ -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]!; +} diff --git a/packages/cli/src/linter/linter/rules/types.test.ts b/packages/cli/src/linter/linter/rules/types.test.ts index 5caf460..c4458c8 100644 --- a/packages/cli/src/linter/linter/rules/types.test.ts +++ b/packages/cli/src/linter/linter/rules/types.test.ts @@ -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(); diff --git a/packages/cli/src/linter/linter/rules/unknown-key.test.ts b/packages/cli/src/linter/linter/rules/unknown-key.test.ts new file mode 100644 index 0000000..32d2350 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/unknown-key.test.ts @@ -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']); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/unknown-key.ts b/packages/cli/src/linter/linter/rules/unknown-key.ts new file mode 100644 index 0000000..159b781 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/unknown-key.ts @@ -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(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, +}; diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index caca7e4..8f480e9 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -13,6 +13,7 @@ // limitations under the License. import type { ParsedDesignSystem } from '../parser/spec.js'; +import { SCHEMA_KEYS } from '../parser/spec.js'; import type { ModelSpec, ModelResult, @@ -29,6 +30,8 @@ import { parseCssColor } from './color-parser.js'; const MAX_REFERENCE_DEPTH = 10; +const SCHEMA_KEY_SET: ReadonlySet = new Set(SCHEMA_KEYS); + /** * Builds a resolved DesignSystemState from parsed YAML tokens. * Handles color parsing, dimension parsing, typography construction, @@ -205,6 +208,10 @@ export class ModelHandler implements ModelSpec { } } + const unknownKeys = [...input.sourceMap.keys()].filter( + key => !SCHEMA_KEY_SET.has(key) + ); + return { designSystem: { name: input.name, @@ -216,6 +223,7 @@ export class ModelHandler implements ModelSpec { components, symbolTable, sections: input.sections, + unknownKeys, }, findings, }; diff --git a/packages/cli/src/linter/model/spec.ts b/packages/cli/src/linter/model/spec.ts index 0b390f6..a8b80bf 100644 --- a/packages/cli/src/linter/model/spec.ts +++ b/packages/cli/src/linter/model/spec.ts @@ -80,6 +80,8 @@ export interface DesignSystemState { symbolTable: Map; /** Markdown heading names found in the document */ sections?: string[] | undefined; + /** Top-level YAML keys that are not part of the known schema */ + unknownKeys?: string[] | undefined; } export interface ComponentDef { diff --git a/packages/cli/src/linter/parser/handler.ts b/packages/cli/src/linter/parser/handler.ts index 5ce47d4..ca7001d 100644 --- a/packages/cli/src/linter/parser/handler.ts +++ b/packages/cli/src/linter/parser/handler.ts @@ -191,6 +191,7 @@ export class ParserHandler implements ParserSpec { */ private toDesignSystem(raw: Record, sourceMap: Map, sections: string[], documentSections: Array<{ heading: string; content: string }>): ParsedDesignSystem { return { + version: typeof raw['version'] === 'string' ? raw['version'] : undefined, name: typeof raw['name'] === 'string' ? raw['name'] : undefined, description: typeof raw['description'] === 'string' ? raw['description'] : undefined, colors: raw['colors'] as Record | undefined, diff --git a/packages/cli/src/linter/parser/spec.ts b/packages/cli/src/linter/parser/spec.ts index 76ec876..663e07e 100644 --- a/packages/cli/src/linter/parser/spec.ts +++ b/packages/cli/src/linter/parser/spec.ts @@ -39,6 +39,7 @@ export interface SourceLocation { /** Raw, unresolved parsed output — mirrors the YAML schema */ export interface ParsedDesignSystem { + version?: string | undefined; name?: string | undefined; description?: string | undefined; colors?: Record | undefined; @@ -53,6 +54,20 @@ export interface ParsedDesignSystem { documentSections?: Array<{ heading: string; content: string }> | undefined; } +/** Canonical top-level YAML keys per the DESIGN.md schema. */ +export const SCHEMA_KEYS = [ + 'version', + 'name', + 'description', + 'colors', + 'typography', + 'rounded', + 'spacing', + 'components', +] as const; + +export type SchemaKey = typeof SCHEMA_KEYS[number]; + // ── RESULT ───────────────────────────────────────────────────────── export type ParserResult = | { success: true; data: ParsedDesignSystem }