Skip to content

Commit 2d78949

Browse files
committed
feat(diagram-lint): package for validating diagram.json files
1 parent b9f5e30 commit 2d78949

File tree

183 files changed

+5703
-1
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

183 files changed

+5703
-1
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"vitest": "^3.0.5"
4545
},
4646
"lint-staged": {
47-
"*.{js,ts}": [
47+
"*.{js,ts,mjs}": [
4848
"prettier --write",
4949
"eslint --fix"
5050
]

packages/diagram-lint/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Generated files
2+
src/registry/parts.json

packages/diagram-lint/README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# @wokwi/diagram-lint
2+
3+
Linter for Wokwi `diagram.json` files. Catches common issues like invalid pins, missing components, and duplicate IDs.
4+
5+
## Installation
6+
7+
```bash
8+
npm install @wokwi/diagram-lint
9+
```
10+
11+
## Usage
12+
13+
```typescript
14+
import { DiagramLinter } from '@wokwi/diagram-lint';
15+
16+
const linter = new DiagramLinter();
17+
const result = linter.lint(diagram);
18+
19+
if (!result.valid) {
20+
for (const issue of result.issues) {
21+
console.error(`[${issue.severity}] ${issue.rule}: ${issue.message}`);
22+
}
23+
}
24+
```
25+
26+
## Rules
27+
28+
| Rule | Severity | Description |
29+
| ------------------- | -------- | ------------------------------------------ |
30+
| `duplicate-id` | error | Parts with duplicate IDs |
31+
| `invalid-pin` | error | Connections using non-existent pins |
32+
| `missing-component` | error | Connections referencing non-existent parts |
33+
| `unknown-part-type` | error | Unknown part types |
34+
| `wrong-coord-names` | error | Using `x`/`y` instead of `left`/`top` |
35+
| `invalid-attribute` | warning | Unknown or invalid attributes |
36+
| `misplaced-coords` | warning | `top`/`left` inside attrs |
37+
| `redundant-parts` | warning | Parts with no connections |
38+
| `unsupported-part` | info | Parts not in official documentation |
39+
40+
## Configuration
41+
42+
```typescript
43+
const linter = new DiagramLinter({
44+
rules: {
45+
'redundant-parts': false, // disable rule
46+
'invalid-attribute': { severity: 'error' }, // change severity
47+
},
48+
});
49+
```
50+
51+
## Loading Latest Board Definitions
52+
53+
The linter includes built-in board definitions, but you can load the latest definitions at runtime from the [wokwi-boards](https://github.com/wokwi/wokwi-boards) bundle:
54+
55+
```typescript
56+
import { DiagramLinter } from '@wokwi/diagram-lint';
57+
58+
const linter = new DiagramLinter();
59+
60+
// Fetch and load the latest board definitions
61+
const bundle = await fetch('https://wokwi.github.io/wokwi-boards/boards.json').then((r) =>
62+
r.json(),
63+
);
64+
const count = linter.getRegistry().loadBoardsBundle(bundle);
65+
console.log(`Loaded ${count} boards`);
66+
67+
// Now lint with the latest board pin definitions
68+
const result = linter.lint(diagram);
69+
```
70+
71+
## Adding Parts
72+
73+
Part definitions are in `src/registry/parts/`. To add or update a part:
74+
75+
1. Edit the JSON file in `src/registry/parts/wokwi-<part-name>.json`
76+
2. Run `npm run build:parts` to regenerate `parts.json`
77+
78+
## License
79+
80+
[The MIT License (MIT)](LICENSE)

packages/diagram-lint/package.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "@wokwi/diagram-lint",
3+
"version": "0.1.0",
4+
"description": "Linter for Wokwi diagram.json files",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"type": "module",
8+
"files": [
9+
"dist",
10+
"README.md",
11+
"LICENSE"
12+
],
13+
"scripts": {
14+
"sync:boards": "node scripts/sync-boards.mjs",
15+
"build:parts": "node scripts/build-parts.mjs",
16+
"prebuild": "npm run build:parts",
17+
"build": "tsc",
18+
"clean": "rimraf dist",
19+
"lint": "eslint src/**/*.ts",
20+
"lint:fix": "eslint src/**/*.ts --fix",
21+
"test": "vitest --run",
22+
"test:watch": "vitest --watch"
23+
},
24+
"keywords": [
25+
"wokwi",
26+
"diagram",
27+
"linter",
28+
"validation",
29+
"electronics",
30+
"circuit"
31+
],
32+
"author": "Uri Shaked",
33+
"license": "ISC",
34+
"repository": {
35+
"type": "git",
36+
"url": "https://github.com/wokwi/wokwi-cli"
37+
},
38+
"devDependencies": {
39+
"rimraf": "^5.0.0",
40+
"typescript": "^5.2.2",
41+
"vitest": "^3.0.5"
42+
}
43+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env node
2+
// Combines individual part JSON files into src/registry/parts.json
3+
// Run: node scripts/build-parts.mjs
4+
5+
import { readdirSync, readFileSync, writeFileSync } from 'fs';
6+
import { dirname, join } from 'path';
7+
import { fileURLToPath } from 'url';
8+
9+
const __dirname = dirname(fileURLToPath(import.meta.url));
10+
const partsDir = join(__dirname, '../src/registry/parts');
11+
const outFile = join(__dirname, '../src/registry/parts.json');
12+
13+
const files = readdirSync(partsDir)
14+
.filter((f) => f.endsWith('.json'))
15+
.sort();
16+
const parts = files.map((file) => JSON.parse(readFileSync(join(partsDir, file), 'utf-8')));
17+
18+
writeFileSync(outFile, JSON.stringify(parts, null, 2) + '\n');
19+
console.log(`Built parts.json (${parts.length} parts)`);
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env node
2+
// Syncs board definitions from wokwi-boards bundle.json
3+
// Run: node scripts/sync-boards.mjs
4+
5+
import { existsSync, readFileSync, writeFileSync } from 'fs';
6+
import { dirname, join } from 'path';
7+
import { fileURLToPath } from 'url';
8+
9+
const __dirname = dirname(fileURLToPath(import.meta.url));
10+
const partsDir = join(__dirname, '../src/registry/parts');
11+
const BUNDLE_URL = 'https://wokwi.github.io/wokwi-boards/boards.json';
12+
13+
async function fetchBundle() {
14+
const response = await fetch(BUNDLE_URL);
15+
if (!response.ok) {
16+
throw new Error(`Failed to fetch bundle: ${response.status} ${response.statusText}`);
17+
}
18+
return response.json();
19+
}
20+
21+
function extractPins(boardDef) {
22+
const pins = Object.keys(boardDef.def.pins || {});
23+
// Sort pins: numeric first (sorted numerically), then alphabetic
24+
return pins.sort((a, b) => {
25+
const aNum = parseFloat(a);
26+
const bNum = parseFloat(b);
27+
const aIsNum = !isNaN(aNum);
28+
const bIsNum = !isNaN(bNum);
29+
if (aIsNum && bIsNum) return aNum - bNum;
30+
if (aIsNum) return -1;
31+
if (bIsNum) return 1;
32+
return a.localeCompare(b);
33+
});
34+
}
35+
36+
function loadExistingPart(filePath) {
37+
if (!existsSync(filePath)) return null;
38+
return JSON.parse(readFileSync(filePath, 'utf-8'));
39+
}
40+
41+
async function main() {
42+
console.log('Fetching wokwi-boards bundle...');
43+
const bundle = await fetchBundle();
44+
const boardIds = Object.keys(bundle);
45+
console.log(`Found ${boardIds.length} boards in bundle`);
46+
47+
let created = 0;
48+
let updated = 0;
49+
let unchanged = 0;
50+
51+
for (const boardId of boardIds) {
52+
const boardDef = bundle[boardId];
53+
const partType = `board-${boardId}`;
54+
const filePath = join(partsDir, `${partType}.json`);
55+
const pins = extractPins(boardDef);
56+
57+
const existing = loadExistingPart(filePath);
58+
59+
const partDef = {
60+
type: partType,
61+
pins,
62+
documented: existing?.documented ?? false,
63+
isBoard: true,
64+
category: 'boards',
65+
};
66+
67+
if (existing) {
68+
// Check if pins changed
69+
const pinsChanged = JSON.stringify(existing.pins) !== JSON.stringify(pins);
70+
if (pinsChanged) {
71+
writeFileSync(filePath, JSON.stringify(partDef, null, 2) + '\n');
72+
console.log(` Updated: ${partType} (${pins.length} pins)`);
73+
updated++;
74+
} else {
75+
unchanged++;
76+
}
77+
} else {
78+
writeFileSync(filePath, JSON.stringify(partDef, null, 2) + '\n');
79+
console.log(` Created: ${partType} (${pins.length} pins)`);
80+
created++;
81+
}
82+
}
83+
84+
console.log(`\nSummary: ${created} created, ${updated} updated, ${unchanged} unchanged`);
85+
}
86+
87+
main().catch((err) => {
88+
console.error('Error:', err.message);
89+
process.exit(1);
90+
});

packages/diagram-lint/src/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Main exports
2+
export { DiagramLinter } from './linter.js';
3+
4+
// Types
5+
export type {
6+
AttributeDefinition,
7+
BoardBundle,
8+
BoardBundleEntry,
9+
Connection,
10+
Diagram,
11+
LintIssue,
12+
LintResult,
13+
LintSeverity,
14+
LinterOptions,
15+
Part,
16+
PartDefinition,
17+
RuleConfig,
18+
SerialMonitorConfig,
19+
} from './types.js';
20+
21+
// Registry
22+
export { partDefinitions } from './registry/part-definitions.js';
23+
export { PartRegistry } from './registry/part-registry.js';
24+
25+
// Rules
26+
export {
27+
allRules,
28+
duplicateIdRule,
29+
invalidAttributeRule,
30+
invalidPinRule,
31+
misplacedCoordsRule,
32+
missingComponentRule,
33+
redundantPartsRule,
34+
unknownPartTypeRule,
35+
unsupportedPartRule,
36+
wrongCoordNamesRule,
37+
} from './rules/index.js';
38+
export { createIssue } from './rules/rule.js';
39+
export type { LintContext, LintRule } from './rules/rule.js';

0 commit comments

Comments
 (0)