|
59 | 59 | * one names another: H46 calls that claimed and so does this gate. That is the |
60 | 60 | * permissive direction — a blocking gate erring toward not-blocking on an |
61 | 61 | * ambiguous thread — and it is the no-fork property, not an accident. |
| 62 | + * |
| 63 | + * Both readings skip a `Claim:` its OWN session later yielded, through the one |
| 64 | + * recogniser `yieldedClaimIndexes` exports beside them (its header carries the |
| 65 | + * three conditions). The race re-read rule makes the loser yield in a comment, |
| 66 | + * never by deleting its claim, so the loser's claim is always the NEWER one; |
| 67 | + * read as current, it made this gate red on the WINNER's PR, which went green |
| 68 | + * only after the winner re-posted its claim by hand. Skipped, the loser's |
| 69 | + * branch is no longer claimed and the winner's claim is the one judged. |
62 | 70 | * - the merge-queue ref reading is `pullNumberFromQueueRef`, the governed |
63 | 71 | * queue guard's, which already carries the `release/v5`-style base-branch |
64 | 72 | * lesson its own header records. |
@@ -281,7 +289,7 @@ import { maskCommentsAndLiterals } from './js-comment-mask.mjs'; |
281 | 289 | // about whether this container's fetch reaches GitHub at all. Only the guard |
282 | 290 | // VARIABLE below is this file's own, and the block above `rearmThroughProxy` |
283 | 291 | // says why. |
284 | | -import { claimGovernance, closingKeywordTargets, EXIT_PREREQUISITE_NOT_MET, h46ClaimNamesBranch, PROXY_FLAG, PROXY_REARM_GUARD, proxyRearmPlan } from './pm/check-half-states.mjs'; |
| 292 | +import { claimGovernance, closingKeywordTargets, EXIT_PREREQUISITE_NOT_MET, h46ClaimNamesBranch, PROXY_FLAG, PROXY_REARM_GUARD, proxyRearmPlan, yieldedClaimIndexes } from './pm/check-half-states.mjs'; |
285 | 293 | import { pullNumberFromQueueRef } from './pm/check-governed-queue-guard.mjs'; |
286 | 294 |
|
287 | 295 | /** This file, resolved once, so the re-exec below hands off to THIS script and not to an argv guess. */ |
@@ -314,11 +322,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ |
314 | 322 | 'A transport failure SPEAKS, and is held apart from UNDETERMINED. Both used': 16, |
315 | 323 | 'The wiring itself. A gate whose workflow step is deleted or whose': 10, |
316 | 324 | 'The predicate sources this gate reuses must still be there to reuse.': 3, |
| 325 | + 'A claim its own session later YIELDED is not the card\'s claim. The': 16, |
317 | 326 | }); |
318 | 327 |
|
319 | 328 | // DELETING an entry silences that battery's floor exactly as effectively as |
320 | 329 | // zeroing it, so the roster's own size is pinned too. |
321 | | -const SELF_TEST_BATTERY_FLOOR = 12; |
| 330 | +const SELF_TEST_BATTERY_FLOOR = 13; |
322 | 331 |
|
323 | 332 | // The key an assertion is filed under when no battery is open. It is not a |
324 | 333 | // declared battery, so it reds by the same set difference rather than silently |
@@ -618,8 +627,9 @@ export function judge(ctx) { |
618 | 627 |
|
619 | 628 | const rows = unclaimed.map((t) => |
620 | 629 | (t.names ?? []).length === 0 |
621 | | - ? ` - \`${t.keyword} #${t.number}\` — no comment on #${t.number} is a \`Claim:\` at all, so the card ` + |
622 | | - `records no owner and nothing on it would have stopped a second seat.` |
| 630 | + ? ` - \`${t.keyword} #${t.number}\` — no comment on #${t.number} is a \`Claim:\` at all (a claim its own ` + |
| 631 | + `session later yielded does not count), so the card records no owner and nothing on it would have ` + |
| 632 | + `stopped a second seat.` |
623 | 633 | : ` - \`${t.keyword} #${t.number}\` — the governing \`Claim:\` on #${t.number} names ` + |
624 | 634 | `${(t.names ?? []).map((b) => `\`${b}\``).join(', ')}, not \`${ctx.head}\`.`, |
625 | 635 | ); |
@@ -1245,6 +1255,66 @@ async function selfTest() { |
1245 | 1255 | const wiringCommands = wiring.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n'); |
1246 | 1256 | t('the guard job invokes no package manager (it needs node and nothing else)', /\b(pnpm|corepack|yarn|npm)\b/.test(wiringCommands), false); |
1247 | 1257 |
|
| 1258 | + // --- A claim its own session later YIELDED is not the card's claim. The |
| 1259 | + // measured race: seat 1 claims, seat 2 claims the same card minutes later, |
| 1260 | + // re-reads the thread and yields in a comment, so the loser's claim is the |
| 1261 | + // NEWER one. Read as current it made this gate red on the winner's PR. |
| 1262 | + battery('A claim its own session later YIELDED is not the card\'s claim. The'); |
| 1263 | + const WIN = `session_01${'W'.repeat(22)}`; |
| 1264 | + const LOSE = `session_01${'L'.repeat(22)}`; |
| 1265 | + const THREAD_BRANCH = 'claude/project-thread-fixture'; |
| 1266 | + const claimBy = (session, ref, iso) => ({ |
| 1267 | + body: `Claim: race fixture\nSession: \`${session}\` (seat)\nBranch: \`${ref}\``, |
| 1268 | + created_at: iso, |
| 1269 | + }); |
| 1270 | + // The yield's BODY names the winner and its session, as the measured one |
| 1271 | + // does; only its first line may say who stepped back. |
| 1272 | + const yieldBy = (firstLine, iso) => ({ |
| 1273 | + body: `${firstLine}\n\nClaim (\`${WIN}\`) precedes this seat's claim; the earlier claim decides.`, |
| 1274 | + created_at: iso, |
| 1275 | + }); |
| 1276 | + const lostBy = (session) => yieldBy(`already claimed — yielding · seat 2 · \`${session}\` · 10:00Z`, '2026-01-01T10:01:00Z'); |
| 1277 | + const claimA = (ref = HEAD) => claimBy(WIN, ref, '2026-01-01T09:47:00Z'); |
| 1278 | + const claimB = claimBy(LOSE, OTHER, '2026-01-01T09:54:00Z'); |
| 1279 | + const RACE = [claimA(), claimB, lostBy(LOSE)]; |
| 1280 | + const raceRoutes = (rows) => ({ |
| 1281 | + '/repos/o/r/issues/15845/comments': rows, |
| 1282 | + '/repos/o/r/issues/15845': { number: 15845, state: 'open' }, |
| 1283 | + }); |
| 1284 | + const raced = (rows, head) => run('Closes #15845', raceRoutes(rows), { head }); |
| 1285 | + const winner = await raced(RACE, HEAD); |
| 1286 | + t('the WINNER\'s PR is CLEAN on a thread whose newest claim was yielded', winner.verdict.exit, EXIT_CLEAN); |
| 1287 | + t('…and costs one comment page, the clean path\'s bound', winner.calls.length, 1); |
| 1288 | + const loser = await raced(RACE, OTHER); |
| 1289 | + t('the LOSER\'s PR is a FINDING — a yielded claim names no branch', loser.verdict.exit, EXIT_UNCLAIMED); |
| 1290 | + t('…and the failure names the WINNER\'s branch as the governing claim', text(loser.verdict).includes(`names \`${HEAD}\``), true); |
| 1291 | + const threadRace = [claimA(THREAD_BRANCH), claimB, lostBy(LOSE)]; |
| 1292 | + const threadWinner = await raced(threadRace, THREAD_BRANCH); |
| 1293 | + t('a winner on a card-thread branch is no longer RED (the measured specimen)', threadWinner.verdict.exit, EXIT_CLEAN); |
| 1294 | + t('…it reads UNDETERMINED, warned about, because its branch is not one the reader parses', text(threadWinner.verdict).includes('::warning::UNDETERMINED'), true); |
| 1295 | + t('…and the loser\'s PR on that thread is no longer read as CLAIMED', (await raced(threadRace, OTHER)).collected.targets[0].verdict !== TARGET_CLAIMED, true); |
| 1296 | + const noSession = [claimA(), claimB, yieldBy('already claimed — yielding · seat 2', '2026-01-01T10:01:00Z')]; |
| 1297 | + t('a yield naming NO session withdraws nothing — today\'s reading holds', (await raced(noSession, OTHER)).verdict.exit, EXIT_CLEAN); |
| 1298 | + const twoSessions = [claimA(), claimB, yieldBy(`already claimed — yielding · \`${LOSE}\` to \`${WIN}\``, '2026-01-01T10:01:00Z')]; |
| 1299 | + t('a yield naming TWO sessions says nobody in particular stepped back — today\'s reading holds', (await raced(twoSessions, OTHER)).verdict.exit, EXIT_CLEAN); |
| 1300 | + t('a yield by ANOTHER session does not withdraw this session\'s claim', (await raced([claimA(), claimB, lostBy(WIN)], OTHER)).verdict.exit, EXIT_CLEAN); |
| 1301 | + const reclaimed = [...RACE, claimBy(LOSE, OTHER, '2026-01-01T11:00:00Z')]; |
| 1302 | + t('a `Claim:` the yielding session posts AFTER its yield stands', (await raced(reclaimed, OTHER)).verdict.exit, EXIT_CLEAN); |
| 1303 | + const yieldFirst = [lostBy(LOSE), claimBy(LOSE, OTHER, '2026-01-01T11:00:00Z')]; |
| 1304 | + t('a yield withdraws only claims that PRECEDE it', (await raced(yieldFirst, OTHER)).verdict.exit, EXIT_CLEAN); |
| 1305 | + const hyphen = [claimA(), claimB, yieldBy(`already claimed - yielding · \`${LOSE}\``, '2026-01-01T10:01:00Z')]; |
| 1306 | + t('a hyphen-written yield is no dialect — today\'s reading holds', (await raced(hyphen, OTHER)).verdict.exit, EXIT_CLEAN); |
| 1307 | + t('the recogniser is the shipped one, and over the race it withdraws the loser\'s claim alone', [...yieldedClaimIndexes(RACE)], [1]); |
| 1308 | + t('…not the winner\'s, whose session only the yield\'s BODY names', yieldedClaimIndexes(RACE).has(0), false); |
| 1309 | + const yieldFixtures = [[RACE, HEAD], [RACE, OTHER], [threadRace, OTHER], [noSession, OTHER], [reclaimed, OTHER], [yieldFirst, OTHER]]; |
| 1310 | + const yieldDelegated = []; |
| 1311 | + for (const [rows, head] of yieldFixtures) yieldDelegated.push((await raced(rows, head)).collected.targets[0].verdict === TARGET_CLAIMED); |
| 1312 | + t( |
| 1313 | + 'over every yield fixture, CLAIMED is exactly the shipped `h46ClaimNamesBranch` (no forked predicate)', |
| 1314 | + yieldDelegated, |
| 1315 | + yieldFixtures.map(([rows, head]) => h46ClaimNamesBranch(rows.map((row) => row.body), head)), |
| 1316 | + ); |
| 1317 | + |
1248 | 1318 | // --- The predicate sources this gate reuses must still be there to reuse. |
1249 | 1319 | battery('The predicate sources this gate reuses must still be there to reuse.'); |
1250 | 1320 | for (const source of PREDICATE_SOURCES) t(`the predicate source ${source} exists`, existsSync(join(ROOT, source)), true); |
|
0 commit comments