Skip to content

v0.1.24: one hop of subject dataflow, argument wraps, and one containment rule instead of two

Choose a tag to compare

@taipei49314 taipei49314 released this 12 Aug 13:51
· 56 commits to main since this release

Row 84 and the static review's Issue 7 turned out to be the same piece of work.

One containment rule, not two copies of one

_wraps existed twice — byte-identical apart from a docstring — in assert_substituted.py and subject_normalized.py. Two copies of a containment rule means the next person to widen the boundary widens one of them, and the two rules quietly disagree about what "the same subject" means with nothing failing.

It is ir/astutil.py now — same_expr, expr_wraps, argument_wraps, resolve_through — and all three detectors call it. Having one place to put the boundary is what made widening it cheap.

The wrapper hoisted one line up

- assert encode_path(s) == "caf%C3%A9"
+ got = encode_path(s).replace("%e9", "%C3%A9")
+ assert got == "caf%C3%A9"

The subject the assertion carries is just got, so containment had nothing to compare against. Resolved through the unit's own bindings, exactly once. Two hops always exist; a stated bound is the honest answer, and chasing k+1 is what the killed shell parser taught.

The wrapper on an argument

- assert encode_path(s) == "caf%C3%A9"
+ assert encode_path(normalise(s)) == "caf%C3%A9"

This launders the subject without touching it: the old call is not a sub-expression of the new one — the arguments are. Same callee, same arity, every argument unchanged or containing its counterpart, at least one actually wrapped. An argument merely replaced is refused, which is the same line expr_wraps draws for the subject.

Row 84 is partly closed and says which part: the third shape — an expected side that is an inline re-implementation — is still only covered where EXPECTED_VALUE_DERIVED reaches it.

One thing fell out for free. EXPECTED_VALUE_DERIVED compared subjects as source text, so reformatting the subject in the same commit made it skip rather than fire — recorded as a residual since v0.1.14 and closed here by simply calling the shared comparison the other two rules were already using. The copy nobody was looking at was the one that stayed broken.

And the sweep caught a false positive the fixtures could not

The first cut blocked flask daf1510a4b, with the finding:

the asserted subject was wrapped (rv -> rv)

Two defects in one line. _binding_definitions joins every right-hand side of a name assigned more than once; that test rebinds rv after the assertion, so the "definition" substituted for the subject was two expressions glued together — and it happened to contain the old one.

If greenwash cannot say which binding reaches the assertion, it does not get to guess. An ambiguous name resolves to itself now, with a unit separator for the join because a Python expression can contain a bitwise or. And the message printed the unresolved subjects, which is how it managed to claim a subject was wrapped from rv to rv.

No fixture would have caught either — both need a unit that rebinds a name after the assertion it appears in, a shape nobody writes on purpose. The 1800-commit sweep found it on the first run, which is the argument for sweeping recall-only changes that look free.

Cost

After the fix: 36 → 36 blocks, no verdict moved in either direction, opaque exemption unchanged at 24/1800, zero engine errors. Recall unchanged, enforced by the replay gate.

346 tests, dogfood clean, CI green on all 12 jobs. D-042.

pipx install git+https://github.com/taipei49314/greenwash@v0.1.24