diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 04f28d9da26..dca4e1dbff5 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -2155,6 +2155,13 @@ export function branchNameTarget(ref) { * not narrow it here to serve H8: that would make the live half invisible to * the rows that exist to see it. * + * ⭐ And ⛔ not to serve H31 either (#18229). That row needs a stronger question + * — "does this PR CLOSE the card", because the gate's clearing stroke rides the + * landing — and it asks it of `bindingClosesCard` as a filter over the + * population THIS relation hands it. The narrowing is the READER's and lives + * beside the row that needs it; the relation stays wide, so H8, H31, H35 and + * H53 still cannot disagree about which PR delivers which card. + * * ## The boolean is DERIVED from `deliveryEvidence`, and stays byte-identical * * The verdict is now one `!== null` over `deliveryEvidence` below rather than a @@ -3014,12 +3021,24 @@ export const PM_STATE_LABELS = [ 'finding', ]; +/** + * The ruling-anchor state: a card that stays OPEN by design, so nothing that + * fires on a CLOSURE ever fires on it. H9's header carries the measurement + * (`Restart-when: closed …#5499` parked a card forever because #5499 is one of + * these), and H31 reads it for the same structural reason — a gate hung on a + * card nothing closes is never cleared by the stroke that clears gates. + * + * One spelling, two readers: a second literal here is exactly the drift that + * lets one reader move and the other keep answering the old way. + */ +export const TRACKING_ANCHOR_LABEL = 'tracking'; + /** * Labels whose NORMAL shape is domain-without-pm-state, excluded by the * sweep's own protocol text (SKILL.md, Backlog sweep): flagging them would * report the protocol's design as a defect. */ -export const H13_EXEMPT_LABELS = ['tracking', 'status:parked', 'qa-run']; +export const H13_EXEMPT_LABELS = [TRACKING_ANCHOR_LABEL, 'status:parked', 'qa-run']; /** * H13 threshold — "one sweep cycle": the triage Routine fires HOURLY and its @@ -6045,6 +6064,44 @@ export function h30QueueRotting(issue, nowMs = Date.now()) { // governs enqueue and landing while the PR is open, and a merged carrier is a // closed-out stroke rather than a live half-write. // +// ## Which binding makes a CARRIER PAIR (#18229) +// +// The comparison needs two carriers of ONE gate, and the gate is cleared by the +// stroke that lands the increment. So the binding that puts a card into it is +// the one that makes that landing reach the card: a CLOSING KEYWORD. A +// `Part of #N` line is the opposite declaration — it says the PR is a MEMBER of +// what #N tracks — and an epic tracker is by construction a card no PR closes. +// +// Measured, anchor #9857's 2026-09-14T19:45Z sweep: card #14122 (`tracking`, the +// one-artifact/N-packages epic) drew a row saying the gate was missing from the +// card half of a dual carrier, while the two PRs the row named — #18212 and +// #18213 — each carried `Fixes` for their OWN card (#18202, #18204), each of +// which carried the gate, and each said in the same line the predicate read that +// #14122 is a tracker that stays open. Both real dual carriers were intact. The +// row's own remedy text is action-shaped, so acting on it means hanging +// `needs:contract-review` on a card nothing closes: the clearing stroke never +// arrives and the gate sits there as a permanent false blocker. This false +// positive costs a WRITE nothing later removes, and every correctly gated +// sub-PR re-manufactures it on every sweep, forever. +// +// ⛔ The shared relation is NOT narrowed — `prDeliversCard`'s docblock forbids +// that, and H8's open side, H35's sibling resolver, `claimDelivery` and the +// pairing `check-clause2-carriers` derives all still read it wide. The narrowing +// is H31's OWN, one filter over the population THIS row judges, so the rows +// still agree about which PR delivers which card and differ only about which +// binding makes a carrier pair — which is a question only this row asks. +// +// ## …and every other binding is DECLINED, never silently dropped +// +// A silent drop would be #4690 in this row's own uniform: a split that was never +// judged would render exactly like a board whose two carriers agree, and this is +// the one row that can tell 「被剥」 from 「从未挂过」. So a weak-bound delivering PR +// whose carrier DISAGREES with the card's still produces a row — one that names +// both carriers and the binding it read, says the comparison is not answerable +// from that binding, and prescribes NOTHING. A weak-bound PR whose carrier +// AGREES produces nothing, exactly as before: there is no split to report, and a +// standing row per tracker per sweep is the disease above, not its cure. +// // Report-only, and emphatically: this is a GATE. ⛔ Never a label written from // this script — a sweeper that hung or cleared a review gate would be issuing // the review verdict, and the one thing the whole clause-② chain forbids is @@ -6054,9 +6111,43 @@ export function h30QueueRotting(issue, nowMs = Date.now()) { /** The clause-② gate label — one constant, both carriers. */ export const CONTRACT_REVIEW_LABEL = 'needs:contract-review'; +/** + * Does this PR's binding to card `n` make GitHub CLOSE the card on merge — the + * binding H31's carrier comparison needs (#18229)? + * + * One read of `deliveryEvidence`'s grading, ⛔ never a second keyword parser: + * that function grades the closing keyword FIRST and never lets position + * downgrade it, so a body carrying both `Fixes #N` and `Part of #N` answers + * `true` here — which is exactly what GitHub does on merge. The three weaker + * kinds answer `false`: `part-of` and `part-of-inline` declare MEMBERSHIP, and + * `branch-name` is a body that declared nothing at all, so neither says the + * landing reaches this card. + * + * ⚠️ It answers "does this PR CLOSE #n", never "does this PR deliver #n" — + * `prDeliversCard` owns the second question and stays wide for the readers that + * need a half in flight to be visible. ⛔ Do not substitute one for the other. + * + * Exported because the sibling shape is live one file over: the clause-② dual + * carrier that `check-clause2-carriers --pair` demands on an epic tracker + * reached through a `Part of` line (#18214) is the same question, and that fix + * wants this predicate rather than a second copy of it. + */ +export function bindingClosesCard(pr, n) { + return deliveryEvidence(pr, n) === 'closing-keyword'; +} + /** * H31 — null when the two carriers agree (or the comparison is not yet - * possible), else the finding sentence. + * possible), else the finding sentence, else — for a delivering PR bound to + * this card by something other than a closing keyword — the DECLINED sentence. + * + * Three outcomes, never two (#18229). A closing-keyword binding is adjudicated + * exactly as before, byte for byte. A weaker binding is never adjudicated and + * never silently dropped: it produces a row only when the two carriers actually + * differ, and that row states that it declined and prescribes no write. The + * declined row is deliberately NOT marked `UNJUDGED_MARKER`: that marker buys + * trim priority ahead of judged rows, and a decline must never sort ahead of a + * real carrier split in the same `gate` band. * * A PR row whose `labels` is not an array is one this sweep could not read, and * it is EXCLUDED from the comparison rather than counted as unlabelled: reading @@ -6076,8 +6167,15 @@ export function h31ContractReviewCarrierSplit(issue, openPrs) { ); if (delivering.length === 0) return null; // card-side-first is legal — see the header note. const cardGated = labelNames(issue ?? {}).includes(CONTRACT_REVIEW_LABEL); - const gatedPrs = delivering.filter((pr) => labelNames(pr).includes(CONTRACT_REVIEW_LABEL)); - const barePrs = delivering.filter((pr) => !labelNames(pr).includes(CONTRACT_REVIEW_LABEL)); + // The population split the header argues for (#18229): only a closing keyword + // makes a landing that reaches THIS card, so only those PRs are a carrier pair + // with it. The rest are reported below, never judged, and never dropped. + const judged = delivering.filter((pr) => bindingClosesCard(pr, n)); + const declined = delivering.filter((pr) => !bindingClosesCard(pr, n)); + const gatedIn = (prs) => prs.filter((pr) => labelNames(pr).includes(CONTRACT_REVIEW_LABEL)); + const bareIn = (prs) => prs.filter((pr) => !labelNames(pr).includes(CONTRACT_REVIEW_LABEL)); + const gatedPrs = gatedIn(judged); + const barePrs = bareIn(judged); // With its evidence (#16706) — this row names a PR as DELIVERING the card, // and a reader clearing a gate off it needs to know whether that rests on a // closing keyword or on a `Part of` that sat mid-line. @@ -6107,7 +6205,33 @@ export function h31ContractReviewCarrierSplit(issue, openPrs) { `demonstrably still live one carrier over. ${contract}` ); } - return null; + // Nothing adjudicable, or the adjudicable pair agrees. What is left is the + // weak-bound half: a PR this sweep believes delivers the card on evidence + // that does not close it. A DIFFERENCE there is still reportable — silence + // would render it identically to agreement, which is the one confusion this + // row exists to end — but it is not adjudicable, so the row says exactly that + // and asks for no write. Agreement here stays silent, as it was before. + const unjudgeable = cardGated ? bareIn(declined) : gatedIn(declined); + if (unjudgeable.length === 0) return null; + const anchorClause = labelNames(issue ?? {}).includes(TRACKING_ANCHOR_LABEL) + ? ` The card carries \`${TRACKING_ANCHOR_LABEL}\` — a ruling-anchor state that stays OPEN by design, the ` + + 'same property that keeps a `Restart-when: closed …#N` exit from ever firing on one — so no PR will ' + + 'close it, a gate hung here would never be reached by the stroke that clears gates, and it would sit ' + + 'as a permanent blocker on a card whose sub-PRs are gated correctly one level down. ' + : ' '; + const direction = cardGated + ? `the CARD carries \`${CONTRACT_REVIEW_LABEL}\` while the open PR ${list(unjudgeable)} does NOT` + : `\`${CONTRACT_REVIEW_LABEL}\` is on the open PR ${list(unjudgeable)} while the CARD does NOT carry it`; + return ( + `${direction} — and this row DECLINES to judge that pair, which is NOT the same as reporting it ` + + 'clean. The binding between the two is the one printed beside the PR number, and it is not a closing ' + + 'keyword: a `Part of` line declares MEMBERSHIP in what this card tracks and a branch-name fallback ' + + 'declares nothing at all, so neither says this PR\'s landing closes this card — and the gate\'s clearing ' + + `stroke rides that landing.${anchorClause}⛔ The row prescribes NOTHING: do not hang the gate on the ` + + 'card and do not clear it off the PR on the strength of it. What is owed first is a reading of the PR ' + + 'body — decide which card that PR actually delivers; only if a real half-write is behind it does anyone ' + + `write a carrier, and then it is the review's owner writing both in one stroke. ${contract}` + ); } // --------------------------------------------------------------------------- @@ -25803,7 +25927,8 @@ async function selfTest() { t('H31: a PR row whose labels could not be read is not judged as bare', h31ContractReviewCarrierSplit(gateCard([CONTRACT_REVIEW_LABEL]), [{ ...bare, labels: undefined }]), null); t('H31: …and one readable bare PR alongside it still fires', typeof h31ContractReviewCarrierSplit(gateCard([CONTRACT_REVIEW_LABEL]), [{ ...bare, labels: undefined }, gatePr(11845, [])]), 'string'); // The delivery relation is H8's, shared rather than re-derived. - t('H31: the branch-name fallback delivers a body-silent PR', typeof h31ContractReviewCarrierSplit(gateCard([CONTRACT_REVIEW_LABEL]), [{ ...bare, body: '' }]), 'string'); + t('H31: the branch-name fallback still produces a row for a body-silent PR', typeof h31ContractReviewCarrierSplit(gateCard([CONTRACT_REVIEW_LABEL]), [{ ...bare, body: '' }]), 'string'); + t('H31: …but it is the DECLINED one — a body that declared nothing does not close the card (#18229)', says(h31ContractReviewCarrierSplit(gateCard([CONTRACT_REVIEW_LABEL]), [{ ...bare, body: '' }]), 'DECLINES to judge'), true); t('H31: …and H8 reads the same PR as delivering the same card', prDeliversCard({ ...bare, body: '' }, '11427'), true); t('H31: a CLOSED card is out of scope', h31ContractReviewCarrierSplit(gateCard([CONTRACT_REVIEW_LABEL], { state: 'closed' }), [bare]), null); t('H31: a missing issue does not crash', h31ContractReviewCarrierSplit(undefined, [bare]), null); @@ -25825,6 +25950,89 @@ async function selfTest() { // all — the shape this row deliberately does NOT report. t('H31 live: #10025 (gated, no PR carrier yet) -> clean', h31ContractReviewCarrierSplit({ ...gateCard(['domain:services', 'pm:blocked', CONTRACT_REVIEW_LABEL]), number: 10025 }, [live11844]), null); + // -- H31: which binding makes a CARRIER PAIR (#18229) ----------------------- + // The defect, byte-shaped from anchor #9857's 2026-09-14T19:45Z sweep: PR + // #18212 carries `Fixes #18202` (its own card, gated) AND a `Part of #14122` + // line naming the epic tracker, and H31 read the second as a delivery — so a + // correctly gated sub-PR manufactured an action-shaped row against a card no + // PR will ever close. + const pr18212 = (labels = ['documentation', 'size/l', 'tests', 'tooling', CONTRACT_REVIEW_LABEL]) => ({ + number: 18212, + merged_at: null, + draft: true, + body: + 'Fixes #18202\n\nPart of #14122 — the epic tracking the one-artifact/N-packages family. ' + + 'That tracker stays open.', + head: { ref: 'claude/issue-18202-crossref-dependency-aware' }, + labels: labels.map((name) => ({ name })), + }); + const card18229 = (number, labels) => ({ + number, + state: 'open', + labels: labels.map((name) => ({ name })), + assignees: [], + body: '', + title: '', + }); + const tracker14122 = card18229(14122, ['priority:p2', TRACKING_ANCHOR_LABEL, 'domain:spec']); + const delivered18202 = card18229(18202, ['bug', 'priority:p1', 'pm:dispatched', 'domain:spec']); + + // The binding predicate itself, both directions on ONE body. + t('#18229: a closing keyword closes the card it names', bindingClosesCard(pr18212(), '18202'), true); + t('#18229: a `Part of` line on the SAME body does not', bindingClosesCard(pr18212(), '14122'), false); + t('#18229: nor an inline `Part of`', bindingClosesCard({ number: 1, body: 'see part of #7918 above' }, '7918'), false); + t('#18229: nor the branch-name fallback', bindingClosesCard({ number: 1, body: '', head: { ref: 'claude/issue-9834-x' } }, '9834'), false); + t('#18229: a keyword outranks an inline `Part of` for the SAME card, exactly as GitHub does', bindingClosesCard({ number: 1, body: 'Fixes #7918\n\nsee part of #7918 above' }, '7918'), true); + t('#18229: a PR bound to no card at all closes nothing', bindingClosesCard({ number: 1, body: 'no declaration' }, '9999'), false); + t('#18229: a missing PR does not crash', bindingClosesCard(undefined, '1'), false); + // ⛔ The SHARED relation is untouched — the narrowing is H31's own filter. + t('#18229: ⛔ `prDeliversCard` is NOT narrowed — the tracker is still a delivery to H8/H35/H53', prDeliversCard(pr18212(), '14122'), true); + t('#18229: …and the evidence kind H8 prints is unchanged', deliveryEvidence(pr18212(), '14122'), 'part-of'); + + // The PIN: the tracker's row no longer prescribes a write. + const row14122 = h31ContractReviewCarrierSplit(tracker14122, [pr18212()]); + t('#18229 pin: the `Part of`-only tracker still produces a row — ⛔ not a silent skip', typeof row14122, 'string'); + t('#18229 pin: …and the row says it DECLINED rather than reporting clean', says(row14122, 'DECLINES to judge'), true); + t('#18229 pin: …naming the binding it read', says(row14122, '#18212 (draft, via a `Part of` declaration)'), true); + t('#18229 pin: …and why a tracker can never clear a gate', says(row14122, 'ruling-anchor state that stays OPEN by design'), true); + t('#18229 pin: …the row prescribes NOTHING', says(row14122, 'prescribes NOTHING'), true); + t('#18229 pin: …it is ⛔ NOT the action-shaped sentence any more', says(row14122, 'more dangerous half'), false); + t('#18229 pin: …nor does it ask anyone to hang the gate on the card', says(row14122, 'do not hang the gate on the'), true); + t('#18229 pin: a decline is not a LOUD row', isLoudFinding(row14122), false); + t('#18229 pin: …and ⛔ not an UNJUDGED-ranked one either — it must never sort ahead of a real split', isUnjudgedFinding(row14122), false); + t('#18229 pin: the decline still carries the dual-carrier contract', says(row14122, 'READ-BACK'), true); + + // The CONTROL: the card that PR actually closes is judged exactly as before. + const row18202 = h31ContractReviewCarrierSplit(delivered18202, [pr18212()]); + t('#18229 control: a closing-keyword binding whose card lacks the gate — the row STANDS', typeof row18202, 'string'); + t('#18229 control: …and it is the unchanged action-shaped sentence', says(row18202, 'more dangerous half'), true); + t('#18229 control: …which is ⛔ not a decline', says(row18202, 'DECLINES to judge'), false); + t('#18229 control: …and the gated card half agrees -> clean', h31ContractReviewCarrierSplit(card(18202, ['bug', CONTRACT_REVIEW_LABEL]), [pr18212()]), null); + + // Agreement on a weak binding stays SILENT — a standing row per tracker per + // sweep is the disease this fix cures, not the cure. + t('#18229: weak binding, both carriers gated -> clean', h31ContractReviewCarrierSplit(card(14122, [TRACKING_ANCHOR_LABEL, CONTRACT_REVIEW_LABEL]), [pr18212()]), null); + t('#18229: weak binding, neither carrier gated -> clean', h31ContractReviewCarrierSplit(tracker14122, [pr18212(['documentation', 'size/l'])]), null); + + // The decline is about the BINDING; the anchor clause is the extra the label + // buys. A weak-bound ordinary card still reports, without that clause. + const weakOrdinary = h31ContractReviewCarrierSplit(card18229(14122, ['priority:p2']), [pr18212()]); + t('#18229: a weak-bound card with NO `tracking` still reports the split', says(weakOrdinary, 'DECLINES to judge'), true); + t('#18229: …without the ruling-anchor clause, which the label is what buys', says(weakOrdinary, 'ruling-anchor state'), false); + + // Precedence: an adjudicable split outranks a decline, and names only the PR + // that can actually close the card. + const mixedCard18229 = card18229(11427, ['pm:dispatched']); + const mixed18229 = h31ContractReviewCarrierSplit(mixedCard18229, [gatePr(11844, [CONTRACT_REVIEW_LABEL]), { ...pr18212(), body: 'Part of #11427' }]); + t('#18229 precedence: a real carrier split wins over a decline', says(mixed18229, 'more dangerous half'), true); + t('#18229 precedence: …and names the closing-bound PR', says(mixed18229, '#11844'), true); + t('#18229 precedence: …⛔ never the `Part of`-bound one', says(mixed18229, '#18212'), false); + + // One spelling of the ruling-anchor state, two readers (H13's exemption list + // and H31's clause) — a second literal is the drift this constant prevents. + t('#18229: the ruling-anchor label has ONE spelling', TRACKING_ANCHOR_LABEL, 'tracking'); + t("#18229: …and H13's exemption list reads that same constant", H13_EXEMPT_LABELS.includes(TRACKING_ANCHOR_LABEL), true); + // -- The window arithmetic (#11118) ---------------------------------------- // The derivation is executable so that a cap and the sentence justifying it // cannot drift apart again: H8's docblock quoted `~18 merges/day` while the