How to Implement Custom Linting Rules in the DESIGN.md CLI
You can implement custom linting rules in the DESIGN.md CLI by creating a RuleDescriptor with a run method that returns Finding objects, then passing it to the lint() function via the optional rules array in LintOptions.
The DESIGN.md CLI from google-labs-code provides a pluggable linting architecture that allows you to enforce project-specific design system conventions without modifying the core source code. The public API exposes the lint function and DEFAULT_RULES from packages/cli/src/linter/index.js, enabling you to compose custom rule sets alongside the built-in validators.
Understanding the Linting Architecture
The DESIGN.md CLI implements a modular rule system centered on two core abstractions defined in packages/cli/src/linter/linter/rules/types.ts.
RuleDescriptor defines the shape of a lint rule. It contains a run function that receives the fully-resolved DesignSystemState and returns an array of Finding objects, plus an optional default severity level.
LintRule represents the executable form of a descriptor. The CLI converts RuleDescriptor objects into LintRule instances using the toLintRule helper in packages/cli/src/linter/linter/rules/index.ts.
The linting pipeline is orchestrated by the lint function in packages/cli/src/linter/lint.ts (lines 25-28). This function accepts an options object containing an optional rules field—a LintRule[] array that overrides or extends the default rule set. The actual execution happens in runLinter() (located in packages/cli/src/linter/linter/runner.ts), which iterates over the provided rule list and aggregates findings into a comprehensive LintReport.
The built-in command at packages/cli/src/commands/lint.ts does not expose a CLI flag for custom rules, requiring you to invoke the linter programmatically or build a custom command wrapper.
Creating a Custom Rule Descriptor
To implement a custom linting rule, create a module that exports a RuleDescriptor implementing the run(state) method.
The following example defines a rule that prohibits tokens named "foo":
// File: packages/cli/src/custom-rules/no-foo-token.ts
import type { RuleDescriptor } from '../linter/linter/rules/types.js';
import type { DesignSystemState } from '../linter/model/spec.js';
import type { Finding } from '../linter/linter/spec.js';
/**
* Disallow any token named "foo".
*/
export const noFooTokenRule: RuleDescriptor = {
severity: 'error',
run(state: DesignSystemState): Finding[] {
const findings: Finding[] = [];
for (const [name, token] of Object.entries(state.tokens ?? {})) {
if (name === 'foo') {
findings.push({
message: 'Token "foo" is not allowed – use a more descriptive name.',
path: ['tokens', name],
});
}
}
return findings;
},
};
Key implementation details:
- The
runmethod receives aDesignSystemStateobject containing the parsed design system model, including thetokensmap. - Findings must include a
messagestring and may include apatharray indicating the location of the violation. - If you omit
severityin individual findings, the descriptor's top-levelseverityvalue applies automatically when the CLI converts the descriptor to aLintRule.
Running the Linter with Custom Rules
Since the default CLI command does not support custom rule paths, create a Node script that imports the lint API and supplies your rule descriptors.
// File: scripts/run-custom-lint.ts
import { readFile } from 'node:fs/promises';
import { lint, DEFAULT_RULES } from '../packages/cli/src/linter/index.js';
import { noFooTokenRule } from '../packages/cli/src/custom-rules/no-foo-token.js';
async function main() {
const designMd = await readFile('examples/totality-festival/DESIGN.md', 'utf-8');
const allRules = [...DEFAULT_RULES, noFooTokenRule];
const report = lint(designMd, { rules: allRules });
console.log('Lint summary:', report.summary);
console.log('Findings:');
for (const f of report.findings) {
console.log(`- [${f.severity}] ${f.message} (path: ${f.path?.join(' → ')})`);
}
}
main().catch(err => {
console.error('Lint failed:', err);
process.exit(1);
});
This script:
- Loads the DESIGN.md content as a string.
- Merges
DEFAULT_RULESwith your custom rule descriptor. - Passes the combined array to
lint()via theoptions.rulesparameter. - Processes the returned
LintReport, which containsfindings, asummaryof severities, the resolved design-system model, and Tailwind output.
To replace the default rules entirely rather than extending them, pass only your custom descriptors: lint(content, { rules: [myRule] }).
Exposing Custom Rules via CLI Flags (Optional)
If you prefer command-line invocation over a Node script, extend the existing CLI command by copying the structure from packages/cli/src/commands/lint.ts and adding a --custom-rules flag:
// Example fragment added to command definition
customRules: {
type: 'string',
description: 'Path to a module exporting a RuleDescriptor or an array thereof',
},
// Inside the run handler:
const custom = await import(path.resolve(args.customRules));
const extra = Array.isArray(custom) ? custom : [custom];
const report = lint(content, { rules: [...DEFAULT_RULES, ...extra] });
This approach dynamically imports the user-provided module, extracts the exported descriptor(s), and merges them with DEFAULT_RULES before executing the lint pipeline.
Summary
- Custom rules in the DESIGN.md CLI are implemented as
RuleDescriptorobjects with arun(state)method that returnsFindingarrays. - The
lint()function inpackages/cli/src/linter/lint.tsaccepts arulesoption that overrides or extends the default rule set. DEFAULT_RULESis exported frompackages/cli/src/linter/linter/rules/index.tsand can be spread into custom rule arrays.- The default CLI command does not expose custom rules, so you must invoke the API programmatically or build a custom command wrapper.
runLinter()inpackages/cli/src/linter/linter/runner.tsexecutes the rules and aggregates results into aLintReport.
Frequently Asked Questions
How do I override the default severity for a custom rule?
Set the severity property at the top level of your RuleDescriptor object. When the CLI converts your descriptor to a LintRule using the toLintRule helper in packages/cli/src/linter/linter/rules/index.ts, it applies this default severity to any findings that do not explicitly specify their own severity level.
Can I disable specific default rules while keeping others?
Yes. Import DEFAULT_RULES from packages/cli/src/linter/linter/rules/index.ts, filter the array to remove unwanted rules, and pass the filtered array to lint() via the options.rules parameter. The lint function uses your provided array exactly as given, without merging back to the default set.
What data is available in the DesignSystemState parameter?
The DesignSystemState object (defined in packages/cli/src/linter/model/spec.ts) contains the fully-resolved design system model, including the tokens map and other parsed DESIGN.md structures. You can inspect token names, values, types, and cross-references to enforce project-specific constraints.
How do I format the lint output for CI pipelines?
The lint() function returns a LintReport object containing a findings array and a summary object. Iterate over report.findings to generate JSON, JUnit XML, or custom formatted output. Each finding includes message, severity, and optional path properties for precise error location reporting.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →