diff --git a/README.md b/README.md index ed855c5..ae1a076 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,20 @@ console.log(report.summary); // { errors, warnings, info } console.log(report.designSystem); // Parsed DesignSystemState ``` +Projects can declare additional component sub-tokens for the `broken-ref` rule. +The configured names are added to the built-in vocabulary, so undeclared names +still produce typo warnings and unresolved token references remain errors: + +```typescript +const report = lint(markdownString, { + ruleOptions: { + 'broken-ref': { + additionalComponentSubTokens: ['owner', 'gap'], + }, + }, +}); +``` + ## Design Token Interoperability DESIGN.md tokens are inspired by the [W3C Design Token Format](https://www.designtokens.org/). The `export` command converts tokens to other formats: @@ -362,4 +376,4 @@ The DESIGN.md format is at version `alpha`. The spec, token schema, and CLI are ## Disclaimer This project is not eligible for the [Google Open Source Software Vulnerability -Rewards Program](https://bughunters.google.com/open-source-security). \ No newline at end of file +Rewards Program](https://bughunters.google.com/open-source-security). diff --git a/packages/cli/src/linter/index.test.ts b/packages/cli/src/linter/index.test.ts index ba12e98..b3c8416 100644 --- a/packages/cli/src/linter/index.test.ts +++ b/packages/cli/src/linter/index.test.ts @@ -163,4 +163,30 @@ motion: ); expect(unknownKeyFindings).toEqual([]); }); + + it('passes per-rule options to built-in lint rules', () => { + const content = `--- +name: Extended components +spacing: + md: 16px +components: + stack: + gap: "{spacing.md}" + owner: "@example/stack" + gaap: "{spacing.md}" +---`; + + const result = lint(content, { + ruleOptions: { + 'broken-ref': { + additionalComponentSubTokens: ['gap', 'owner'], + }, + }, + }); + + const unknownSubTokens = result.findings.filter( + f => f.rule === 'broken-ref' && f.message.includes('not a recognized') + ); + expect(unknownSubTokens.map(f => f.path)).toEqual(['components.stack.gaap']); + }); }); diff --git a/packages/cli/src/linter/index.ts b/packages/cli/src/linter/index.ts index 4fd4bf7..adcff3c 100644 --- a/packages/cli/src/linter/index.ts +++ b/packages/cli/src/linter/index.ts @@ -34,8 +34,9 @@ export type { CssVarsEmitterResult, CssVarDeclaration } from './css-vars/spec.js // ── Advanced linting ─────────────────────────────────────────────── export { runLinter, preEvaluate } from './linter/runner.js'; -export { DEFAULT_RULES } from './linter/rules/index.js'; -export type { LintRule } from './linter/rules/types.js'; +export { DEFAULT_RULES, DEFAULT_RULE_DESCRIPTORS } from './linter/rules/index.js'; +export type { LintRule, RuleDescriptor, RuleOptions } from './linter/rules/types.js'; +export type { BrokenRefOptions } from './linter/rules/broken-ref.js'; export type { GradedTokenEdits, TokenEditEntry } from './linter/spec.js'; export { brokenRef, diff --git a/packages/cli/src/linter/lint.ts b/packages/cli/src/linter/lint.ts index b25dbfd..499da27 100644 --- a/packages/cli/src/linter/lint.ts +++ b/packages/cli/src/linter/lint.ts @@ -20,11 +20,17 @@ import { TailwindEmitterHandler } from './tailwind/handler.js'; import type { DesignSystemState } from './model/spec.js'; import type { Finding } from './linter/spec.js'; import type { LintRule } from './linter/rules/types.js'; +import type { BrokenRefOptions } from './linter/rules/broken-ref.js'; +import { DEFAULT_RULE_DESCRIPTORS } from './linter/rules/index.js'; import type { TailwindEmitterResult } from './tailwind/spec.js'; export interface LintOptions { - /** Custom lint rules. Defaults to DEFAULT_RULES if omitted. */ + /** Custom lint rules. Defaults to the built-in rules if omitted. */ rules?: LintRule[]; + /** Options for individual built-in lint rules. */ + ruleOptions?: { + 'broken-ref'?: BrokenRefOptions; + }; } export interface LintReport { @@ -89,7 +95,11 @@ export function lint(content: string, options?: LintOptions): LintReport { } const { designSystem, findings: modelFindings } = model.execute(parseResult.data); - const lintResult = runLinter(designSystem, options?.rules); + const lintResult = runLinter( + designSystem, + options?.rules ?? DEFAULT_RULE_DESCRIPTORS, + options?.ruleOptions, + ); const tailwindConfig = tailwind.execute(designSystem); const findings = [...modelFindings, ...lintResult.findings]; diff --git a/packages/cli/src/linter/linter/rules/broken-ref.test.ts b/packages/cli/src/linter/linter/rules/broken-ref.test.ts index 13ac68a..c700cc7 100644 --- a/packages/cli/src/linter/linter/rules/broken-ref.test.ts +++ b/packages/cli/src/linter/linter/rules/broken-ref.test.ts @@ -46,6 +46,38 @@ describe('brokenRef', () => { expect(subTokenDiag!.severity).toBe('warning'); }); + it('accepts additional project-owned component sub-tokens', () => { + const state = buildState({ + spacing: { md: '16px' }, + components: { + stack: { + gap: '{spacing.md}', + owner: '@example/stack', + gaap: '{spacing.md}', + }, + }, + }); + const findings = brokenRef(state, { + additionalComponentSubTokens: ['gap', 'owner'], + }); + + expect(findings.some(d => d.path === 'components.stack.gap')).toBe(false); + expect(findings.some(d => d.path === 'components.stack.owner')).toBe(false); + expect(findings.some(d => d.path === 'components.stack.gaap')).toBe(true); + }); + + it('still reports unresolved references when additional sub-tokens are configured', () => { + const state = buildState({ + components: { stack: { gap: '{spacing.missing}' } }, + }); + const findings = brokenRef(state, { + additionalComponentSubTokens: ['gap'], + }); + + expect(findings.some(d => d.message.includes('does not resolve'))).toBe(true); + expect(findings.some(d => d.message.includes('not a recognized'))).toBe(false); + }); + it('has a valid rule descriptor', () => { expect(brokenRefRule.name).toBe('broken-ref'); expect(brokenRefRule.severity).toBe('error'); diff --git a/packages/cli/src/linter/linter/rules/broken-ref.ts b/packages/cli/src/linter/linter/rules/broken-ref.ts index 7aadb73..15b5754 100644 --- a/packages/cli/src/linter/linter/rules/broken-ref.ts +++ b/packages/cli/src/linter/linter/rules/broken-ref.ts @@ -16,11 +16,23 @@ import type { DesignSystemState } from '../../model/spec.js'; import { VALID_COMPONENT_SUB_TOKENS } from '../../model/spec.js'; import type { RuleDescriptor, RuleFinding } from './types.js'; +export interface BrokenRefOptions { + /** Additional project-owned component sub-token names to recognize. */ + additionalComponentSubTokens?: readonly string[]; +} + /** * Broken/circular references and unknown component sub-tokens. */ -export function brokenRef(state: DesignSystemState): RuleFinding[] { +export function brokenRef( + state: DesignSystemState, + options: BrokenRefOptions = {}, +): RuleFinding[] { const findings: RuleFinding[] = []; + const validComponentSubTokens = new Set([ + ...VALID_COMPONENT_SUB_TOKENS, + ...(options.additionalComponentSubTokens ?? []), + ]); for (const [compName, comp] of state.components) { // Unresolved references for (const ref of comp.unresolvedRefs) { @@ -32,11 +44,11 @@ export function brokenRef(state: DesignSystemState): RuleFinding[] { // Unknown component sub-tokens (lower severity override) for (const [propName] of comp.properties) { - if (!(VALID_COMPONENT_SUB_TOKENS as readonly string[]).includes(propName)) { + if (!validComponentSubTokens.has(propName)) { findings.push({ severity: 'warning', path: `components.${compName}.${propName}`, - message: `'${propName}' is not a recognized component sub-token. Valid sub-tokens: ${VALID_COMPONENT_SUB_TOKENS.join(', ')}.`, + message: `'${propName}' is not a recognized component sub-token. Valid sub-tokens: ${[...validComponentSubTokens].join(', ')}.`, }); } } @@ -44,7 +56,7 @@ export function brokenRef(state: DesignSystemState): RuleFinding[] { return findings; } -export const brokenRefRule: RuleDescriptor = { +export const brokenRefRule: RuleDescriptor = { name: 'broken-ref', severity: 'error', description: 'Broken/circular references and unknown component sub-tokens.', diff --git a/packages/cli/src/linter/linter/rules/types.ts b/packages/cli/src/linter/linter/rules/types.ts index d26075b..2ed4827 100644 --- a/packages/cli/src/linter/linter/rules/types.ts +++ b/packages/cli/src/linter/linter/rules/types.ts @@ -27,9 +27,12 @@ export interface RuleFinding { /** A pure lint rule: takes immutable state, returns findings. No side effects. */ export type LintRule = (state: DesignSystemState) => Finding[]; -export interface RuleDescriptor { +/** Options passed to a lint rule by name. */ +export type RuleOptions = Record; + +export interface RuleDescriptor { name: string; severity: Severity; description: string; - run: (state: DesignSystemState) => RuleFinding[]; + run(state: DesignSystemState, options?: TOptions): RuleFinding[]; } diff --git a/packages/cli/src/linter/linter/runner.ts b/packages/cli/src/linter/linter/runner.ts index 6c2c9bc..c37bded 100644 --- a/packages/cli/src/linter/linter/runner.ts +++ b/packages/cli/src/linter/linter/runner.ts @@ -14,8 +14,8 @@ import type { DesignSystemState } from '../model/spec.js'; import type { LintResult, Finding, GradedTokenEdits, TokenEditEntry } from './spec.js'; -import type { LintRule, RuleDescriptor } from './rules/types.js'; -import { DEFAULT_RULES, DEFAULT_RULE_DESCRIPTORS } from './rules/index.js'; +import type { LintRule, RuleDescriptor, RuleOptions } from './rules/types.js'; +import { DEFAULT_RULE_DESCRIPTORS } from './rules/index.js'; /** Type guard: checks if the array contains RuleDescriptors (objects with `run`). */ function isDescriptorArray(rules: LintRule[] | RuleDescriptor[]): rules is RuleDescriptor[] { @@ -28,13 +28,15 @@ function isDescriptorArray(rules: LintRule[] | RuleDescriptor[]): rules is RuleD */ export function runLinter( state: DesignSystemState, - rules: LintRule[] | RuleDescriptor[] = DEFAULT_RULES, + rules: LintRule[] | RuleDescriptor[] = DEFAULT_RULE_DESCRIPTORS, + ruleOptions: RuleOptions = {}, ): LintResult { const findings: Finding[] = isDescriptorArray(rules) - ? rules.flatMap(desc => desc.run(state).map(f => ({ + ? rules.flatMap(desc => desc.run(state, ruleOptions[desc.name]).map(f => ({ severity: f.severity ?? desc.severity, path: f.path, message: f.message, + rule: f.rule ?? desc.name, }))) : rules.flatMap(rule => rule(state)); return { @@ -52,9 +54,10 @@ export function runLinter( */ export function preEvaluate( state: DesignSystemState, - rules: LintRule[] | RuleDescriptor[] = DEFAULT_RULES, + rules: LintRule[] | RuleDescriptor[] = DEFAULT_RULE_DESCRIPTORS, + ruleOptions: RuleOptions = {}, ): GradedTokenEdits { - const { findings } = runLinter(state, rules); + const { findings } = runLinter(state, rules, ruleOptions); const fixes: TokenEditEntry[] = []; const improvements: TokenEditEntry[] = []; const suggestions: TokenEditEntry[] = [];