Engineering Journal
Schema Editor
Schema Editor

Your visual editor needs a linter: rule engines over connectivity graphs

2026-07-17

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:

The nets are the part most editors skip, and they are where the interesting rules live. We compute them with union-find over canonical endpoints: union the two endpoints of every wire, and connectivity through any chain falls out for free. Without nets, a "power shorted to ground" check would need a graph traversal per query; with nets it is a set-membership test.

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.

Read this post in the full Engineering Journal โ†’