Your visual editor needs a linter: rule engines over connectivity graphs
TLDR
Code editors lint. Visual editors mostly don't, even when the drawing has semantics a machine could check: unconnected pins, duplicate names, shorted nets, orphaned nodes. This post walks through adding a rule engine to a diagram editor: what the context object should contain, how rules stay independent and crash-isolated, and why the findings panel matters more than the rules.
Who this is for
Anyone building an editor where the artifact has meaning beyond its pixels: circuit tools, ER diagram editors, workflow builders, infrastructure canvases, node-graph tools. If your users can draw something that is syntactically fine but semantically broken, this pattern applies.
The prerequisite: a queryable model
You cannot lint pixels. The rule engine needs a semantic model, and for connection-oriented editors that means three collections:
- Components: the typed nodes, each with a reference to its DOM element and its spec (pin count, required fields).
- Wires: the edges, with resolved endpoints.
- Nets: maximal connected sets, computed transitively. Two wires joined at a point, or through a chain of junctions, are one net.
The rule shape
Each rule is data plus one pure function:
{
id: 'unconnected-pin',
severity: 'error',
check(ctx) {
const out = [];
ctx.components.forEach(c => {
const spec = ctx.specs[c.symbol];
if (!spec?.pinCount) return;
if (c.ports.length < spec.pinCount) {
out.push({
message: ${c.symbol}: ${c.ports.length}/${spec.pinCount} pins connected,
elementIds: [c.element.id],
});
}
});
return out;
},
}
Three properties matter. Rules read, never write: ctx is a snapshot; a rule that mutates the model poisons every rule after it. Rules return element references: a finding without a pointer back to the canvas is a log line, not a tool. Rules are crash-isolated: wrap each check in try/catch so one broken rule degrades to a missing category instead of killing the whole run:
rules.forEach(rule => {
try {
rule.check(ctx).forEach(f =>
findings.push({ ...f, ruleId: rule.id, severity: rule.severity }));
} catch (_) { / one broken rule must not kill the run / }
});
Rules that need the net model
The simple rules (duplicate names, missing values) only need the component list. The valuable ones need nets:
// power-short: a net containing both a power-ish and a ground-ish node
ctx.nets.filter(n => {
const syms = [...n.compIds].map(id => symbolOf(id));
return syms.some(isPower) && syms.some(isGround);
})
// floating-wire: a net that touches zero components ctx.nets.filter(n => n.compIds.size === 0)
// dangling-wire: one wire, one component โ an end hangs loose ctx.nets.filter(n => n.wireIds.length === 1 && n.compIds.size === 1)
Each of these is two or three lines because the net computation already did the hard part. This is the general shape: invest in the model once, and every rule after it is cheap. The first version of our editor tried to do "analysis" as a single hardcoded pass (one CSS class on unconnected pins); every new check would have meant threading new logic through the pipeline. As a rule pack over a shared context, the sixth rule cost as little as the second.
The findings panel is the product
A rule engine that dumps to the console is a debugging aid. The interaction that makes it a feature: click a finding, see the offending elements light up on canvas. That closes the loop from "there is a problem" to "it is this thing, here." We reuse the same highlight classes the hover system uses, so the visual language of "these elements are related" stays consistent.
Sort by severity, show counts in the header, and always render the empty state ("no issues found") โ a linter that only appears when something is wrong trains users to fear the button.
On-demand first, live later
The instinct is to re-lint on every edit, like an IDE. Resist it until your undo/history and analysis pipeline are incremental. Our history still snapshots the document per action, and the geometry pipeline reruns wholesale; wiring ERC into every mutation would multiply that cost at exactly the moments the editor should feel fast. A toolbar button that runs against the last analysis is 90% of the value at 0% of the performance risk. Move to debounced auto-run when (and only when) the pipeline underneath is dirty-region aware.
Tradeoffs
Heuristic content is the false-positive trap. Rules keyed on typed symbols (data-symbol components with specs) are trustworthy; rules applied to shapes the classifier merely guessed about will cry wolf on imported drawings. Restrict hard rules to explicitly-typed content and let heuristic content get info-level findings at most. And keep an ignore mechanism on the roadmap: a lint system without suppressions eventually gets turned off entirely.