diff --git a/trios/.trinity/loop/close-done.mjs b/trios/.trinity/loop/close-done.mjs index 36f3bced2a..b8220e2816 100644 --- a/trios/.trinity/loop/close-done.mjs +++ b/trios/.trinity/loop/close-done.mjs @@ -30,6 +30,7 @@ import { fileURLToPath } from 'node:url' const DIR = path.dirname(fileURLToPath(import.meta.url)) const L = await import(path.join(DIR, 'loop.mjs')) +const LAND = await import(path.join(DIR, 'land.mjs')) const { shq } = L // IMPORT-SAFE. This module ran its production query and called process.exit at @@ -130,9 +131,16 @@ for (const r of rows) { // change the base AT ALL? Ancestry says no for ever after a squash, and a // three-dot diff measures from the divergence point rather than from what the // base holds now. Comparing the merged tree with the base tree is route-blind. - const baseTree = tryShell(`git rev-parse ${BASE}^{tree}`) - const mergedTree = tryShell(`git merge-tree --write-tree ${BASE} ${branch}`) - const landed = baseTree && mergedTree && mergedTree.split('\n')[0].trim() === baseTree + // ONE RULE, IN ONE PLACE. This file carried its own copy of the tree test for + // weeks while `land.mjs` grew four more routes it never learned: ancestry, a + // drifted cherry-pick found by patch-id, and - the one that jammed the + // pipeline on 2026-09-05 - a change re-cut onto a fresh base and squash-merged + // under a different branch, which no comparison of bytes can see at all. + // + // Nine branches conflicted, four of them finished work. `land.mjs` learned to + // recognise them and this file went on refusing to close their issues, because + // a rule transcribed twice is two rules that agree until somebody edits one. + const landed = LAND.isLanded(String(branch).replace(/^origin\//, '')) if (!landed) { plan.push({ n, skip: `work is on ${r.branch || `queen-${n}`} but NOT in ${BASE} - run \`tri land --land\` first; closing now would be a false statement` }) continue diff --git a/trios/.trinity/loop/land.mjs b/trios/.trinity/loop/land.mjs index 970778da15..b284637b5b 100644 --- a/trios/.trinity/loop/land.mjs +++ b/trios/.trinity/loop/land.mjs @@ -66,6 +66,66 @@ const sh = (c, opts = {}) => { * catches a squash, a cherry-pick, and a change somebody applied by hand - all * of which leave the branch looking unmerged for ever. */ + +/** + * THE FIFTH ROUTE: the base's own history says it closed this issue. + * + * Every content-based route above compares BYTES - a tree, a patch-id, an + * ancestry. All four are blind to the one thing this loop does constantly: + * when a bee's branch goes stale, I re-cut its change against the current base + * and squash-merge THAT. The carry is a new commit with a new tree and a new + * patch-id, so nothing content-shaped can connect it back, and the bee's + * original branch becomes permanent debt - re-offered every round, conflicting + * every round, holding its issue open and its boundary fenced for ever. + * + * Measured 2026-09-05 on the nine branches that had jammed the pipeline: + * #1310 landed as PR #330, #1308 as PR #331, #1362 carries `Closes #1362` in a + * base commit. Three of nine were finished work that no content route could see. + * + * So read the message. L1 of this repository is "no code merged without + * `Closes #N`", which makes the message a load-bearing record rather than a + * courtesy - and the squash subject convention `(#N)` says the same thing. + * + * THE MATCHING IS DONE IN JAVASCRIPT, not in git's regex. Two rounds ago a BRE + * read as a JavaScript regex convicted a bee; the lesson is to keep the dialect + * somewhere it is known. git is asked the loose question and the boundary is + * checked here. + */ +export function closedInBase(branch, run = sh) { + const m = String(branch).match(/queen-(\d+)$/) + if (!m) return false + const n = m[1] + const raw = run(`git log origin/${BASE} --format=%B%x1e --grep=${JSON.stringify('#' + n)} -i`) + if (!raw) return false + const closes = new RegExp(`(?:closes|fixes|resolves)\\s+#${n}(?![0-9])`, 'i') + // `(#N)` is how a squash subject records the issue it came from, and it must + // END A LINE, which is what makes it a subject rather than a mention. The + // loose version would match "unlike (#1421), this does X" in any paragraph. + // + // In THIS repository issue numbers are four digits and pull-request numbers + // three, so `(#1310)` cannot be a PR reference - a repo-specific fact, stated + // because it is not a general one. + // + // ...ALLOWING THE REFERENCES THAT FOLLOW IT. A squash subject here reads + // `feat(queen): explain idle paid slots (#1310) (#330)` - the issue first, + // then the pull request that merged it. Demanding `(#N)` at the very end of + // the line rejected exactly the case this route was written for, which I + // discovered by tightening the rule and watching #1310 stop being recognised + // one minute later. A trailing CHAIN of parenthesised references is a + // subject; a parenthesis in the middle of a sentence is not. + const subject = new RegExp(`\\(#${n}\\)(?:\\s*\\(#\\d+\\))*\\s*$`, 'm') + // And the phrasing my own carry commits use, which is the case this route + // exists for: the change was re-cut onto a fresh base and merged under a + // different branch, saying so in words because no byte-comparison can. + const carried = new RegExp(`carr(?:y|ies|ied)\\s+(?:the\\s+)?#${n}(?![0-9])`, 'i') + for (const message of raw.split('\x1e')) { + if (closes.test(message)) return true + if (carried.test(message)) return true + if (Number(n) >= 1000 && subject.test(message)) return true + } + return false +} + export function isLanded(branch) { if (sh(`git merge-base --is-ancestor origin/${branch} origin/${BASE} && echo yes`) === 'yes') return true // THE EXACT QUESTION: would merging this change the base at all? @@ -102,9 +162,13 @@ export function isLanded(branch) { // A branch with no commits left unaccounted for has landed, whatever route it // took. An empty answer from `git cherry` is not evidence of anything. const cherry = sh(`git cherry origin/${BASE} origin/${branch}`) - if (cherry === null) return false - const unaccounted = cherry.split('\n').filter((l) => l.trim().startsWith('+')) - return cherry.trim() !== '' && unaccounted.length === 0 + if (cherry !== null) { + const unaccounted = cherry.split('\n').filter((l) => l.trim().startsWith('+')) + if (cherry.trim() !== '' && unaccounted.length === 0) return true + } + + // ...and the route no comparison of bytes can reach. + return closedInBase(branch) } export function mergesCleanly(branch) { diff --git a/trios/.trinity/loop/selftest.mjs b/trios/.trinity/loop/selftest.mjs index 07743de451..77981bf848 100644 --- a/trios/.trinity/loop/selftest.mjs +++ b/trios/.trinity/loop/selftest.mjs @@ -1170,8 +1170,15 @@ check('close-done demands LANDED, not merely pushed', () => { // the remote" is what manufactured 169 closed issues whose code was outside // the base. const src = fs.readFileSync(path.join(DIR, 'close-done.mjs'), 'utf8') - if (!/merge-tree --write-tree/.test(src)) { - throw new Error('it must ask whether merging would change the base at all') + // The rule moved into land.mjs so there is exactly one of it. The demand is + // unchanged and is now asserted where it lives: close-done must ASK, and the + // answer must still include "would merging change the base at all". + if (!/LAND\.isLanded/.test(src)) { + throw new Error('it must ask the landing rule rather than close on "pushed"') + } + const land = fs.readFileSync(path.join(DIR, 'land.mjs'), 'utf8') + if (!/merge-tree --write-tree/.test(land)) { + throw new Error('the rule it asks must still ask whether merging would change the base at all') } if (!/NOT in/.test(src)) throw new Error('the refusal must say the work is not in the base') if (!/false statement/.test(src)) throw new Error('and why that matters') @@ -1451,8 +1458,13 @@ check('landed knows the fourth route: patch-id equivalence', () => { check('an unreadable cherry answer is not evidence of landing', () => { const code = codeOf('land.mjs') const fn = code.slice(code.indexOf('export function isLanded'), code.indexOf('export function mergesCleanly')) - if (!/cherry === null\) return false/.test(fn)) throw new Error('unreadable is not landed') + // The route now falls through to closedInBase rather than returning, so the + // assertion is about what an unreadable answer may NOT do: it may not become + // a "yes" from this route. + if (!/cherry !== null/.test(fn)) throw new Error('an unreadable cherry answer must not be read by this route at all') + if (/cherry === null[\s\S]{0,40}return true/.test(fn)) throw new Error('unreadable is never landed') if (!/cherry\.trim\(\) !== ''/.test(fn)) throw new Error('an EMPTY answer must not read as "no unaccounted commits"') + if (!/closedInBase\(branch\)/.test(fn)) throw new Error('the fifth route is a different question, asked separately') }) check('a section is read from its heading, not from the first mention of it', async () => { @@ -1782,6 +1794,41 @@ check('the audits do not pay for the freeing steps', () => { if (!/reportPhaseStartedAt/.test(code)) throw new Error('the report budget starts when the report phase does, not when the chain did') }) +check('a carried change is landed, and a mention of it is not', async () => { + const { closedInBase } = await import('./land.mjs') + // Every content route compares bytes. A carry is re-cut onto a fresh base and + // squash-merged, so it has a new tree and a new patch-id and nothing + // byte-shaped can connect it back. Three of the nine branches jamming the + // pipeline on 2026-09-05 were finished work in exactly this state. + const msg = (s) => () => s + if (!closedInBase('queen-1310', msg('feat: something\n\nCloses #1310\n'))) throw new Error('Closes is L1 of this repo and must count') + if (!closedInBase('queen-1310', msg('feat(queen): explain idle paid slots (#1310)\n'))) throw new Error('a squash subject records the issue') + // The real shape: the issue, then the pull request that merged it. Demanding + // (#N) at the very end of the line rejected the exact case this exists for. + if (!closedInBase('queen-1310', msg('feat(queen): explain idle paid slots (#1310) (#330)\n'))) { + throw new Error('a trailing chain of references is still a subject') + } + if (!closedInBase('queen-1308', msg('feat(queen): carry #1308 onto the supervisor base\n'))) throw new Error('a carry says so in words') + // ...and the mentions that must NOT count. + if (closedInBase('queen-1421', msg('unlike (#1421), this one does something else\n'))) { + throw new Error('a parenthetical mid-sentence is not a squash subject') + } + if (closedInBase('queen-1310', msg('this is unrelated to #1310 and does not close it\n'))) { + throw new Error('a bare mention closes nothing') + } + if (closedInBase('queen-131', msg('Closes #1310\n'))) throw new Error('#131 is not #1310') + if (closedInBase('not-a-queen-branch', msg('Closes #1310\n'))) throw new Error('only a queen-N branch has an issue number') +}) + +check('close-done and land agree about what landed, because it is one rule', () => { + const code = codeOf('close-done.mjs') + // This file carried its own copy of the tree test while land.mjs grew four + // more routes it never learned. Nine branches conflicted, four were finished + // work, and close-done went on refusing to close their issues. + if (!/LAND\.isLanded/.test(code)) throw new Error('the landing rule lives in land.mjs and is asked, not re-implemented') + if (/const landed = baseTree && mergedTree/.test(code)) throw new Error('the second copy of the rule must be gone, not merely bypassed') +}) + check('the harness can fail an async check', async () => { // Guarding the fix above: before it, this file reported 0 failures while an // async case was rejecting into the void.