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
2 changes: 1 addition & 1 deletion packages/cli/src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default defineCommand({
return;
}

console.log(serializeTailwindV4(result.data.theme));
process.stdout.write(serializeTailwindV4(result.data.theme));
} else if (format === 'json-tailwind' || format === 'tailwind') {
const handler = new TailwindEmitterHandler();
const result = handler.execute(report.designSystem);
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(10);
expect(output.rules.length).toBe(13);
});
});
56 changes: 56 additions & 0 deletions packages/cli/src/linter/linter/rules/declared-omission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// 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 { declaredOmission } from './declared-omission.js';
import { buildState } from './test-helpers.js';

describe('declaredOmission', () => {
it('emits info for each omitted section (bare string form)', () => {
const state = buildState({
colors: { primary: '#ff0000' },
omitted: ['spacing', 'rounded'],
});
const findings = declaredOmission(state);
expect(findings.length).toBe(2);
expect(findings[0]!.path).toBe('omitted.spacing');
expect(findings[0]!.severity).toBe('info');
expect(findings[1]!.path).toBe('omitted.rounded');
});

it('emits info with reason when object form is used', () => {
const state = buildState({
colors: { primary: '#ff0000' },
omitted: [
{ section: 'spacing', reason: 'No spacing scale defined in source material' },
],
});
const findings = declaredOmission(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('omitted.spacing');
expect(findings[0]!.message).toContain('No spacing scale defined');
});

it('returns empty when no omitted key', () => {
const state = buildState({
colors: { primary: '#ff0000' },
});
expect(declaredOmission(state)).toEqual([]);
});

it('returns empty for empty state', () => {
const state = buildState({});
expect(declaredOmission(state)).toEqual([]);
});
});
43 changes: 43 additions & 0 deletions packages/cli/src/linter/linter/rules/declared-omission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// 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';
import { getSectionName } from './omitted-utils.js';

/**
* Declared omission — acknowledges sections that are intentionally absent.
*/
export function declaredOmission(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
if (!state.omitted) return findings;

for (const entry of state.omitted) {
const section = getSectionName(entry);
const reason = typeof entry === 'object' && entry.reason ? ` Reason: ${entry.reason}` : '';
findings.push({
path: `omitted.${section}`,
message: `'${section}' intentionally omitted — no ${section} tokens will be validated.${reason}`,
severity: 'info',
});
}
return findings;
}

export const declaredOmissionRule: RuleDescriptor = {
name: 'declared-omission',
severity: 'info',
description: 'Declared omission — acknowledges sections that are intentionally absent.',
run: declaredOmission,
};
9 changes: 9 additions & 0 deletions packages/cli/src/linter/linter/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import { sectionOrderRule } from './section-order.js';
import { missingTypographyRule } from './missing-typography.js';
import { unknownKeyRule } from './unknown-key.js';
import { tokenLikeIgnoredRule } from './token-like-ignored.js';
import { declaredOmissionRule } from './declared-omission.js';
import { redundantOmissionRule } from './redundant-omission.js';
import { unknownOmissionRule } from './unknown-omission.js';

/** The default set of lint rule descriptors, in order. */
export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
Expand All @@ -38,6 +41,9 @@ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
sectionOrderRule,
unknownKeyRule,
tokenLikeIgnoredRule,
declaredOmissionRule,
redundantOmissionRule,
unknownOmissionRule,
];

/** Converts a RuleDescriptor into a LintRule by injecting severity into findings. */
Expand All @@ -64,4 +70,7 @@ export { missingTypography } from './missing-typography.js';
export { unknownKey } from './unknown-key.js';
export { sectionOrder } from './section-order.js';
export { tokenLikeIgnored } from './token-like-ignored.js';
export { declaredOmission } from './declared-omission.js';
export { redundantOmission } from './redundant-omission.js';
export { unknownOmission } from './unknown-omission.js';
export type { LintRule } from './types.js';
21 changes: 21 additions & 0 deletions packages/cli/src/linter/linter/rules/missing-sections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,25 @@ describe('missingSections', () => {
const state = buildState({});
expect(missingSections(state)).toEqual([]);
});

it('suppresses spacing warning when spacing is declared omitted', () => {
const state = buildState({
colors: { primary: '#ff0000' },
rounded: { regular: '4px' },
// no spacing — but declared omitted
omitted: ['spacing'],
});
const findings = missingSections(state);
expect(findings.length).toBe(0);
});

it('suppresses rounded warning when rounded is declared omitted', () => {
const state = buildState({
colors: { primary: '#ff0000' },
spacing: { unit: '8px' },
omitted: ['rounded'],
});
const findings = missingSections(state);
expect(findings.length).toBe(0);
});
});
4 changes: 3 additions & 1 deletion packages/cli/src/linter/linter/rules/missing-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,21 @@

import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
import { getOmittedSections } from './omitted-utils.js';

/**
* Missing sections — notes when optional sections (spacing, rounded) are absent.
*/
export function missingSections(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
const omittedSections = getOmittedSections(state);
const sections = [
{ map: state.spacing, name: 'spacing', fallback: 'Layout spacing will fall back to agent defaults.' },
{ map: state.rounded, name: 'rounded', fallback: 'Corner rounding will fall back to agent defaults.' },
];

for (const { map, name, fallback } of sections) {
if (map.size === 0 && state.colors.size > 0) {
if (map.size === 0 && state.colors.size > 0 && !omittedSections.has(name)) {
findings.push({
path: name,
message: `No '${name}' section defined. ${fallback}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,12 @@ describe('missingTypography', () => {
const state = buildState({});
expect(missingTypography(state)).toEqual([]);
});

it('suppresses warning when typography is declared omitted', () => {
const state = buildState({
colors: { primary: '#ff0000' },
omitted: ['typography'],
});
expect(missingTypography(state)).toEqual([]);
});
});
6 changes: 5 additions & 1 deletion packages/cli/src/linter/linter/rules/missing-typography.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ import type { RuleDescriptor, RuleFinding } from './types.js';
* Missing typography — warns when colors are defined but no typography tokens exist.
* Without typography tokens, agents will fall back to their own font choices,
* reducing the author's control over the design system's typographic identity.
* Suppressed when typography is declared in the `omitted` key.
*/
export function missingTypography(state: DesignSystemState): RuleFinding[] {
if (state.typography.size === 0 && state.colors.size > 0) {
const typographyOmitted = state.omitted?.some(
e => (typeof e === 'string' ? e : e.section) === 'typography'
);
if (state.typography.size === 0 && state.colors.size > 0 && !typographyOmitted) {
return [{
path: 'typography',
message: "No typography tokens defined. Agents will use default font choices, reducing your control over the design system's typographic identity.",
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/linter/linter/rules/omitted-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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 { OmittedEntry } from '../../parser/spec.js';

/** Valid section names that can appear in the `omitted` key. */
export const VALID_OMITTED_SECTIONS = new Set([
'colors',
'typography',
'spacing',
'rounded',
'components',
]);

/**
* Extract the section name from an OmittedEntry.
*/
export function getSectionName(entry: OmittedEntry): string {
return typeof entry === 'string' ? entry : entry.section;
}

/**
* Build a Set of section names declared as omitted.
*/
export function getOmittedSections(state: DesignSystemState): Set<string> {
const omitted = new Set<string>();
if (state.omitted) {
for (const entry of state.omitted) {
omitted.add(getSectionName(entry));
}
}
return omitted;
}

/**
* Check if a specific section is declared as omitted.
*/
export function isOmitted(state: DesignSystemState, section: string): boolean {
if (!state.omitted) return false;
return state.omitted.some(e => getSectionName(e) === section);
}
67 changes: 67 additions & 0 deletions packages/cli/src/linter/linter/rules/redundant-omission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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 { redundantOmission } from './redundant-omission.js';
import { buildState } from './test-helpers.js';

describe('redundantOmission', () => {
it('emits warning when spacing is omitted but spacing tokens exist', () => {
const state = buildState({
colors: { primary: '#ff0000' },
spacing: { unit: '8px' },
rounded: { regular: '4px' },
omitted: ['spacing'],
});
const findings = redundantOmission(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('omitted.spacing');
expect(findings[0]!.severity).toBe('warning');
expect(findings[0]!.message).toContain('has no effect');
});

it('emits warning when typography is omitted but typography tokens exist', () => {
const state = buildState({
colors: { primary: '#ff0000' },
typography: {
'body-md': { fontFamily: 'Inter', fontSize: '16px' },
},
omitted: ['typography'],
});
const findings = redundantOmission(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('omitted.typography');
});

it('returns empty when omitted sections genuinely have no tokens', () => {
const state = buildState({
colors: { primary: '#ff0000' },
omitted: ['spacing', 'rounded'],
});
expect(redundantOmission(state)).toEqual([]);
});

it('returns empty when no omitted key', () => {
const state = buildState({
colors: { primary: '#ff0000' },
spacing: { unit: '8px' },
});
expect(redundantOmission(state)).toEqual([]);
});

it('returns empty for empty state', () => {
const state = buildState({});
expect(redundantOmission(state)).toEqual([]);
});
});
Loading