diff --git a/README.md b/README.md index 3cea273..e020ab2 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 is not part of the known schema (catches typos like `colours:`) | ### 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..51730b3 100644 --- a/packages/cli/src/linter/index.test.ts +++ b/packages/cli/src/linter/index.test.ts @@ -129,4 +129,21 @@ 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 === 'Unexpected unknown top-level key "colours"' + ); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('warning'); + expect(finding!.path).toBe('colours'); + }); }); 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/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..c892f28 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/unknown-key.test.ts @@ -0,0 +1,68 @@ +// 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('emits a warning for a misspelled top-level key', () => { + 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('Unexpected unknown top-level key "colours"'); + }); + + 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 unknown key', () => { + const state = buildState({ + sourceMap: new Map([ + ['colors', loc], + ['colours', loc], + ['typografy', 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..3f4cff0 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/unknown-key.ts @@ -0,0 +1,36 @@ +// 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'; + +/** + * Unknown key — warns when a top-level YAML key (in front matter or a fenced + * yaml block) is not part of the known schema. A misspelled section name + * (e.g. `colours`) is otherwise silently discarded by the parser, leaving the + * author with no signal that an entire block of tokens was ignored. + */ +export function unknownKey(state: DesignSystemState): RuleFinding[] { + return (state.unknownKeys ?? []).map(key => ({ + path: key, + message: `Unexpected unknown top-level key "${key}"`, + })); +} + +export const unknownKeyRule: RuleDescriptor = { + name: 'unknown-key', + severity: 'warning', + description: 'Unknown key — warns when a top-level YAML key is not part of the known schema.', + run: unknownKey, +}; diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index caca7e4..9cc3f53 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -29,6 +29,18 @@ import { parseCssColor } from './color-parser.js'; const MAX_REFERENCE_DEPTH = 10; +/** Known top-level YAML keys, per docs/spec.md. */ +export const KNOWN_TOP_LEVEL_KEYS: ReadonlySet = new Set([ + 'version', + 'name', + 'description', + 'colors', + 'typography', + 'rounded', + 'spacing', + 'components', +]); + /** * Builds a resolved DesignSystemState from parsed YAML tokens. * Handles color parsing, dimension parsing, typography construction, @@ -205,6 +217,10 @@ export class ModelHandler implements ModelSpec { } } + const unknownKeys = [...input.sourceMap.keys()].filter( + key => !KNOWN_TOP_LEVEL_KEYS.has(key) + ); + return { designSystem: { name: input.name, @@ -216,6 +232,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 {