mirror of
https://github.com/Comfy-Org/github-workflows.git
synced 2026-11-05 00:17:37 +00:00
[PR #311] [MERGED] fix(reviewers): converge both reviewers.yml parsers on last-wins duplicate keys, s-white comments and a leading BOM #312
Labels
No labels
cursor-review-catalog-drift
groom
groom
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Comfy-Org/github-workflows#312
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
📋 Pull Request Information
Original PR: https://github.com/Comfy-Org/github-workflows/pull/311
Author: @mattmillerai
Created: 9/18/2026
Status: ✅ Merged
Merged: 9/19/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-15312-reviewers-parser-convergence📝 Commits (8)
1ab8b60test(reviewers): drive both reviewers.yml parsers from one shared corpus02f501ctest(reviewers): bracket the whole script scalar, frame nested spans, pin multi-line flow62980a5fix(reviewers): converge both reviewers.yml parsers on last-wins duplicate keys, s-white comments and a leading BOM3559301fix(reviewers): repoint locs on a shadowed default_pool, anchor the flow span, scope the last-wins claimbc66d13Merge remote-tracking branch 'origin/main' into matt/be-15312-reviewers-parser-convergence3140808fix(reviewers): trim tokens with one explicit space-and-tab set in both reviewers.yml parsers (#312)4f5209fMerge remote-tracking branch 'origin/main' into matt/be-15312-reviewers-parser-convergence61f923afix(reviewers): warn for every configured login, not only the ones a run routes through📊 Changes
7 files changed (+766 additions, -41 deletions)
View changed files
📝
.github/assign-reviewers/README.md(+39 -0)📝
.github/assign-reviewers/parser-corpus.json(+248 -0)📝
.github/assign-reviewers/tests/assignment.test.cjs(+101 -1)📝
.github/refresh-reviewers/generate.py(+98 -19)📝
.github/refresh-reviewers/tests/test_generate.py(+150 -1)📝
.github/workflows/assign-reviewers.yml(+98 -20)📝
docs/callers/assign-reviewers.md(+32 -0)📄 Description
ELI-5
Two different programs read the same
reviewers.ymlfile: the workflow that assigns owners to a PR (JavaScript, inline inassign-reviewers.yml) and the generator that rewrites that file when expertise drifts (Python, ingenerate.py). Neither uses a real YAML library, so each has its own small hand-written parser — and in three places those two parsers disagreed about what the same bytes mean. This makes them agree, and adds cases to the shared fixture file so they cannot silently drift apart again.What changed
Three convergence points, each decided by the preceding spike and each now pinned in the shared corpus:
1. Duplicate top-level
default_pool:is last-wins, with a warning. YAML requires unique keys and PyYAML's de-facto last-wins is the reference. The JS block arm used to append to the flow list it should have replaced (default_pool: [alice]thendefault_pool:/- bobparsed as["alice", "bob"]on the JS side and["bob"]on the Python side); it now resets first. Both ports warn and neither rejects — a malformed map must not hard-fail routing or the drift generator.2.
#opens a comment only at column 0 or after a space or a tab (YAML s-white), spelled out on both sides./\s/andisspace()are both wider than s-white and wider in different directions, so the ports genuinely disagreed:isspace()treats U+0085 and U+001C as whitespace and/\s/does not;/\s/treats U+FEFF as whitespace andisspace()does not. Each of those three characters was a live divergence.3. A single leading U+FEFF is stripped from the document before splitting into lines, on both sides. JS
trim()already tolerated it; Pythonstrip()does not treat it as whitespace, so a BOM-prefixed config had its firstdefault_pool:key skipped entirely and routed on an empty pool.rewrite_configdeliberately keeps its owntext.split("\n")and the BOM with it — its contract is byte-faithfulness. Dropping one character off the head of line 0 changes no line index, and_rewrite_flow_linelocates the bracket span within the line, so the BOM survives a rewrite in place. A new test pins exactly that.The duplicate-key warning is the one part the shared corpus cannot carry, because the channels differ (
core.warningvs a printed::warning::line), so each suite asserts its own. Both are keyed on "the key was seen" rather than "the list is non-empty" — an emptiness test would make the JS port warn ondefault_pool: []/default_pool: [bob]where the Python port stayed silent, reintroducing a divergence inside the fix for one.The JS parity harness now injects a
corestub into thenew Functionscope it extracts the parser into. That is load-bearing, not cosmetic:coreis ambient inside agithub-scriptstep but not inside the harness, and the shipped call site sits inside acatchthat logs "nothing to do" and returns — so acorethat were not in scope would silently disable routing rather than fail loudly. The stub also captures the warnings, which is what lets a test assert one fired.Non-vacuity by mutation
Each edit reverted one at a time, everything else in place; the table records what actually went red.
corpus config — duplicate default_pool, flow then block,— second one empty, plus the JS warning test/\s/corpus config — U+FEFF before # is not a commenta duplicate top-level default_pool: warns once…,an empty first default_pool: still warns…isspace()test_config_cases [U+0085 before # is not a comment],[U+001C before # is not a comment]removeprefixremovedtest_config_cases [leading BOM before the first key],test_bom_flow_default_pool_rewrite_keeps_the_bomtest_duplicate_warns_once_and_last_wins,test_empty_first_list_still_warnslocsfalls back to the shadowed keytest_locs_drop_a_shadowed_key_when_the_winner_has_no_itemstest_flow_span_is_anchored_to_the_value_not_to_any_later_bracketreviewers.ymlthe duplicate warning names the configured path…Note the two comment-rule rows red on disjoint characters, which is the divergence itself showing through: U+FEFF only reds the JS side, U+0085 and U+001C only the Python side.
The one honestly vacuous edit: the JS
text.replace(/^\uFEFF/, ''). Reverting it reds nothing, and it cannot —indentOfsees a BOM as zero indent and the very next statement israw.trim(), whose JS definition already strips U+FEFF. It is kept because the ticket asks for the strip to be explicit in both ports rather than left to a whitespace definition the two languages disagree about, which is the whole point of the change; it is a parity-by-construction edit, not a behavioural one. Three of the ten new corpus cases are likewise pure regression pins that no mutation in the table reds:leading BOM before a comment line(both rules reach the same answer either way),tab before # starts a commentandspace before # starts a comment(old and new rules agree on s-white itself — they exist so the narrowing cannot be narrowed further).Live exposure
Nil, and measured rather than assumed. Every committed
reviewers.ymlI can read — this repo's own plus the two consumer configs the ticket names — was parsed through the before and after build of both ports:#header line; each has exactly one top-leveldefault_pool:locationsidentical before and after for all three, so the rewrite path is untouchedSo no routing outcome changes for any current caller. The third config the reusable could in principle serve was not readable with this token; see Residual.
Review fixes (cursor-review panel, 6 threads)
The panel raised six findings; five are fixed in
3559301, one is deferred with afollow-up. Each fix is mutation-checked the same way as the original work.
locsdid not follow last-wins (6/6 reviewers). Reproduced before fixing:default_pool: [alice]/default_pool:/rules: …parsed to[]whilelocsstill pointed at("flow", 0), sorewrite_configwrote the refreshedpool onto the shadowed key — an emitted file that re-parses to an empty pool
and a scheduled run that regenerates the same no-op diff forever. The block arm
now repoints
locs["default_pool"]on every occurrence, takingNonewhen thewinner carries no item lines.
Noneis not a new dead end:main()already hada
locs["default_pool"] is None or not dp_afterbranch that reports thecommitted pool unchanged, so this lands on an existing path. Pointing
locsatthe item-less winner instead was the alternative, but there is no span to
rewrite there and
rewrite_confignever inserts lines.A regression this PR introduced in the rewrite path (1/6). Narrowing
_strip_commentto s-white means that after a non-s-white character the#nolonger opens a comment, so the whole tail is the plain scalar value — and
_rewrite_flow_line, which searched for any[after the key, rewrote thebracket inside that comment-shaped tail and left the real value routing:
reviewers: alice<U+00A0># see [bob]becamereviewers: alice<U+00A0># see [new1, new2]. Fixed by anchoring rather than byreverting the comment rule: the flow span now requires the value itself to
open with
[, exactly as_parse_flowalready requires before returning a flowlist. That removes an asymmetry between the two instead of papering over it.
The last-wins claim overclaimed (4/6). A second top-level
rules:appends onboth ports, with no seen-flag and no warning — verified. Took the reviewers'
second option and scoped the documented guarantee to
default_pool:in all threedoc sites, rather than extending the reset to
rules:: that would be a fourthbehavioural change here, and silently dropping a previously-honoured rules block
is too big to make unannounced.
Two nits, both real. The duplicate-key warning hardcoded
reviewers.ymlthough
reviewer_config_pathcan rename it;parseReviewerConfignow takes thepath as an argument and the shipped call site passes
configPath. It could notsimply close over the outer binding — the parity harness extracts the parser
region into its own
new Functionscope injecting onlycore, so a freeconfigPathwould throw ReferenceError there. And the BOM test's literal U+FEFFcharacters are now
\ufeffescapes: a literal goes silently vacuous if aneditor normalises it away, since
startswith("")is always true. The NBSP inthe new regression test is escaped for the same reason.
Deferred, not fixed: token-level trim/strip still diverges (4/6). Confirmed in
both directions —
[<U+FEFF>alice]yields the routable loginalicein JS and\ufeffalicein Python;[x<U+0085>, y]yieldsxin Python andx\^EinJS, because
strip()takes U+0085/U+001C but not U+FEFF andtrim()is theexact reverse. It is the same class of divergence this PR exists to close, but
closing it means defining one explicit trim set and applying it at every
strip()/trim()call site in both ports plus its own boundary corpus cases —token parsing rather than the comment/BOM surfaces edited here. Filed as a
follow-up with the reproduction and call sites. Nothing routes differently today:
no readable committed config carries these characters.
Verification
node --test .github/assign-reviewers/tests/*.test.cjs— 114 pass, 0 failpython3 -m unittest discover -s .github/refresh-reviewers/tests -p 'test_*.py'— 63 pass, 0 failpython3 .github/workflow-pins/check_workflow_pins.py— OK, 11 workflows, 0 defaults, every ref checkout guardedpython3 .github/agents-md-integrity/check_agents_md.py --root .— passed (2 pre-existing warnings, unrelated)python3 -m unittest discover -s .github/workflow-pins/tests …— 341 pass;…/agents-md-integrity/tests— 46 passyaml.safe_loadonassign-reviewers.yml— parses;python3 -W error::SyntaxWarningimport ofgenerate.py— cleanBoth CI path filters already watch
parser-corpus.json, so this runs both suites.Judgment calls
Deviation from the ticket's literal prescription, one line, deliberate. The ticket specifies the JS warning fire on
if (out.default_pool.length)and the Python one onlocs["default_pool"] is not None. Those two conditions disagree with each other: ondefault_pool: []/default_pool: [bob]the Python one warns and the JS one does not, which would ship a fresh JS/Python divergence inside the PR whose purpose is removing them. Both sides use an explicit seen-flag instead. Parsed behaviour is exactly what the ticket specifies — last-wins — and is corpus-pinned; only the warning's trigger differs from the prose, and the two tests named in the table pin the empty-first-list case on each side so the divergence cannot come back.Chesterton's fence on
out.default_pool = [];. This is the one line that changes existing behaviour (append → replace), so: on a firstdefault_pool:it is a no-op because the field is initialised to[], so the only reachable change is a second occurrence — which is the ticket's decision, matches what the JS flow arm and both Python arms already did, and matches PyYAML.git log/git blameon the block arm shows no intent comment or commit message defending accumulation; it reads as an oversight in the original port, which is what the- bob/["alice","bob"]divergence above demonstrates.Scope.
rewrite_configand thelocationsbookkeeping are untouched, as the ticket requires — that surface belongs to the separate rewrite-path work. The one newrewrite_configtest asserts the existing behaviour still holds under a BOM; it changes no rewrite code.Negative-claim falsification: trigger did not fire. The diff adds no
throw, no deny/dead-end path, no "not supported"/"unavailable" string, and flips no test to assert a dead-end. The two strings it adds are warnings on a path that continues and still routes. The one capability genuinely narrowed —#after U+0085/U+001C/U+FEFF no longer starting a comment — is a deliberate convergence on the YAML spec, is symmetric across both ports, and is pinned in the corpus in the positive direction (those documents now parse to a login, where one port previously truncated them).Residual
.github/reviewers.ymlin a third potential consumer repo was not readable with this token (404 Not Foundfrom the contents API — either the file does not exist there or the token lacks access; I cannot distinguish the two from a 404, and did not try to). The live-exposure measurement above therefore covers three configs, not necessarily every config this reusable serves. If a fourth config exists that carries a BOM, a duplicatedefault_pool:, or a#preceded by U+0085/U+001C/U+FEFF, its routing would change — the three conditions are cheap to grep for, and a maintainer with broader read access should do so before this lands. Every config actually checked has none of them.The PythonNow fixed — the panel raised it 6/6 and it is addressed inlocationsbookkeeping is left inconsistent for one duplicate-key shape.3559301(see Review fixes above). It was pre-existing rather than introduced here, but leaving it while adding a corpus case for exactly that document shape was not defensible: the rewrite wrote the refreshed pool onto the shadowed key and the file re-parsed empty.trim()/strip()still diverges between the ports, and is deferred.[<U+FEFF>alice]routes asalicein JS and\ufeffalicein Python;[x<U+0085>, y]isxin Python andx\^Ein JS. Reachable but not live — no readable committed config carries these characters — and closing it is a fourth convergence point needing an explicit shared trim set at every call site in both ports plus boundary corpus cases. Filed as a follow-up rather than folded in.Second round (CodeRabbit, 1 thread)
The invalid-configured-login warning did not match its own documented contract.
docs/callers/assign-reviewers.mdpromises the run warns "whenever a login configuredin
reviewers.ymlcannot be a GitHub login at all". It did not: the warning fired onlyfrom the candidate helper, reached on two paths — a rule's reviewers once that rule's
paths matched a changed file, and
default_poolonly on a run that fell through to thefallback. A rotted owner in an area this PR did not touch stayed silent, and a padded
default_poolentry could go unreported for as long as any rule kept matching, right upuntil the fallback fired and needed it. Backwards, given the padding is invisible
precisely so the warning is what finds it.
Fixed by sweeping every
config.rules[].reviewersentry and the wholedefault_poolonce up front through a warning-only
warnConfiguredLogin. Warn broadly, routenarrowly: the sweep enqueues nothing, so an unmatched rule's owners are reported without
entering
candidates, andwarnedLoginsstill holds it to one warning per bad token.configuredCandidatewas removed rather than kept as a second guard — with the sweep inplace, dropping its warn call reds no test, so it was dead redundancy.
That warning shipped in #312 with no test whatsoever. Six now cover it, each pinned
to a distinct mutation:
default_pool, dedupe)warnedLoginsdedupe removedeach bad token warns once…,a padded login in a MATCHED rule…eligible()instead of the shape testa well-formed login is never warned about, excluded or notconfiguredCandidate(pre-collapse)\u00a0is written escaped in the new tests for the reason the earlier round established:a literal goes silently vacuous if an editor normalises it away.
Both doc sites now state the scope explicitly — the warning reports the state of the
file, not of the diff, and being warned about is not being routed to.
No Python-side change: this is the routing script, outside the shared
PARITY-HARNESS:config-parserregion, andgenerate.pyhas no equivalent warning.Negative-claim falsification: trigger did not fire. The change adds
core.warningcalls on a path that continues and still routes; it adds no throw, no deny, no
"unavailable"/"not supported" string, and flips no test to assert a dead end. No login
that routed before stops routing — candidate insertion is byte-for-byte the prior logic.
Provenance
uses:SHA-pinned); check_agents_md.py: passed (2 pre-existing warnings); check-org-repo-literals.sh: OK; yaml.safe_load on assign-reviewers.yml: parses; mutation sweep re-run including the 5 new rows for this round; all 7 review threads replied to and resolved (6 fixed, 1 deferred with a follow-up)out.default_pool.length/locs["default_pool"] is not Nonepair, because those two conditions disagree with each other on an empty first list; parsed last-wins behaviour is exactly as specified. A one-line dialect note was also added to the workflow's own header comment, beyond the two doc files the ticket names, since that header is where the schema is documented for callers. The review round then touched two surfaces the ticket fences off (locsbookkeeping and_rewrite_flow_line): the first because the panel raised it 6/6 and this PR adds a corpus case for exactly that shape, the second because the narrowed comment rule introduced the fault there. Both are confined to correctness fixes; no rewrite capability was added.parseReviewerConfiggained an optional second argument (the config path, for the warning text) — additive, and the parity harness still calls it with one. Second round: the configured-login warning was broadened from the two routing paths that reached it to an up-front sweep of the whole config, which is a behaviour change beyond the thread's literal "scan and warn" ask only in thatconfiguredCandidatewas deleted rather than left in place — justified by mutation (its warn call reds nothing once the sweep exists). Both doc sites were edited to state the scope, though neither was wrong before: the contract they already described is the one the code now meets.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.