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 run method receives a DesignSystemState object containing the parsed design system model, including the tokens map.
  • Findings must include a message string and may include a path array indicating the location of the violation.
  • If you omit severity in individual findings, the descriptor's top-level severity value applies automatically when the CLI converts the descriptor to a LintRule.

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:

  1. Loads the DESIGN.md content as a string.
  2. Merges DEFAULT_RULES with your custom rule descriptor.
  3. Passes the combined array to lint() via the options.rules parameter.
  4. Processes the returned LintReport, which contains findings, a summary of 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 RuleDescriptor objects with a run(state) method that returns Finding arrays.
  • The lint() function in packages/cli/src/linter/lint.ts accepts a rules option that overrides or extends the default rule set.
  • DEFAULT_RULES is exported from packages/cli/src/linter/linter/rules/index.ts and 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() in packages/cli/src/linter/linter/runner.ts executes the rules and aggregates results into a LintReport.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →