Skip to content

Make --match-if match the semantics of --match-unless - #168

Merged
ESultanik merged 1 commit into
masterfrom
148-match-if-semantics
Sep 9, 2026
Merged

ESultanik merged 1 commit into
masterfrom
148-match-if-semantics

Conversation

@ESultanik

Copy link
Copy Markdown
Collaborator

Closes #148

--match-if refused every pair when used exactly as its own --help text describes, collapsing the diff into one
wholesale 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.

MatchIf differed from MatchUnless in two ways beyond the sense of the test, and both are fixed here:

  1. Operand binding. MatchIf bound the raw TreeNodes; MatchUnless bound from_node.to_obj() /
    to_node.to_obj(). Subscripting a DictNode yields a KeyValuePairNode carrying both key and value, so
    from['foo'] == to['bar'] compared two pairs with different keys and was always false — precisely the case the
    documented example exists to allow. TreeNode.to_obj's own docstring already says it exists to supply the objects
    for --match-if and --match-unless.
  2. Error handling. On exception MatchIf fell through to return Replace(...), refusing the pair; MatchUnless
    returned None. Constraints are attached to every node by DFS, so they also reach leaves, where subscripting
    raises.

Both changes are required. Binding to_obj() alone pairs the roots correctly but still refuses every string and
integer 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-unless spelling:

$ 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 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 one that does nothing.
  • README.md: the closing paragraph of "Match Constraints" documented the asymmetry as a caveat and told readers to
    prefer --match-unless. That is now wrong. It is replaced with an --match-if example block (output produced by
    actually running the command) and an accurate description of the shared behavior.
  • graphtage/__main__.py: the --match-if help text now says from and to are bound to plain Python values, and
    that 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.debug rather than raising them to a warning. Now that a raising expression is the
normal, 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 --debug for diagnosing a constraint that appears to have no effect, which I
verified prints the caught error for each skipped pair.

Validation

$ uv run --frozen --extra dev ruff check graphtage test docs bindist
All checks passed!

$ uv run --frozen --extra dev pytest
145 passed in 32.92s

$ uv run --frozen --extra dev make -C docs html SPHINXOPTS="-W --keep-going"
build succeeded.

uv lock --check reports the lockfile needs updating, but that reproduces on an unmodified tree — it is caused by a
resolver 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.py state pytest test/test_constraints.py
unmodified (master) 3 failed, 4 passed
to_obj() binding only 1 failed, 6 passed
return None on exception only 2 failed, 5 passed
both changes (this branch) 7 passed

Each 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

`--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
@ESultanik
ESultanik merged commit c5f7673 into master Sep 9, 2026
11 checks passed
@ESultanik
ESultanik deleted the 148-match-if-semantics branch September 9, 2026 14:42
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.

--match-if refuses every pair when used as its --help text describes

1 participant