Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions trios/.trinity/loop/close-done.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
70 changes: 67 additions & 3 deletions trios/.trinity/loop/land.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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) {
Expand Down
53 changes: 50 additions & 3 deletions trios/.trinity/loop/selftest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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.
Expand Down
Loading