Authoring a check rule

The path from a review-checklist item to a shipped, trustworthy check rule, worked through one real rule.

A check rule asserts something about the design as it was read: “every rail carries a test point”. It does not compute the way analysis does, it does not fix anything, and it has to be checkable from the IR the readers produce today. The architecture behind the pieces lives in Rules and checks and Net solving and hierarchy. This page is the practical path, worked through one real rule, test-point-coverage.

flowchart TB
    subgraph S1["Decide"]
        direction LR
        A["is it a rule<br/>at all?"] --> B["write the sentence,<br/>then the guards"]
    end
    subgraph S2["Author"]
        direction LR
        C["spec-first"] --> D["one file,<br/>one line, one doc"] --> E["Scope, Where,<br/>Remedy"] --> F["say what you<br/>LOOKED AT"]
    end
    subgraph S3["Prove"]
        direction LR
        G["fixtures: fires,<br/>passes, guard"] --> H["verify at<br/>four levels"] --> I(["ship"])
    end
    S1 --> S2 --> S3

Is it a rule at all?

Ask what the rule reads. If the answer includes data no reader emits yet (net classes, datasheet limits before the parameters tier exists), the reader work comes first and the rule waits.

The worked example comes from a design-for-test checklist: rails and ground should be probe-able. It reads component classes, net membership, and rail-ness, all three present in the IR. So it is a rule.

Write the sentence, then the guards

A rule is one sentence plus its exceptions. The exceptions keep an engineer from muting it. Write the sentence first:

A power rail or ground net must carry a test-point component.

Then interrogate it the way a real corpus will.

The question What it settled here
What if the board has no test points at all? A demo board is not wrong, it just has no test-point convention. Fire only where the design places test points somewhere. This kind of channel gate once took a rule from 1836 findings to zero.
What is “a rail” when the format carries no direction data? A bare EDIF netlist has no rail evidence except the name, so fall back to rail facts or rail/ground name heuristics.
What about nets the read did not fully cover? Skip nets marked external.
Who decides the policy edge cases, like a feedback node named like a rail and deliberately left unprobed? The reviewer does. Use info severity and say so in the rule’s doc.
If you do read a name, is the net actually a rail? net.nominal_voltage token-scans a whole name, so a level encoded in a SIGNAL net’s name parses as a rail nominal (agni issue 194: U3_12_U7_4_3V3 yields 3.3 while classifying as neither rail nor ground). Gate on Model.IsRailNet first.

Three of the questions teach something wider than this rule.

1. Is there a tier of evidence better than the name? A rail’s voltage comes from check.NominalVoltageFromName, a naming convention. The convention is silent on a rail nobody named for its voltage. It is wrong on a name that outlived a design change. Connectivity sometimes answers outright. The pin-tracking rules bound the difference between two pins. Two pins on ONE net are one node, so that difference is exactly zero, and no name is read. Take a design whose nets carry no voltage token at all. On it, that tier settles a “must not exceed 0V” bound as satisfied and a “must be at least 1V” bound as violated. Reach for the convention as the fallback, not the first answer.

2. What does a role gate cost when the project has not configured it? It inherits all of the role’s configuration. IsRailNet reads a stamp derived from the naming lexicon, and the built-in vocabulary is start-anchored (VCC, VDD, +3V3), so a project naming rails function-first matches almost none of it. On a real 1700-net board, declaring the project’s patterns moved the rail count from 13 to 91. Gate anyway, and make the gap visible rather than assuming the config is there. The rail-not-classified tripwire makes it visible.

We measured it end to end later (August 2026, against the catalog of the day). On a real board naming rails function-first with no lexicon declared, 18 supply nets classified as rails and 91 did not, and rail-not-classified fired 45 times. Declaring the project’s conventions moved recognition to 62 and the tripwire to zero. The tripwire works, and it points at configuration as the remedy rather than more rules.

3. Is your evidence tier actually asking your question? Preferring connectivity over a name is right, and it is still possible to pick a connectivity fact that is wider than the property you mean. decoupling-present selects “a net with a non-virtual power_in pin and no capacitor” and calls that a supply rail. On the same board every one of its 14 findings was a false positive: gate drives, buck switching nodes and sense lines all carry a power-input pin, and two of them were advising a capacitor that would short a switch node to ground. Write the sentence. Then take the fact you selected on, and check that nothing the sentence would exclude can satisfy it.

If the sentence names two entities, carry both

A Finding has one Subject, and the subject is what a reader has to CHANGE. Sometimes the sentence is about a different entity. Then the finding sends the reader somewhere the message never mentioned. This section exists because of crystal-load-caps. Its message reads “crystal terminal net XOUT1 has no load capacitor”. Its subject is the crystal, correctly, because someone edits the crystal. But the sentence is about a NET. So clicking the finding highlighted a part. Both terminals sit inside that symbol, so the drawing could not say which one was at fault.

So when a message names a design entity other than its subject, carry it in Finding.Context, each entry with a Role naming the part it plays. The panel renders them as clickable chips.

// Go
Context: []check.ContextSubject{
    {Kind: check.KindNet, Subject: n.Name, NetID: n.Id, Role: "terminal"},
}

// datalog: the entity is already bound in the answer row
ContextVars: []query.ContextVar{
    {Var: "net", Kind: check.KindNet, Role: "terminal"},
}
Rule Why
Never repeat the subject as its own context the chip would navigate to where the reader already is. On a KindPin rule the subject is the ref/pin pair, so {pin} is the subject and only the net is context
Order is yours and it is significant chips render in declaration order, so declare them in the order your message names them
A role is a short lower-case noun terminal, rail, source, sense. Open vocabulary, deliberately, and roles need not be unique within a finding (“A and B both strap to address N”)
Entities only, never values thresholds, voltages and units belong in the message. A reader clicks a chip to go to that entity in the drawing

Set NetID on a net context entity where you have it, so a net sharing its name with another highlights the right instance rather than all of them.

Author spec-first

Proven vocabulary goes in the Spec AST. Anything multi-clause goes behind a registered SpecFunc that declares its own reads and primitives, so the derived reads still cover the code behind that boundary. The example uses two FFIs plus existing facts. has_test_points is the channel gate. not_a_supply recognises a floating pad or a divider tap by structure.

Over: nets
Scope: has_test_points(design)
   and not external(N)
   and (global(N) or power_driven(N) or rail_name(N) or ground_name(N))
   and not feedback_name(N) and not switching_name(N)
   and not control_name(N) and not gate_drive_name(N)
   and not not_a_supply(N)
Where: not exists T in N.connections where class(T) == test_point

Bind it with spec.Rule(check.Rule{...}). Reads and Primitives derive from the body, so they cannot drift from what the rule does. There is one init-order trap. Register a rule’s own FFI inside the rule variable’s own initializer. Package variables initialize before init funcs run, and binding validates the Call targets. Shared helpers like rail_name need no such care, since stdlib/rules/builtin imports core/check and check’s package init registers them first.

Under the twin discipline, proven vocabulary is spec-only, as here. New interpreter vocabulary ships with a Go Eval as the canonical twin until it soaks, with parity asserted between the two. A new entity set, a new fact and a new traversal are all new interpreter vocabulary.

One file, one line, one doc

Path Holds
stdlib/rules/builtin/rule_<name>.go the rule
stdlib/rules/builtin/register.go one line adding it to the rules slice
stdlib/rules/builtin/docs/<name>.md the single source of the rule’s Detail, embedded at build time

The built-in catalog is its own package, not part of the core engine. It installs itself through the same public check.RegisterBuiltins registration point an overlay uses, so core/check owns no rules of its own.

The harness fails CI without the doc. Write it as proper ### sections under the rule’s ## title, not bold run-ins. The sections are What it means, Why engineers want it, Impact, an ASCII sketch of fires-versus-fine, a Scope note recording every guard decision from the step above, the query structure, and a “For software readers” section. That last section maps the EE concepts to structural analogies, with a diagram beside it. For example, a test point is a metrics endpoint, and the rule reads as “critical paths must emit telemetry”.

Say what to do about it

A rule carries four pieces of prose. Summary is the one-liner, Impact is what goes wrong when the rule is violated, Detail is the long-form markdown, and Remedy is what to DO about it, in the imperative, as one engineer would say it to another. Remedy is the easiest to leave off. Without it, a reader can accept that the finding matters and still has to already know the fix. TestEveryRuleStatesARemedy (in tools/catalogdocs) holds the whole catalog to it.

What a remedy must get right
Generic over the RULE, not the subject name the class of change (“add a bulk capacitor where the rail enters”), never a designator. A remedy templated on the bound subject is a later tier
Where the fix needs a value the engine cannot derive, say what to size it from and stop the pull-up an I2C bus wants depends on its capacitance and clock rate, neither of which the netlist states, so i2c-pull-up names no resistance. Verdicts exist to remove a silent-authority problem, and inventing a plausible number would bring the same problem back one layer up
Where the finding is about the ANALYSIS rather than the design, say so rail-not-classified and symbol-unresolved are remedied by giving the tool more (--conventions, --symbol-path), and their remedies say explicitly that nothing is yet known to be wrong with the board

Write it in the field, never in the rule’s docs/<name>.md. tools/catalogdocs projects Remedy onto the docsite reference page as its leading ### Remedy section, so a doc that also wrote one would print the fix twice. Impact follows the opposite convention. Its field and its ### Impact doc section may both exist and say the same thing differently.

The intent and profile families generate a rule per declaration. Such a rule keys its remedy by KIND rather than writing it at the builder. The fix for a missing OV clamp is the same sentence on every rail that declares one. See docRemedies in stdlib/rules/intent/docs.go and requirementCaption in stdlib/profiles/docs.go.

Scope and Where, for a spec rule

A spec has two predicates and they are not interchangeable. Both filter. They differ in where each one sends the elements it discards.

flowchart LR
    O(["Over: nets"]) --> S{"Scope"}
    S -->|"out"| NO["no verdict at all.<br/>The rule has no opinion about you."]
    S -->|"in"| W{"Where"}
    W -->|"false"| P["PASS<br/>looked at you and cleared you"]
    W -->|"true"| F["FAIL"]
    classDef out fill:none,stroke-dasharray: 4 3;
    class NO out;

Old-style rules put both in one And. That was harmless while a spec only reported violations. Once the interpreter states a considered set, it does harm. A subject the rule was never about is then reported as fine. test-point-coverage had five conjuncts over Over: "nets", and only the last one is the rule:

has_test_points(design)                      <- scope: does this board use test points at all
not net.attr.external                        <- scope: the read did not cover this net
global or power_driven or rail or ground     <- scope: is this even a rail
not feedback_name(net)                       <- scope: a sense node must not be probed
not exists(connection with class test_point) <- THE VIOLATION

On the tutorial gateway design the scope narrows 15 nets to 4 rails. Without the split, the rule would assert that 11 signal nets carry a test point. The first clause is worse, because it is design-wide. Imagine a board that uses no test points anywhere. It would report every net as passing. The rule would have declined to run and still claimed universal success.

Splitting cannot change what fails. The old predicate is exactly Scope AND Where, so only the meaning of the non-failing subjects changes. TestScopeAndWhereProjectToTheSameFindings pins it.

Rule of thumb
A clause belongs in Scope when a pass over it would be meaningless “this capacitor is not an LED” is not a useful thing for led-polarity to say. “This LED is the right way round” is
Scope contributes to DerivedReads so moving a clause must not change what a rule declares it reads. TestScopeContributesDerivedReads holds that
A spec stating no Scope claims every element of Over is its subject a real claim, so leave it out only when it is true

A pass states the VALUE that refuted Where, not the expression that tested it. The interpreter renders the first false conjunct and reads the subject’s actual value. So it prints `claims is 1, not

= 2rather thanclaims >= 2 does not hold. The threshold survives, so a reader can see what would have made the rule fire. A false !=has no threshold to name, and it is the only such case. So a passinglabels != “"readslabels is empty. This costs an author nothing but it does make a Let` binding name READER-FACING, so name a binding for the fact it holds.

A predicate that measured nothing still cannot say anything. IsTrue{Call{...}} decides on a bare bool, so led-polarity passes with led_reversed does not hold and no value to offer. Closing that needs a SpecFunc that hands back what it saw rather than only what it concluded.

Say what you looked at, not only what failed

Eval returns one check.Verdict for each subject the rule applied to, passes included. It MAPS the design onto outcomes rather than filtering it down to failures. The findings contract is a projection of that map, and Run takes the projection through Rule.Findings. There is one body, so a rule cannot report findings that disagree with its verdicts.

Eval takes the run’s context.Context first, because a served check stops when its caller goes away. A rule walking a large set checks ctx.Err() between subjects and returns early, since Run then reports the cancellation and discards what the rule returned. A query-backed rule passes query.EvalOptions(ctx) to the evaluator, and a server’s work budget reaches the evaluator that way. A test with no caller passes context.Background(), and check.RunBackground runs a whole rule list that way.

flowchart TB
    En["enumerate<br/>the subjects the rule applies to"] --> J["judge one subject"]
    J --> V["<b>Verdict</b>, one per subject<br/>Pass · Fail · NOT_CONSIDERED<br/>Subjects is a TUPLE, in the rule's own order<br/>VerdictID = rule:(kind:ref,...)"]
    V -->|"Rule.Findings<br/>projects the failures"| F["<b>Finding</b>, only for failures<br/>Subject is SINGULAR: what a reader CHANGES<br/>everything else the sentence names goes in Context"]
The discipline Why
Enumerate, then judge two functions, one yielding the subjects the rule applies to and one deciding a single subject, with Eval as map(judge, enumerate). Every rule converted so far factored this way without a fight, and the split makes a single verdict answerable on its own
Produce the outcome and its evidence in ONE call check.CompareToBound returns both from one comparison. Suppose a rule decided the outcome and then separately assembled a witness. Skipping the second step would then fail nothing
An enumerator that drops a subject must say so a pin that will not resolve, a net with no voltage in its name, a datasheet binding no row of the kind. Those used to skip silently, and they reported the same nothing as a rule that never looked. They are now NOT_CONSIDERED verdicts carrying the step that stopped them. Distinguish from OUT OF SCOPE, where a pin that is not a supply terminal yields no event at all
StatesConsideredSet is a declaration, not an inference a failures-only rule returns Fail verdicts structurally identical to a considered set whose every subject failed, so only the author can say which. Forgetting it under-reports a converted rule and can never over-report one

Five outcomes, and the three that are not a pass

A verdict’s outcome is one of five. Three of them say why no pass was possible. Keeping those three apart lets a report say what the design lacks rather than guess.

Outcome Wire form Meaning
Pass pass the subject reached the comparison and cleared it
Fail fail the subject reached the comparison and did not clear it
Inconclusive inconclusive every input was present and the rule reached its decision, but the design cannot discriminate between the cases. A transistor in a power path may be an ideal-diode controller giving reverse protection or a plain switch giving none, and a netlist cannot tell them apart
NotConsidered not-considered the rule applied to the subject and never reached a comparison. Reason names the step that stopped it
NoLimit no-limit the subject DID reach the comparison and the datasheet row stated no bound, so nothing was checked

A consumer must not count Inconclusive as a failure. It is the outcome form of Finding.Inconclusive and has that field’s contract. So it cannot simply be Fail. Mapping it to NotConsidered would be worse. A NotConsidered verdict produces no finding, so the mapping would delete a finding that the check path reports. Today Inconclusive comes from reverse-blocking-absent, the strap-group-* rules, the pin-tracking-* rules, the io-map rules and the property-* rules.

NoLimit and NotConsidered differ in where the rule stopped. NotConsidered never reached a comparison. NoLimit reached it and found nothing to compare against. Before NoLimit existed, two cases took the same silent return out of a rule. In one, a datasheet row stated no maximum. In the other, a design sat comfortably under a stated maximum. check.CompareToBound returns NoLimit for a Bound with neither side stated, so a rule that compares through it gets the distinction without asking.

The two rules that still decline

Rule How it declines Why
dangling-endpoint check.FailuresOnly, conspicuous at the call site it reads a diagnostic list holding the offenders and nothing else. internal/netgraph’s dangling already decides per endpoint against an occupied map and discards it into a bool, so the obstacle is volume rather than the reader. The showcase fixture has 28 distinct endpoints for 20 wires, against 132 subjects across the whole catalog (agni issue 420)
cap-voltage StatesConsideredSet = false directly, no wrapper, since it is built from a spec capVoltageDetail returns "" both for a capacitor that clears its rating and for one with no datasheet seeded, so inside scope it cannot tell a pass from a gap

Grepping for FailuresOnly therefore misses one of them. Converting a rule means deleting the wrapper, writing the map, and setting StatesConsideredSet. The diagnostic rule bus-not-modeled always CAN state a set. unmodeled_buses holds every bus construct the reader saw. So the rule partitions it, and a resolved bus is a countable pass.

The general fix is usually the READER

Two rules were in that position and left it by changing what the reader records.

Rule What the reader now records Why it makes a pass evidence
symbol-unresolved (agni issue 418) the references that DID resolve, with the pin count each supplied a stale library entry resolves as successfully as the real symbol, answers with no pins, and costs the netlist as much as a missing file. The count moves when the library does
wire-no-junction (agni issue 420) the taps something JOINS, beside the ones nothing does splitWiresAt runs at every junction dot and mid-span label before the detection pass, so running the same detection once more before the split recovers the set

wire-no-junction was the most expensive silence in the catalog. Its own Impact calls a dotless T-tap the most dangerous silent connectivity slip in schematic capture. Yet a sheet with a dot on every tap reported exactly what a sheet with no tap reported.

The reader fix comes with a capability, not just a field. Only the KiCad reader examines wire geometry, so wire-no-junction declares RequiresCapability: CapJunctionTaps and reports not-applicable with a reason on xschem, gEDA and EDIF. Without that it would contribute a considered set of nothing on three formats. An empty considered set reads as a clean sheet. symbol-unresolved needed no gate, because all three symbol-resolving readers supply the diagnostic.

When a rule seems to have no fixed arity, it is usually being asked the wrong question. strap-address-collision declined because its subject was the SET of devices sharing an address. That set holds two devices on one bus and four on the next, while SubjectShape is fixed per rule. The set describes the FINDING. The rule answers a binary question, whether these two devices strap to the same number. Three devices at one address is three yes answers, so the subject is a PAIR (agni issue 391). Its message got less wrong too. The old single finding said “U12 and U13 and U14 BOTH strap to”. The old body dropped two cases with a bare continue, and those cases became NOT_CONSIDERED verdicts. A body that filters has nowhere to put a subject it declined.

A tuple in the verdict, one entity in the finding

One rule about a relation, producing three different shapes from one judgement. The verdict's subject is a tuple naming every entity in the relation, because that tuple is the verdict's identity. The finding's subject is singular and names the one entity a reader has to change. Everything else the sentence mentions travels in the finding's context, as clickable chips. The same judgement, at three grains, for three different questions. one judgement about a relation a regulator over-driving a part it feeds, across a rail Verdict.Subjects a TUPLE, in the rule's own order U1 +3V3 U7 name fewer and two answers share one id Finding.Subject SINGULAR, and only for a failure U7 the one entity a reader has to CHANGE Finding.Context U1 · source +3V3 · rail clickable chips one judgement at three grains, because identity, the edit, and the sentence are three questions

A verdict’s subject is a TUPLE, and for most rules it holds one entity. A rule whose question is a RELATION names every entity in it. copper-clearance measures a distance between two nets and belongs to neither. regulator-output-exceeds-abs-max compares a regulator against a part it feeds ACROSS a rail, and all three are needed, because one source feeding one load over two supplied rails is two different answers. Naming fewer than all of them gives several answers one id. That is wrong now that the report links every row by id. Five rules used to decline for exactly this reason and no longer do.

Rule Why
Order is yours and it is significant a tracking bound reads subject-pin minus reference-pin, so swapping the two inverts the sign of the claim
A SYMMETRIC relation canonicalises INSIDE the rule copper-clearance orders its pair by name. A framework that sorted every tuple would break the directional ones
Declare the shape Rule.SubjectShape lists the kinds, so a person can construct an id without running the check and TestSubjectShapeHolds can fail a rule whose arity moves between designs. Leave it empty for the ordinary one-subject case, which the same test enforces
Do not reach for a subject that is nearly unique and hope two seeded regulators feeding one load, and a high-side FET above breakdown on both its rails, are ordinary topologies

A Finding’s subject is still SINGULAR, and that is a different question. The verdict’s tuple is identity and has to be complete. The finding’s subject is the one entity a reader has to CHANGE, and an answer with three entities in it is not one they can act on. TestFindingSubjectComesFromTheVerdictsTuple holds the two together, and consumers wanting equal standing read Context instead.

The two may differ in GRAIN, and often should. A verdict about a supply terminal carries a finding about the whole part. A reader asks “why is this pin fine” of a pin. A reviewer reads a sentence that names what they will change. pin-exceeds-abs-max established it. Plan for the consequence. A part with three VDD pins on one rail produces three verdicts and the one finding it always produced, so the extra terminals carry no Finding.

Converting a rule that has a spec twin has one trap. check.VerdictsToFindings returns a NON-NIL empty slice, matching check.Report, and TestSpecParity compares with reflect.DeepEqual, where a nil slice is not equal to an empty one. A converted rule would otherwise diverge from its twin on every clean design while agreeing about every finding. Twinned rules have three things that must agree (the Go body, the spec twin, and the verdicts), and the trap affects about a quarter of the catalog.

Answer for one net or part

CheckDesign takes subjects, and the CLI --subject, so the viewer can ask every rule about the net or part someone selected (agni issue 897). A full run defines the answer for a subject. The answer is every finding and verdict the full run reports with that subject in the verdict’s tuple, and a component covers its own pins. check.Run enforces that by narrowing every rule’s results to the scope after the rule returns, so a new rule is correct under a scope without doing anything. It is just no faster.

A rule says more than that through Rule.Scoping.

Scoping What the rule does When to use it
ScopeFiltered (the zero value) runs over the whole design, and the run narrows the answer the rule compares every subject against the rest of the design, or its outer loop is not over its subjects
ScopeSubjects narrows its own outer loop with check.ScopedNets, ScopedComponents or ScopedPins the loop enumerates the verdict’s subject and nothing outside the loop depends on which subjects it visited
ScopeDesignWide is not run under a scope, and the service names it in skipped the verdict’s subject is a symbol file, a drawn bus or a wire end, which no net or part names

A rule built from a check.Spec declares ScopeSubjects by itself too (agni issue 1070), since the interpreter builds each element’s subject before it evaluates anything and skips an element the scope does not cover.

A rule built with query.RuleFromQuery declares ScopeSubjects by itself, and binds the scope’s names to SubjectVar (and a pin rule’s designators to PinVar) in the finding, domain and evidence goals. The exception is a rule whose TupleVars name a second net or part. A scope can name that second element, and then it reaches verdicts the subject binding would leave out. So that rule runs over the whole design, and the run filters its answer.

A relation rule narrows by its pairs. copper-clearance decides (net, net) pairs, so under a scope it measures each scoped net’s segments against whatever lies within reach of them. It reads the same per-layer index that the full sweep builds. A pair with both nets in the scope is measured once, from its earlier segment in board order. So every count and tie-break matches the full sweep’s, and TestAScopedSweepGivesTheFullSweepsVerdictsAboutItsNets holds it to that. On Jetson, the whole catalog asked about one I2C net went from 66 ms of rule time to 9 ms, and copper-clearance from 23 ms to 4.

A rule whose answer depends on a design-wide gate puts the cheap subject test first. A profile’s missing-pullup matches the scoped nets against its signals before asking whether the interface is in use. The profile’s other net rules answer nothing when no scoped net matches one of its signals. They check that before their query derives in_use, which costs about a millisecond a rule on a large board.

Only the inner loop may still see the whole design. i2c-redundant-pull-up narrows the I2C nets it visits and still walks every resistor from each, because the walk is the evidence. Suppose a rule narrowed the partner set as well, such as the other ref-des in a duplicate-ref-des comparison. It would then answer a different question.

TestScopedRunMatchesFilteredFullRun in internal/composetest holds every rule in the composed catalog to the definition. It runs on three boards, and reads two of them with their copper so the board rules are asked too. It asks each rule about a spread of the subjects its full run named. It also asks about one net and one part the full run named nothing about. It calls a ScopeSubjects rule’s Eval directly, outside the run’s narrowing, and fails one that returns verdicts outside the scope. That catches a rule that declares the scope and still walks the whole board. And it fails a rule whose verdicts name no net, part or pin, unless the rule is ScopeDesignWide. The run keeps such a verdict for every subject, as it must for the unresolved-symbol gate’s inconclusive.

A rule has one severity, so a severity axis means two rules

check.Run stamps Finding.Severity from the rule’s own metadata over whatever Eval set, because the docsite catalog and --fail-on both read the declared severity as the truth about a rule’s findings. So a fact carrying its own severity axis becomes TWO rules sharing one walker. Both instances are in the datasheet tier. pin-exceeds-abs-max and pin-out-of-recommended split one comparison by LimitKind. pin-tracking-violated and pin-tracking-advisory split one by the datasheet’s Modality, because “shall never exceed” and “should be at least 1 V higher for best operation” are different claims. A caller wants the split too. Separate rule names let a team gate CI on the requirement and merely report the recommendation.

Where a value is legal but leaves the severity unknown, send it to the louder rule and mark the finding Inconclusive rather than dropping it. param.Validate requires a pin relation’s kind, bound and provenance but not its modality, so an unstated modality is a legal spec. Reporting it as an error would invent a requirement. Dropping it would pass a breach in silence.

Comparing is safe, subtracting is not

A rule that SUBTRACTS two name-derived voltages meets binary floating point head-on. 3.3 - 1.8 is 1.4999999999999998, which prints as noise in a finding and misjudges a bound of exactly 1.5. The older datasheet rules only ever compared a rail against a limit, so this first appeared with pin tracking. Round the result, since microvolt resolution is finer than any bound a datasheet states. Then assert the PRINTED number in a test. A test like that caught this bug.

Two independent channels, when one channel is ambiguous by construction

Some questions cannot be answered from one signal no matter how carefully you read it. Is a net named ..._3V3 a 3.3 V rail, or a signal that swings at 3.3 V? Both are named that way, legitimately, by the same teams. No naming grammar separates them, and a rule firing on the name alone is noise dressed as a finding.

flowchart LR
    N["the NET's name<br/>carries a voltage"] --> AND{"both agree"}
    P["the PART declares<br/>the pin a power input"] --> AND
    AND -->|"yes"| C["candidate: 101 nets down to 45"]
    AND -->|"no"| Q["silent"]

rail-not-classified works this way. One channel comes off the net, and the other comes off the part’s own pin declaration. The sources are unrelated, so their agreement is evidence in a way neither channel is alone. The rule separated a board with a genuine configuration gap (45) from one without (5).

Two things keep it from overclaiming. Name the channels in the rule’s doc, because a reviewer needs to know the finding depends on a coincidence of two weak signals. And check what happens when a channel is structurally absent. On a format that cannot type power pins the second channel is always missing. So the rule is a no-op there rather than a partial answer, and the doc should say so.

Fixtures are the rule’s contract

Conformance fixtures are executable expectations, and the sidecar drives both the harness and the viewer’s expectations panel. Author three shapes:

  • A fires fixture carries the defect, plus every incidental firing listed, because the harness is exhaustive.
  • A passes fixture carries the same topology done right. fires: {} is the strongest assertion the harness holds.
  • A guard fixture is the channel case, proving the rule is silent where the convention is absent (rails, zero test points, nothing fires).

Three KiCad authoring details bite everyone once. Pins bind at wire endpoints, while labels and junction dots bind mid-span. The pin connect point is origin + (local_x, −local_y). And power-symbol fixtures need the {} .kicad_pro stub, or their nets read as external.

Expect the showcase boards to react. A false positive has to get past them before it ships, so a new rule firing there is a deliberate decision. Cover the passes board, which must remain silent, and list the fires board’s incidental firings in its sidecar.

Verify at four levels

Level What it provides
Unit guard matrix one test exercising every guard both ways
Conformance the fixtures above, in CI
Corpus plus the real exports sweep old-versus-new binaries and attribute every delta per-file, per-rule. The corpus alone is not enough. The real exports carry the scale and the tool dialects that expose whole classes of false positive (the 1836-finding lesson)
A reference implementation, when one exists agreement with kicad-cli sch erc or kicad-cli pcb drc is the strongest evidence a rule’s semantics are right, and it pinned the connection-point rules and the naming priorities

Hand-trace what fires at level 3. The example’s five real-board findings decomposed into three genuine gaps and two policy-edge feedback nodes, and that decomposition set the severity. Design-for-test has no open reference implementation, so say that in the PR.

Ship it

The PR carries the reviewer’s guide, the hardware-context primer, the before/after transcript, and a record of every deferred edge. The rule’s doc is already written, so the catalog, the viewer, and the next reader all get the explanation the day it merges.