Make --match-if match the semantics of --match-unless - #168
Merged
Merged
Conversation
`--match-if` refused every pair when used exactly as its own `--help` text describes, collapsing the whole diff into one wholesale replacement. It differed from `--match-unless` in two ways beyond the sense of the test, and both were wrong. First, it bound the raw `TreeNode` objects rather than their `to_obj()` representations. Subscripting a `DictNode` yields a `KeyValuePairNode` carrying both the key and the value, so `from['foo'] == to['bar']` compared two pairs with different keys and was always false. That is precisely the case the documented example exists to allow. `TreeNode.to_obj` already documents itself as existing to supply the objects for `--match-if` and `--match-unless`. Second, it answered an expression that raised with a `Replace`, refusing the pair. Constraints are attached to every node by a depth-first search, so they also reach the leaves, where subscripting by a dictionary key raises. Binding `to_obj()` alone is not enough: the roots then pair correctly, but every string and integer underneath them is still refused. Returning `None` leaves those pairs unconstrained, as `--match-unless` already did. Rework both tests in `test/test_constraints.py`. They used attribute access on node objects, which no longer applies once plain values are bound, and `test_match_unless` passed only because its expression raised and the error was swallowed. The replacements exercise the documented subscript syntax and cover both the permitting and the refusing case for each option, so they distinguish a working constraint from a constraint that does nothing. The errors stay at debug level. Now that a raising expression is the normal path for every leaf, warning on it would fire on essentially every run; the README points at `--debug` instead for diagnosing a constraint that appears to have no effect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa
This was referenced Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #148
--match-ifrefused every pair when used exactly as its own--helptext describes, collapsing the diff into onewholesale replacement. This changes the semantics of
--match-if. Expressions that relied on the old behavior —attribute access on Graphtage node objects, or on an error refusing a pair — will behave differently.
MatchIfdiffered fromMatchUnlessin two ways beyond the sense of the test, and both are fixed here:MatchIfbound the rawTreeNodes;MatchUnlessboundfrom_node.to_obj()/to_node.to_obj(). Subscripting aDictNodeyields aKeyValuePairNodecarrying both key and value, sofrom['foo'] == to['bar']compared two pairs with different keys and was always false — precisely the case thedocumented example exists to allow.
TreeNode.to_obj's own docstring already says it exists to supply the objectsfor
--match-ifand--match-unless.MatchIffell through toreturn Replace(...), refusing the pair;MatchUnlessreturned
None. Constraints are attached to every node by DFS, so they also reach leaves, where subscriptingraises.
Both changes are required. Binding
to_obj()alone pairs the roots correctly but still refuses every string andinteger underneath them:
{ "other": 1 -> "other": 2, "foo": "same" -> "bar": "same" }With both changes, the issue's command produces the same diff as the equivalent
--match-unlessspelling:$ graphtage --match-if "from['foo'] == to['bar']" original.json modified.json{ "other": 1 -> 2, "~~foo~~++bar++": "same" }Other changes
test/test_constraints.py: both existing tests are reworked. They used attribute access on node objects, which nolonger applies once plain values are bound, and
test_match_unlesspassed only because its expression raised andthe error was swallowed. The replacements exercise the documented subscript syntax and cover both the permitting and
the refusing case for each option, so they distinguish a working constraint from one that does nothing.
README.md: the closing paragraph of "Match Constraints" documented the asymmetry as a caveat and told readers toprefer
--match-unless. That is now wrong. It is replaced with an--match-ifexample block (output produced byactually running the command) and an accurate description of the shared behavior.
graphtage/__main__.py: the--match-ifhelp text now saysfromandtoare bound to plain Python values, andthat a pair whose expression raises is left unconstrained. It also says "two nodes" rather than "two dictionaries",
since the constraint applies to every node.
On the log level
I left the swallowed errors at
log.debugrather than raising them to a warning. Now that a raising expression is thenormal, expected path for every leaf node, a warning would fire on essentially every run and be noise rather than
signal. The README instead points at
--debugfor diagnosing a constraint that appears to have no effect, which Iverified prints the caught error for each skipped pair.
Validation
uv lock --checkreports the lockfile needs updating, but that reproduces on an unmodified tree — it is caused by aresolver date pin in my environment ("Resolving despite existing lockfile due to addition of global exclude newer"),
not by this branch. No dependency files are touched.
The tests were verified to catch the bug rather than merely pass:
graphtage/constraints.pystatepytest test/test_constraints.pymaster)to_obj()binding onlyreturn Noneon exception onlyEach half of the fix in isolation still leaves a failing test, which is what establishes that both are needed.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa