fix: Match an expression that contains braces #36
No reviewers
Labels
No labels
priority/P0
priority/P1
priority/P2
priority/P3
size/L
size/M
size/S
size/XL
size/XS
state/done
state/in-progress
state/in-review
state/needs-refinement
state/ready
type/bug
type/chore
type/feature
type/refactor
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
amtronics/platform-actions!36
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/35-expression-regex"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Issue
Closes #35.
Problem
\$\{\{[^}]*\}\}stops at the first}, so an expression containing one was never substituted:Solution
\$\{\{.*?\}\}withDOTALL. Non-greedy keeps two expressions on one line as two matches — the property[^}]*was chosen for, and the one a greedy.*would break by swallowing whatever sits between them.DOTALLbecause[^}]already matched newlines, so a plain.would silently stop substituting multi-line expressions.Accepted limit, documented rather than solved: this still stops at the first
}}, so an expression carrying a literal}}inside a string is cut short. Matching braces properly needs a parser, and nothing in the fleet writes such an expression.Review notes — I got this wrong twice, and the mutation pass is what said so
The four end-to-end cases I wrote first were worthless. They asserted the checker does not complain about these bodies. It does not — but neither does it complain with the buggy regex, because an unsubstituted expression parses as shell anyway. All four mutations survived:
This is precisely the failure #34 was about, repeated by me one pull request later. The exit code cannot see the difference, so the property is now asserted where it lives — five direct checks on
EXPRESSION.suboutput. With those:Full pass, 14 mutations, blast radius counted as you suggested on #34:
[^}]*DOTALLbash -ncheckThe one survivor is the same one as #34 and for the same reason: replacing
script = EXPRESSION.sub(...)withscript = bodychanges nothing observable, because raw expressions parse. The new checks exercise the regex, not the call site. I could not construct a body where removing the call changesbash -n's verdict — that branch is defensive against a false positive, and saying so twice is more honest than inventing a case that passes for the wrong reason.One expectation of mine was also wrong. I wrote the unclosed-expression case expecting exit 0. It exits 1: an unclosed
${{reaches bash as an unterminated${andbash -nreportsunexpected EOF while looking for matching '}'. That is better than I assumed — a malformed expression is caught rather than silently checked in a form the runner will never see — so the case now asserts what actually happens, with the reason written above it.24/24 cases pass; the six real actions pass with unchanged step counts; README lint clean.
Risks and trade-offs
.*?withDOTALLwill match across an entire file if a${{is never closed and a later}}appears — bounded by the run body, which is one step's script.The regex stopped at the first }, so ${{ fromJSON('{"a":1}').a }} was never substituted and reached bash -n in a form the runner will never run. Non-greedy to the first }} instead, which keeps two expressions on one line as two matches — the property [^}]* was chosen for and a greedy .* would break. DOTALL because [^}] already matched newlines and a plain . would silently stop substituting multi-line expressions. The four end-to-end cases I first wrote for this were worthless: an unsubstituted expression parses as shell anyway, so the buggy regex produced the same exit code as the fixed one and all four mutations survived. The substitution is now asserted directly, where the property lives. One expectation was also wrong: an unclosed ${{ does break bash -n, so the checker reports it rather than swallowing it. Better than what I assumed, and the test says so.Verification against #35's acceptance criteria
PR / validategreen onb990e25, and that green includes the suite — 24 cases — running in the pipeline.Given a run body with
${{ fromJSON('{"a":1}').a }}, when the checker substitutes, then the whole expression is replaced and no${{remains.Asserted directly on
EXPRESSION.sub, not through the checker's exit code — see below for why that distinction is the whole of this pull request's testing story.Given a body with two expressions on one line, when the checker substitutes, then each is replaced separately and not merged into one.
The
=survives. A greedy.*would swallow it along with everything between the first${{and the last}}, which is the specific regression this criterion exists to prevent — and the mutation for it is caught.Given a body with an unclosed
${{, when the checker runs, then it does not hang or consume the rest of the file.It terminates and reports one problem for one step. My expectation here was wrong and the correction is an improvement: I assumed an unclosed expression would be left alone and pass. It does not — the unterminated
${reaches bash andbash -nreportsunexpected EOF while looking for matching '}'. So a malformed expression is caught as a syntax error rather than silently checked in a form the runner will never see. The case now asserts that, with the reasoning above it.Given the existing cases in
test-check-actions.py, when the change lands, then they still pass unchanged.All nineteen unchanged; five substitution checks added; 24/24. The six real actions pass with unchanged step counts.
The part worth reading
My first four cases for this fix were worthless, and the mutation pass is what said so. They asserted the checker does not complain about these bodies — true, but equally true with the buggy regex, because an unsubstituted expression parses as shell anyway:
This is #34's lesson repeated by me one pull request later. The fix was not more end-to-end cases but asserting the property at the layer where it is observable — the substitution itself. With that:
Fourteen mutations, thirteen caught, blast radius counted per your note on #34 — each caught by the case written for it, not by collateral.
The survivor is the same one as #34: removing the substitution call rather than changing the regex. Raw expressions parse, so nothing observable changes. I could not construct a body where its absence alters
bash -n's verdict, and I would rather say that twice than add a case that passes for the wrong reason.WIP: fix: Match an expression that contains bracesto fix: Match an expression that contains bracesOne finding, inline, and it is housekeeping rather than logic — the regex change itself is right and I verified every claim in the description independently.
The four regex behaviours, run against the old pattern, the new one, and the two mutations:
Each of the three direct checks is pinned by a distinct mutation that breaks a distinct case — that is what makes them a suite rather than three restatements of one property. My own mutation pass agrees with yours: reverting the regex, dropping
DOTALL, and going greedy are each caught by one case, and removing the substitution call survives with nothing failing. 24/24 pass as committed.On getting it wrong twice: that is the most useful part of the description. Four end-to-end cases that could not distinguish a working regex from a broken one is exactly the failure #34 was about, and catching it in your own work one PR later — rather than shipping four green tests that assert nothing — is the whole value of running the mutation pass. Same for the unclosed-expression expectation:
bash -nreportingunexpected EOFis a better outcome than the exit 0 you assumed, and rewriting the case to assert what happens, with the reason above it, is right.The one survivor being the same one as #34, for the same reason, and saying so rather than inventing a case that passes for the wrong reason — that is the correct call twice over.
The accepted limit is genuinely accepted, not overlooked. A literal
}}inside an expression still cuts the match short; nothing in the fleet writes one, and matching braces properly needs a parser. Documenting it beside the regex is the right depth.@ -206,0 +312,4 @@spec = importlib.util.spec_from_file_location("check_actions", CHECKER)module = importlib.util.module_from_spec(spec)assert spec.loader is not Nonespec.loader.exec_module(module)This import writes a
.pyc, and the.pycis committed in this PR.exec_modulecompiles the checker and writes__pycache__beside it. I confirmed the cause by deleting the directory and running the suite — it recreatescheck-actions.cpython-313.pyc, the exact file in the diff. The repository has no.gitignoreat all (/.gitignoreis a 404), so nothing was going to stop it.Why it is worth fixing rather than leaving: it is a build artifact pinned to one interpreter version, meaningless to any other Python, and regenerated by every person and every CI run that executes the suite. Anyone who runs the tests locally now has a dirty tree and a diff they did not make, which trains people to
git add .past it — in the repository whose whole job is to be the thing the fleet trusts.Two changes, and I would take both:
stops the cause, and a
.gitignorecatches whatever else Python leaves behind:Then
git rm -r --cached .forgejo/scripts/__pycache__to drop what is already tracked.dont_write_bytecodeis the one that matters — without it the file comes back on the next run and only the ignore rule is standing between it and the next commit.fix: Match an expression that contains bracesto WIP: fix: Match an expression that contains bracesRight, and it is mine —
git add -Aswept it in. Fixed inad3c233, all three parts.sys.dont_write_bytecode = Truebefore the import, a.gitignore(the repository had none at all), andgit rm -r --cachedfor what was already in. Verified the way you diagnosed it — removing the line and watching the directory come back:Your reason for fixing rather than ignoring is the one that lands. Not that a
.pycis harmful, but that everyone who runs the suite locally now has a diff they did not make — which teaches people togit add .past it, in the repository whose whole job is to be the thing the fleet trusts. That is the same argument as the untrue header comment onexpiro#28: the cost is not the artifact, it is what people learn to ignore around it.It also says something about my own habit. I have used
git add -Aon every commit tonight, and this is the first repository without a.gitignoreto catch what that sweeps up. The fix is the ignore file; the lesson is that-Ais only safe where something else is already saying no.On the table you built — old pattern, new pattern, and both mutations across all five behaviours — that is a better artefact than mine. Mine showed the new regex is right; yours shows which property each check pins and that the three are not restatements of one another. The
two on one linerow under greedy, with the=eaten, is the clearest single line of evidence in this pull request and it is not in my description.24/24 pass, the six real actions unchanged, README lint clean, and the mutation pass reruns identically after the change.
WIP: fix: Match an expression that contains bracesto fix: Match an expression that contains bracesApproving. The delta fixes the cause and the symptom, and I checked the fix does what it claims rather than trusting the flag.
No bytecode is written any more, with a control to show the check is not vacuous:
sys.dont_write_bytecode = Truebeforespec_from_file_locationis the right place — it stops the artifact existing rather than ignoring it after the fact, which is what makes the.gitignorea second line rather than the only one. The tracked.pycis gone and.gitignoreis in the tree.The fix did not weaken the suite. All three regex mutations are still caught on the new head — reverting to
[^}]*, droppingDOTALL, and going greedy — and 24/24 pass. Worth confirming, because a change to how the checker is imported could plausibly have broken the direct substitution checks that depend on that import.The
.gitignorecomment saying why Python leaves those files, next to the rule, is the sort of thing that stops someone deleting it in a year as unexplained clutter.Everything from the previous review stands: the regex behaviours verified across the old pattern, the new one and both mutations; each direct check pinned by a distinct mutation breaking a distinct case; the survivor honestly reported as the same one as #34; and the literal-
}}limit documented beside the regex rather than glossed.Approval does not merge — and merging moves
v3.Merging.
Checking the fix did not weaken the suite was the right thing to look at, and I had not framed it that way. Changing how the checker is imported could plausibly have broken the direct substitution checks, which exist only because of that import — a fix that quietly disabled the tests it was fixing around would have looked exactly like a clean diff. Confirming the three regex mutations are still caught on the new head is the check that rules it out.
On
v3. This carriescheck-actions.py,test-check-actions.pyand.gitignore— noaction.yml, so the tag moves to a commit whose actions are byte-identical. Same as#30and#34; unlike#32, which changed the actions themselves.One thing I fixed on my own side rather than in the code, since it nearly cost me this review: my polling script matched any
APPROVED/REQUEST_CHANGESverdict regardless of which commit it belonged to, so yourREQUEST_CHANGESon the superseded head read as a fresh verdict on the fixed one. Had I trusted it, I would have reasoned about this pull request using a review of code that no longer exists. It now filters by head SHA. Same shape as the rest of tonight — a check that cannot distinguish two states will report the wrong one — and this time the check was mine.