Skip to content

Commit b866e93

Browse files
committed
feat(cli): add lint command
1 parent 6b24efc commit b866e93

File tree

12 files changed

+324
-6
lines changed

12 files changed

+324
-6
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ You can also set the `WASI_SDK_PATH` environment variable to use a custom WASI-S
8282

8383
For more information about creating custom chips, see the [Custom Chips documentation](https://docs.wokwi.com/chips-api/getting-started).
8484

85+
## Diagram Linting
86+
87+
Validate your `diagram.json` file for errors and warnings:
88+
89+
```bash
90+
wokwi-cli lint
91+
```
92+
93+
The linter checks for common issues like unknown part types, invalid pin connections, and missing components. By default, it fetches the latest board definitions from the Wokwi registry.
94+
95+
Options:
96+
- `--ignore-warnings` - Only report errors
97+
- `--warnings-as-errors` - Exit with error code if warnings are found (useful for CI)
98+
- `--offline` - Skip downloading latest board definitions
99+
85100
## MCP Server
86101

87102
The MCP server is an experimental feature that allows you to use the Wokwi CLI as a MCP server. You can use it to integrate the Wokwi CLI with AI agents.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@iarna/toml": "2.2.5",
5454
"@modelcontextprotocol/sdk": "^1.0.0",
5555
"@wokwi/client": "workspace:*",
56+
"@wokwi/diagram-lint": "workspace:*",
5657
"commander": "^12.1.0",
5758
"chalk": "^5.3.0",
5859
"chalk-template": "^1.1.0",

packages/cli/src/cli.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { Command } from 'commander';
2-
import { chipCommand, initCommand, mcpCommand, simulateCommand } from './commands/index.js';
2+
import {
3+
chipCommand,
4+
initCommand,
5+
lintCommand,
6+
mcpCommand,
7+
simulateCommand,
8+
} from './commands/index.js';
39
import { readVersion } from './readVersion.js';
410
import { handleUnknownCommand } from './utils/didYouMean.js';
511

@@ -36,6 +42,7 @@ export function createCLI(): Command {
3642

3743
// Register other commands
3844
initCommand(program);
45+
lintCommand(program);
3946
chipCommand(program);
4047
mcpCommand(program);
4148

packages/cli/src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { chipCommand } from './chip/index.js';
22
export { initCommand } from './init.js';
3+
export { lintCommand } from './lint.js';
34
export { mcpCommand } from './mcp.js';
45
export { simulateCommand } from './simulate.js';

packages/cli/src/commands/lint.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { DiagramLinter } from '@wokwi/diagram-lint';
2+
import chalkTemplate from 'chalk-template';
3+
import type { Command } from 'commander';
4+
import { existsSync, readFileSync } from 'fs';
5+
import path from 'path';
6+
import {
7+
displayLintResults,
8+
fetchRemoteBoards,
9+
formatLintSummary,
10+
hasLintErrors,
11+
} from '../lint/index.js';
12+
13+
interface LintOptions {
14+
ignoreWarnings?: boolean;
15+
warningsAsErrors?: boolean;
16+
offline?: boolean;
17+
}
18+
19+
export function lintCommand(program: Command): void {
20+
program
21+
.command('lint')
22+
.description('Lint a Wokwi diagram file')
23+
.argument('[path]', 'Path to diagram.json or project directory', '.')
24+
.option('--ignore-warnings', 'Do not report warnings')
25+
.option('--warnings-as-errors', 'Treat warnings as errors (exit code 1)')
26+
.option('--offline', 'Skip downloading latest board definitions')
27+
.action(async (projectPath: string, options: LintOptions) => {
28+
await runLint(projectPath, options);
29+
});
30+
}
31+
32+
async function runLint(projectPath: string, options: LintOptions) {
33+
const { ignoreWarnings, warningsAsErrors, offline } = options;
34+
const shouldFetch = !offline;
35+
36+
// Resolve diagram path
37+
let diagramPath = path.resolve(projectPath);
38+
if (existsSync(diagramPath) && !diagramPath.endsWith('.json')) {
39+
// If it's a directory, look for diagram.json inside
40+
const diagramFile = path.join(diagramPath, 'diagram.json');
41+
if (existsSync(diagramFile)) {
42+
diagramPath = diagramFile;
43+
} else {
44+
console.error(chalkTemplate`{red Error:} diagram.json not found in {yellow ${projectPath}}`);
45+
process.exit(1);
46+
}
47+
}
48+
49+
if (!existsSync(diagramPath)) {
50+
console.error(chalkTemplate`{red Error:} File not found: {yellow ${diagramPath}}`);
51+
process.exit(1);
52+
}
53+
54+
// Create linter
55+
const linter = new DiagramLinter();
56+
57+
// Try to fetch remote boards (only for lint command)
58+
if (shouldFetch) {
59+
const remoteBoards = await fetchRemoteBoards();
60+
if (remoteBoards) {
61+
linter.getRegistry().loadBoardsBundle(remoteBoards);
62+
}
63+
}
64+
65+
// Read and lint the diagram
66+
let diagramContent: string;
67+
try {
68+
diagramContent = readFileSync(diagramPath, 'utf-8');
69+
} catch {
70+
console.error(chalkTemplate`{red Error:} Could not read file: {yellow ${diagramPath}}`);
71+
process.exit(1);
72+
}
73+
74+
const result = linter.lintJSON(diagramContent);
75+
76+
// Filter issues if ignoring warnings
77+
const filteredIssues = ignoreWarnings
78+
? result.issues.filter((i) => i.severity === 'error')
79+
: result.issues;
80+
81+
if (filteredIssues.length === 0) {
82+
console.log(chalkTemplate`{green \u2713} No issues found`);
83+
process.exit(0);
84+
}
85+
86+
// Display results
87+
displayLintResults(result, { quiet: ignoreWarnings });
88+
89+
// Summary
90+
console.log('');
91+
console.log(formatLintSummary(result));
92+
93+
// Exit code
94+
if (hasLintErrors(result, warningsAsErrors)) {
95+
process.exit(1);
96+
}
97+
process.exit(0);
98+
}

packages/cli/src/commands/simulate.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type ChipsLogPayload,
55
type SerialMonitorDataPayload,
66
} from '@wokwi/client';
7+
import { DiagramLinter } from '@wokwi/diagram-lint';
78
import chalkTemplate from 'chalk-template';
89
import type { Command } from 'commander';
910
import { createWriteStream, existsSync, readFileSync, writeFileSync } from 'fs';
@@ -15,6 +16,7 @@ import { TestScenario } from '../TestScenario.js';
1516
import { parseConfig } from '../config.js';
1617
import { DEFAULT_SERVER } from '../constants.js';
1718
import { idfProjectConfig } from '../esp/idfProjectConfig.js';
19+
import { displayLintResults } from '../lint/index.js';
1820
import { loadChips } from '../loadChips.js';
1921
import { readVersion } from '../readVersion.js';
2022
import { DelayCommand } from '../scenario/DelayCommand.js';
@@ -195,6 +197,17 @@ async function runSimulation(projectPath: string, options: SimulateOptions, comm
195197

196198
const diagram = readFileSync(diagramFilePath, 'utf8');
197199

200+
// Lint the diagram before simulation
201+
const linter = new DiagramLinter();
202+
const lintResult = linter.lintJSON(diagram);
203+
204+
if (lintResult.stats.errors > 0 || lintResult.stats.warnings > 0) {
205+
if (!quiet) {
206+
console.error(chalkTemplate`{cyan Diagram issues} in {yellow ${diagramFilePath}}:`);
207+
}
208+
displayLintResults(lintResult, { quiet });
209+
}
210+
198211
const chips = loadChips(config?.chip ?? [], rootDir);
199212

200213
const resolvedScenarioFile = scenarioFile ? path.resolve(rootDir, scenarioFile) : null;

packages/cli/src/lint/index.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Shared lint utilities for CLI commands
3+
*/
4+
5+
import {
6+
REMOTE_BOARDS_URL,
7+
type BoardBundle,
8+
type LintIssue,
9+
type LintResult,
10+
} from '@wokwi/diagram-lint';
11+
import chalkTemplate from 'chalk-template';
12+
13+
export interface LintDisplayOptions {
14+
/** Only show errors, hide warnings and info */
15+
quiet?: boolean;
16+
}
17+
18+
/**
19+
* Format a single lint issue for display
20+
*/
21+
export function formatLintIssue(issue: LintIssue): string {
22+
const icon =
23+
issue.severity === 'error' ? '\u2717' : issue.severity === 'warning' ? '\u26A0' : '\u2139';
24+
const color =
25+
issue.severity === 'error' ? 'red' : issue.severity === 'warning' ? 'yellow' : 'gray';
26+
const location = issue.partId ? ` (${issue.partId})` : '';
27+
return chalkTemplate`{${color} ${icon} [${issue.rule}]} ${issue.message}${location}`;
28+
}
29+
30+
/**
31+
* Display lint results to console
32+
*/
33+
export function displayLintResults(result: LintResult, options: LintDisplayOptions = {}): void {
34+
const { quiet = false } = options;
35+
36+
// If quiet and no errors, don't show anything
37+
if (quiet && result.stats.errors === 0) {
38+
return;
39+
}
40+
41+
for (const issue of result.issues) {
42+
// In quiet mode, only show errors
43+
if (quiet && issue.severity !== 'error') {
44+
continue;
45+
}
46+
console.error(formatLintIssue(issue));
47+
}
48+
}
49+
50+
/**
51+
* Check if lint result has blocking errors
52+
*
53+
* @param result - The lint result
54+
* @param warningsAsErrors - If true, treat warnings as errors
55+
* @returns true if there are blocking errors (or warnings if warningsAsErrors is true)
56+
*/
57+
export function hasLintErrors(result: LintResult, warningsAsErrors?: boolean): boolean {
58+
if (result.stats.errors > 0) {
59+
return true;
60+
}
61+
if (warningsAsErrors && result.stats.warnings > 0) {
62+
return true;
63+
}
64+
return false;
65+
}
66+
67+
/**
68+
* Format lint summary
69+
*/
70+
export function formatLintSummary(result: LintResult): string {
71+
const parts: string[] = [];
72+
73+
if (result.stats.errors > 0) {
74+
parts.push(chalkTemplate`{red ${result.stats.errors} error(s)}`);
75+
}
76+
if (result.stats.warnings > 0) {
77+
parts.push(chalkTemplate`{yellow ${result.stats.warnings} warning(s)}`);
78+
}
79+
if (result.stats.infos > 0) {
80+
parts.push(chalkTemplate`{gray ${result.stats.infos} info}`);
81+
}
82+
83+
if (parts.length === 0) {
84+
return chalkTemplate`{green \u2713} No issues found`;
85+
}
86+
87+
return `Found ${parts.join(', ')}`;
88+
}
89+
90+
export interface FetchBoardsOptions {
91+
/** Timeout in milliseconds (default: 5000) */
92+
timeout?: number;
93+
}
94+
95+
/**
96+
* Fetch board definitions from the remote registry
97+
*
98+
* @returns Board bundle, or null if fetch fails
99+
*/
100+
export async function fetchRemoteBoards(options?: FetchBoardsOptions): Promise<BoardBundle | null> {
101+
const timeout = options?.timeout ?? 5000;
102+
103+
try {
104+
const response = await fetch(REMOTE_BOARDS_URL, {
105+
signal: AbortSignal.timeout(timeout),
106+
});
107+
108+
if (!response.ok) {
109+
return null;
110+
}
111+
112+
return (await response.json()) as BoardBundle;
113+
} catch {
114+
// Network error, timeout, or JSON parse error
115+
return null;
116+
}
117+
}

packages/cli/src/mcp/MCPServer.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
ListToolsRequestSchema,
77
ReadResourceRequestSchema,
88
} from '@modelcontextprotocol/sdk/types.js';
9+
import { DiagramLinter, type LintResult } from '@wokwi/diagram-lint';
10+
import { existsSync, readFileSync } from 'fs';
11+
import path from 'path';
912
import { readVersion } from '../readVersion.js';
1013
import { SimulationManager } from './SimulationManager.js';
1114
import { WokwiMCPResources } from './WokwiMCPResources.js';
@@ -22,6 +25,7 @@ export class WokwiMCPServer {
2225
private readonly simulationManager: SimulationManager;
2326
private readonly tools: WokwiMCPTools;
2427
private readonly resources: WokwiMCPResources;
28+
private lintWarnings: LintResult | null = null;
2529

2630
constructor(private readonly options: MCPServerOptions) {
2731
const { version } = readVersion();
@@ -40,7 +44,9 @@ export class WokwiMCPServer {
4044
);
4145

4246
this.simulationManager = new SimulationManager(options.rootDir, options.token, options.quiet);
43-
this.tools = new WokwiMCPTools(this.simulationManager);
47+
this.tools = new WokwiMCPTools(this.simulationManager, {
48+
getLintWarnings: () => this.getLintWarnings(),
49+
});
4450
this.resources = new WokwiMCPResources(options.rootDir);
4551

4652
this.setupHandlers();
@@ -65,6 +71,27 @@ export class WokwiMCPServer {
6571
}
6672

6773
async start() {
74+
// Validate diagram before starting
75+
const diagramPath = path.join(this.options.rootDir, 'diagram.json');
76+
if (existsSync(diagramPath)) {
77+
const linter = new DiagramLinter();
78+
const diagram = readFileSync(diagramPath, 'utf8');
79+
const result = linter.lintJSON(diagram);
80+
81+
if (result.stats.errors > 0) {
82+
const errorMessages = result.issues
83+
.filter((i) => i.severity === 'error')
84+
.map((i) => `[${i.rule}] ${i.message}`)
85+
.join('\n');
86+
throw new Error(`Diagram lint errors:\n${errorMessages}`);
87+
}
88+
89+
// Store warnings to include in start_simulation response
90+
if (result.stats.warnings > 0) {
91+
this.lintWarnings = result;
92+
}
93+
}
94+
6895
const transport = new StdioServerTransport();
6996
await this.server.connect(transport);
7097

@@ -73,6 +100,13 @@ export class WokwiMCPServer {
73100
}
74101
}
75102

103+
/**
104+
* Get lint warnings from startup validation
105+
*/
106+
getLintWarnings(): LintResult | null {
107+
return this.lintWarnings;
108+
}
109+
76110
async stop() {
77111
await this.simulationManager.cleanup();
78112
await this.server.close();

0 commit comments

Comments
 (0)