Skip to content

feat!: a third outcome for the differ, and the org exit-code contract - #24

Merged
CameronBrooks11 merged 1 commit into
mainfrom
feat/a3-third-state
Sep 6, 2026
Merged

CameronBrooks11 merged 1 commit into
mainfrom
feat/a3-third-state

Conversation

@CameronBrooks11

Copy link
Copy Markdown
Member

Resolves adjudication A3. For a differ too, silence must never read as no
difference
.

The defect, reproduced

has_changes is a boolean, so it was false both for two identical boards and for a
board carrying geometry the engine does not model.

A stroke drawn with a macro or block aperture is not modelled by the geometry engine.
docs/geometry-diff.md has said so under "Known limitations" for a long time — but the
output never did:

$ gerberdiff geomdiff before after --fail-on-diff     # after/ has an added trace
geomdiff: 0/1 layers changed, 0 changes  (1 ms)
exit=0            # stderr: 0 bytes

$ gerberdiff diff before after --fail-on-diff         # same two directories
diff: 1/1 layers changed  (271 ms)
exit=1

Two engines, one question, opposite answers — and the geometry engine gave the
dangerous one.
The JSON was byte-identical to comparing before against itself, so
there was no field a consumer could branch on.

The fix

The engine records what it could not model, by reason
(UNREPRESENTED_REASONS), covering every silent return in the emit path: strokes with
macro or block apertures, apertures whose extents cannot be computed, macro flashes that
evaluate to empty, and block nesting past the replay depth.

A layer carrying any of it is indeterminate, and the comparison reports
identical | different | indeterminate:

$ gerberdiff geomdiff before after --fail-on-diff
warning: this comparison is incomplete; 1 operation(s) could not be modelled:
  1 x a stroke drawn with a macro aperture; the geometry engine does not model it
geomdiff: indeterminate, 0/1 layers changed, 0 changes  (1 ms)
exit=2

different outranks indeterminate, so one unmodellable stroke cannot mask a trace
that actually moved. The reasons go to stderr even under --quiet — suppressing them
is the failure being reported on.

Indeterminate does not wait for --fail-on-diff. That flag chooses whether a
difference is a failure; it has no bearing on whether the tool could look. Same
reasoning as netspec's incomplete.

BREAKING: exit codes now match partspec and netspec

A1 settled this vocabulary across the org; gerberdiff was the last member out of step.

code before now
0 no changes no differences, everything modelled
1 changes found; also could-not-write-output differences found (with --fail-on-diff)
2 parse error; also bad --align-offset comparison could not be completed
4 an input could not be read or parsed
64 usage (EX_USAGE)

1 and 2 were each doing two jobs. A gate written [ $? -eq 0 ] is unaffected; one
that treated 2 as a parse failure must now read 4.

Geometry report schema version 2 -> 3, adding summary.outcome,
summary.unrepresented and a per-layer unrepresented. Additive — every v2 field keeps
its meaning.

Tests

New tests/test_indeterminate.py, 14 tests.

Proved red by mutation rather than by reverting the source: neutering
cannot_represent to a no-op — restoring exactly the old silent-drop behaviour while
keeping the API — turns 6 of them red and leaves 8 green. The 8 are the pure-outcome
logic and the controls (identical boards still exit 0, a fully-modelled board records
nothing). Reverting the whole module instead would have failed at import and proved
nothing per-test, which is the shape §2.4 of the org contract warns about.

Ten existing tests changed, every one a direct consequence of the renumbering
(overwrite guards 1 -> 64, parse errors 2 -> 4, schema 2 -> 3) and each inspected
individually.

ruff, ruff format, mypy, the ASCII scan and the coverage gate all pass: 487
passed, 95.77%
(baseline 473).

Resolves adjudication A3. For a differ too, silence must never read as no
difference.

has_changes is a boolean, so it was false both for two identical boards
and for a board carrying geometry the engine does not model. A stroke
drawn with a macro or block aperture is not modelled -- documented as a
known limitation, but invisible at runtime. A board with an added trace
therefore reported 0 changes at exit 0, with the JSON byte-identical to
comparing a board against a copy of itself, while the raster engine
reported the change. Two engines, one question, opposite answers, and the
geometry engine gave the dangerous one.

The engine now records what it could not model, per reason. A layer
carrying any of it is indeterminate, and the comparison reports
identical | different | indeterminate. different outranks indeterminate,
so one unmodellable stroke cannot mask a trace that moved. The reasons
are named on stderr even under --quiet: suppressing them is the failure
being reported on.

Indeterminate exits 2 without waiting for --fail-on-diff. That flag
chooses whether a difference is a failure; it has no bearing on whether
the tool could look.

BREAKING CHANGE: the exit-code contract now matches partspec and netspec.
0 no differences, 1 differences (with --fail-on-diff), 2 could not
complete the comparison, 4 an input could not be read or parsed, 64 usage
(EX_USAGE). Previously 2 meant a parse error, and 1 doubled as both
"differences found" and "could not write the output file". The geometry
report schema goes 2 -> 3, additively.
@CameronBrooks11
CameronBrooks11 merged commit 0685cde into main Sep 6, 2026
8 checks passed
@CameronBrooks11
CameronBrooks11 deleted the feat/a3-third-state branch September 6, 2026 05:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant