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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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).
Rewards Program](https://bughunters.google.com/open-source-security).
26 changes: 26 additions & 0 deletions packages/cli/src/linter/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
5 changes: 3 additions & 2 deletions packages/cli/src/linter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/linter/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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];
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/linter/linter/rules/broken-ref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
20 changes: 16 additions & 4 deletions packages/cli/src/linter/linter/rules/broken-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -32,19 +44,19 @@ 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(', ')}.`,
});
}
}
}
return findings;
}

export const brokenRefRule: RuleDescriptor = {
export const brokenRefRule: RuleDescriptor<BrokenRefOptions> = {
name: 'broken-ref',
severity: 'error',
description: 'Broken/circular references and unknown component sub-tokens.',
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/linter/linter/rules/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

export interface RuleDescriptor<TOptions = unknown> {
name: string;
severity: Severity;
description: string;
run: (state: DesignSystemState) => RuleFinding[];
run(state: DesignSystemState, options?: TOptions): RuleFinding[];
}
15 changes: 9 additions & 6 deletions packages/cli/src/linter/linter/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand All @@ -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 {
Expand All @@ -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[] = [];
Expand Down