Skip to content

Commit ae7b7f1

Browse files
claude[bot]claude
andauthored
tooling(pm): split the clause-② declaration limb's NO READING into two readings (#15735)
The declaration limb returned one `absent` state for two different facts: a card whose thread carries no claim comment at all, and a card whose claim comment carries no `Clause-②:` line. One sentence, one count, and two different things owed -- a report could say "15 NO READING" about two populations and a seat reading its own row could not tell which remedy was being asked for. `cardDeclaration` now returns `absent` (no comment on the thread is a claim comment -- no line beginning `Claim:`) and `missing` (a claim comment is there and carries no declaration line) as two members of the same enum, under the same exit code the limb has always returned. `c2DeclarationUnreadable` writes a sentence per state: the ABSENT one names the fixed first-line spelling as its remedy and says in one clause that a heading-style claim is not a claim comment to this predicate; the MISSING one sends the remedy to the claim comment already on the thread. The sweep's per-repo summary counts them as two numbers under two labels, from the same reader the rows use, and `--json` carries the same pair. `CLAIM_COMMENT_MARKER` is unchanged -- it is imported from `check-half-states.mjs` so the two readers cannot drift, and the spelling it accepts is a maintainer ruling. No exit code changes. Self-test: 174 -> 190 cases. New cases cover a prose-only thread (ABSENT), a `## Claim --` heading thread (ABSENT, with the remedy sentence), a `Claim:` comment with no line (MISSING), a `Claim:` comment carrying the key only inside prose (MISSING, never readable -- the reader is line-anchored), and the summary tally over all of them. Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5b0c779 commit ae7b7f1

1 file changed

Lines changed: 133 additions & 25 deletions

File tree

scripts/pm/check-clause2-carriers.mjs

Lines changed: 133 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,9 @@ import {
298298
// remedy is to find what stopped registering.
299299
const SELF_TEST_BATTERIES = Object.freeze({
300300
'the declaration reader: the fixed spelling, and everything that is not': 15,
301-
'the card-level declaration: four states, none collapsed into another': 9,
301+
'the card-level declaration: every state, none collapsed into another': 13,
302302
'C1, replaying the 2026-08-31 measured table': 12,
303-
'C2, the row this file exists for': 14,
303+
'C2, the row this file exists for': 26,
304304
'C3, the direction a carrier comparison cannot see': 7,
305305
'#14155: the COMPLETED state, and the three it must stay distinct from': 18,
306306
'the event reader itself': 9,
@@ -439,9 +439,25 @@ export function readClause2Line(text) {
439439
* as `absent` would blame the wrong thing, and reporting it as `declared` would
440440
* bless a carrier the gate cannot use.
441441
*
442+
* ⭐ "Not read" is TWO facts, and they are returned as two states because the
443+
* remedies differ and one count over both describes neither. `absent` — no
444+
* comment on the thread is a claim comment at all, so the carrier this limb
445+
* reads does not exist and no line could have been read from it; what is owed
446+
* is the claim comment. `missing` — a claim comment IS there and carries no
447+
* declaration line; the carrier exists and the line is what is owed.
448+
*
449+
* The predicate that separates them is `CLAIM_COMMENT_MARKER`, imported rather
450+
* than restated: a claim comment is one whose body carries a LINE BEGINNING
451+
* `Claim:` (or `Claimed:`, optionally blockquoted), and that one spelling is
452+
* the whole set — so a heading-style claim (`## Claim — …`) is not a claim
453+
* comment here, however complete the reasoning under it, and its thread reads
454+
* `absent`. ⛔ Widening the predicate is not this file's to do: it is the
455+
* sibling's constant precisely so the two readers cannot drift, and the remedy
456+
* for a thread that reads `absent` is a comment in the fixed spelling.
457+
*
442458
* @param {{ body?: string, created_at?: string }[]|null} commentRows — the REST
443459
* comment rows, or `null` when the thread could NOT be read.
444-
* @returns {{ state: 'declared'|'malformed'|'misplaced'|'absent'|'unreadable',
460+
* @returns {{ state: 'declared'|'malformed'|'misplaced'|'missing'|'absent'|'unreadable',
445461
* value?: 'yes'|'no', detail?: string }}
446462
*/
447463
export function cardDeclaration(commentRows) {
@@ -476,7 +492,11 @@ export function cardDeclaration(commentRows) {
476492
if (read?.kind === 'near-miss' && nearMiss === null) nearMiss = read;
477493
}
478494
if (malformed) return { state: 'malformed', detail: malformed.line };
479-
return { state: 'absent', detail: nearMiss?.line };
495+
// Which of the two not-read states this is, told apart by whether the carrier
496+
// exists at all. `claimRows` is non-empty exactly when some comment matched
497+
// the imported claim predicate, and `pool` is derived from it — so this asks
498+
// the same question the reading above asked and cannot answer it differently.
499+
return { state: claimRows.length > 0 ? 'missing' : 'absent', detail: nearMiss?.line };
480500
}
481501

482502
// ---------------------------------------------------------------------------
@@ -575,9 +595,19 @@ export function c1CarrierSplit(pair) {
575595
*
576596
* ⭐ The row this file exists for. A card that declared `no` made a decision; a
577597
* card with nothing to read made none, and until now the two looked the same to
578-
* every consumer. The sentence therefore leads with which of the four states
579-
* this is, and never prescribes a value: ⛔ nobody may fill the line in on
580-
* another seat's behalf, because the declaration IS the judgement.
598+
* every consumer. The sentence therefore leads with WHICH state this is, and
599+
* never prescribes a value: ⛔ nobody may fill the line in on another seat's
600+
* behalf, because the declaration IS the judgement.
601+
*
602+
* ⭐ "Nothing to read" is itself two readings, and each gets its own sentence
603+
* because each names a different remedy. A thread with no claim comment — no
604+
* comment carrying a line that BEGINS `Claim:`, which is the whole of the
605+
* predicate — has no carrier for the limb at all, and what it owes is that
606+
* comment; a heading-style claim (`## Claim — …`) is that case, not a claim
607+
* comment short of a line. A thread whose claim comment carries no
608+
* `Clause-②:` line has the carrier and owes the line. Reporting both as one
609+
* "no reading" told a seat neither which of the two it was in nor what to
610+
* write, and let a round report state one number about two different owings.
581611
*/
582612
export function c2DeclarationUnreadable(pair) {
583613
const d = cardDeclaration(pair?.cardComments ?? null);
@@ -609,12 +639,24 @@ export function c2DeclarationUnreadable(pair) {
609639
`but not one of the two values, so there is no reading. ${fixed}. ${notADecision} ` +
610640
`${NEVER_WRITES}`
611641
);
612-
default:
642+
case 'missing':
613643
return (
614-
`${head} — NO READING on the declaration limb: the card's claim comment carries no ` +
615-
`\`Clause-②:\` line in the fixed spelling` +
644+
`${head} — NO READING on the declaration limb, and the DECLARATION LINE is what is ` +
645+
`missing: the card's claim comment is there and carries no \`Clause-②:\` line in the ` +
646+
'fixed spelling' +
616647
(d.detail ? `, and the nearest thing on the thread is ${JSON.stringify(d.detail)}` : '') +
617-
`. ${fixed}. ${notADecision} ${NEVER_WRITES}`
648+
`. Remedy: add the line to that claim comment — ${fixed}. ${notADecision} ${NEVER_WRITES}`
649+
);
650+
default:
651+
return (
652+
`${head} — NO READING on the declaration limb, and the CLAIM COMMENT is what is ` +
653+
'missing: no comment on the card\'s thread is a claim comment, so the carrier this limb ' +
654+
'reads does not exist and no line could have been read from it' +
655+
(d.detail ? `; the nearest thing on the thread is ${JSON.stringify(d.detail)}` : '') +
656+
'. Remedy: write the claim comment with a first line beginning `Claim:`, then the ' +
657+
`\`Clause-②: yes|no\` line; ${fixed}. A heading-style claim (\`## Claim — …\`) is not a ` +
658+
'claim comment to this predicate, however complete the reasoning under it. ' +
659+
`${notADecision} ${NEVER_WRITES}`
618660
);
619661
}
620662
}
@@ -1078,6 +1120,35 @@ export function pairRows(pair) {
10781120
return rows;
10791121
}
10801122

1123+
/**
1124+
* The declaration limb's two not-read states, counted separately for one sweep.
1125+
*
1126+
* The summary line is where a round report takes its number from, so a single
1127+
* "no reading" count is one number said about two different owings: a card
1128+
* whose claim comment carries no declaration line owes that line, while a card
1129+
* with no claim comment owes the claim comment first, and a reader of the
1130+
* total can tell neither how many of each nor which remedy to send. Two
1131+
* numbers, two labels.
1132+
*
1133+
* The tally reads the SAME `cardDeclaration` the rows read, so a count can
1134+
* never disagree with the rows printed under it. `declared`, `misplaced`,
1135+
* `malformed` and `unreadable` are counted into neither — each is its own
1136+
* reading with its own row, and an unreadable thread is UNJUDGED rather than
1137+
* either not-read state.
1138+
*
1139+
* @param {{ cardComments?: object[]|null }[]|null} pairs
1140+
* @returns {{ absent: number, missing: number }}
1141+
*/
1142+
export function declarationLimbTally(pairs) {
1143+
const tally = { absent: 0, missing: 0 };
1144+
for (const pair of pairs ?? []) {
1145+
const { state } = cardDeclaration(pair?.cardComments ?? null);
1146+
if (state === 'absent') tally.absent += 1;
1147+
else if (state === 'missing') tally.missing += 1;
1148+
}
1149+
return tally;
1150+
}
1151+
10811152
/**
10821153
* What this pair could NOT be judged on — the #4690 half.
10831154
*
@@ -1563,12 +1634,16 @@ function renderSweep({ repo, pulls, pairs }, { json = false } = {}) {
15631634
const gap = pairUnjudged(pair);
15641635
if (gap) unjudged.push({ pr: pair.pr, card: pair.card, text: gap });
15651636
}
1637+
const declarationLimb = declarationLimbTally(pairs);
15661638
if (json) {
1567-
console.log(JSON.stringify({ repo, openPrs: pulls.length, pairs: pairs.length, rows, unjudged }, null, 2));
1639+
console.log(JSON.stringify({ repo, openPrs: pulls.length, pairs: pairs.length, declarationLimb, rows, unjudged }, null, 2));
15681640
} else {
15691641
console.log(
15701642
`check-clause2-carriers: ${pairs.length} card/PR pair(s) derived from ${pulls.length} open ` +
15711643
`PR(s) in ${repo}${rows.length} clause-② finding(s), ${unjudged.length} pair(s) UNJUDGED. ` +
1644+
`Declaration limb not read: ${declarationLimb.absent} with NO CLAIM COMMENT (a line ` +
1645+
`beginning \`Claim:\` is the whole set) and ${declarationLimb.missing} with a claim ` +
1646+
'comment but NO DECLARATION LINE — two readings, two remedies, ⛔ never one number. ' +
15721647
'Report-only: findings are patrol input, not a gate verdict.',
15731648
);
15741649
for (const row of rows) console.log(`- **${row.code}** #${row.pr} / #${row.card}${row.text}`);
@@ -1672,13 +1747,23 @@ export function selfTest() {
16721747
t('a card that never mentions the clause reads null', readClause2Line('Claim: whatever\nBranch: x') === null);
16731748
t('⛔ the reader never invents a value from an adjacent word', readClause2Line('this card is clause 2 yes in substance')?.kind !== 'declared');
16741749

1675-
// -- the card-level declaration: four states, none collapsed into another ---
1676-
battery('the card-level declaration: four states, none collapsed into another');
1750+
// -- the card-level declaration: every state, none collapsed into another --
1751+
battery('the card-level declaration: every state, none collapsed into another');
16771752
t('a claim comment carrying the line reads DECLARED', cardDeclaration([CLAIM('Clause-②: no')]).state === 'declared');
16781753
t('…and keeps the value', cardDeclaration([CLAIM('Clause-②: yes')]).value === 'yes');
1679-
t('a thread with no claim comment at all reads ABSENT', cardDeclaration([{ body: 'just a comment', created_at: '2026-08-31T10:00:00Z' }]).state === 'absent');
1680-
t('the #13910 shape — a claim comment with no Clause-② line — reads ABSENT', cardDeclaration([CLAIM('Domain: `domain:engine`')]).state === 'absent');
1681-
t('⛔ ABSENT is not `no`', cardDeclaration([CLAIM('Domain: x')]).state !== 'declared');
1754+
t('a thread with no claim comment at all reads ABSENT — no carrier, so no line could be read', cardDeclaration([{ body: 'a triage note, and nothing that begins a line with the claim key', created_at: '2026-08-31T10:00:00Z' }]).state === 'absent');
1755+
// The claim predicate is a LINE BEGINNING `Claim:`, and that one spelling is
1756+
// the whole set. A heading-style claim carries no such line, so the thread
1757+
// has no claim carrier at all and what it owes is the comment, not the line.
1758+
t('a heading-style claim is NOT a claim comment — the thread reads ABSENT, not missing-a-line', cardDeclaration([{ body: '## Claim — PM loop round R1\nBranch: `claude/issue-13476-unresolvable-engine-403`\nDomain: `domain:engine`', created_at: '2026-08-31T10:00:00Z' }]).state === 'absent');
1759+
t('the #13910 shape — a claim comment with no Clause-② line — reads MISSING: the carrier is there, the line is not', cardDeclaration([CLAIM('Domain: `domain:engine`')]).state === 'missing');
1760+
t('⛔ ABSENT and MISSING are two readings, never one — one owes a comment, the other a line', cardDeclaration([{ body: 'a triage note, and nothing that begins a line with the claim key', created_at: '2026-08-31T10:00:00Z' }]).state !== cardDeclaration([CLAIM('Domain: x')]).state);
1761+
// The substring trap: a claim comment that DESCRIBES the declaration carries
1762+
// the key as a fragment inside a sentence, never as a line of its own. The
1763+
// reader is line-anchored, so a description is MISSING and never readable.
1764+
t('a claim comment that only DESCRIBES the line reads MISSING, never declared', cardDeclaration([CLAIM('the dev declares `Clause-②: yes|no` from the diff')]).state === 'missing');
1765+
t('…and it carries no value — a fragment inside prose is not a reading of one', cardDeclaration([CLAIM('the dev declares `Clause-②: yes|no` from the diff')]).value === undefined);
1766+
t('⛔ neither not-read state is `no`', cardDeclaration([CLAIM('Domain: x')]).state !== 'declared' && cardDeclaration([{ body: 'a triage note, and nothing that begins a line with the claim key', created_at: '2026-08-31T10:00:00Z' }]).state !== 'declared');
16821767
t('the line in a NON-claim comment reads MISPLACED, not absent and not declared', cardDeclaration([CLAIM('Domain: x'), { body: 'Clause-②: yes', created_at: '2026-08-31T11:00:00Z' }]).state === 'misplaced');
16831768
t('a malformed line in the claim comment reads MALFORMED', cardDeclaration([CLAIM('Clause-②: Yes')]).state === 'malformed');
16841769
t('an UNREADABLE thread reads unreadable — never absent (#4690)', cardDeclaration(null).state === 'unreadable');
@@ -1717,13 +1802,35 @@ export function selfTest() {
17171802

17181803
// -- C2, the row this file exists for --------------------------------------
17191804
battery('C2, the row this file exists for');
1720-
const absent = c2DeclarationUnreadable(pair({ cardComments: [CLAIM('Domain: x')] }));
1721-
t('a card with no Clause-② line in its claim comment produces a C2 row', typeof absent === 'string');
1722-
t('…and says NO READING in as many words', says(absent, 'NO READING'));
1723-
t('…and states that it is not a declared `no`', says(absent, 'NOT a declared'));
1724-
t('…and refuses to have the line filled in on the seat\'s behalf', says(absent, 'Do not fill the line in'));
1725-
t('…and forbids relaxing the spelling to prose', says(absent, 'do not relax the'));
1726-
t('…and quotes the fixed spelling so the remedy is executable', says(absent, 'Clause-②: yes'));
1805+
const missingLine = c2DeclarationUnreadable(pair({ cardComments: [CLAIM('Domain: x')] }));
1806+
t('a card with no Clause-② line in its claim comment produces a C2 row', typeof missingLine === 'string');
1807+
t('…and says NO READING in as many words', says(missingLine, 'NO READING'));
1808+
t('…and names the DECLARATION LINE as the thing that is missing', says(missingLine, 'DECLARATION LINE is what is missing'));
1809+
t('…and sends the remedy to the claim comment that is already there', says(missingLine, 'add the line to that claim comment'));
1810+
const noClaim = c2DeclarationUnreadable(pair({ cardComments: [{ body: 'a triage note, and nothing that begins a line with the claim key', created_at: '2026-08-31T10:00:00Z' }] }));
1811+
t('a thread with no claim comment produces a C2 row of its own', typeof noClaim === 'string');
1812+
t('…and names the CLAIM COMMENT as the thing that is missing, not the line', says(noClaim, 'CLAIM COMMENT is what is missing'));
1813+
t('…and its remedy names the fixed first-line spelling', says(noClaim, 'first line beginning `Claim:`'));
1814+
t('…and names the heading-style claim as the shape that does not count', says(noClaim, '## Claim —'));
1815+
t('the heading-style thread gets that same row — the shape the predicate never matched', c2DeclarationUnreadable(pair({ cardComments: [{ body: '## Claim — PM loop round R1\nBranch: `claude/issue-13476-unresolvable-engine-403`\nDomain: `domain:engine`', created_at: '2026-08-31T10:00:00Z' }] })) === noClaim);
1816+
t('⛔ the two not-read rows are DIFFERENT sentences — one number over both describes neither', missingLine !== noClaim);
1817+
t('…and both still say it is not a declared `no`', says(missingLine, 'NOT a declared') && says(noClaim, 'NOT a declared'));
1818+
t('…and both refuse to have the line filled in on the seat\'s behalf', says(missingLine, 'Do not fill the line in') && says(noClaim, 'Do not fill the line in'));
1819+
t('…and both forbid relaxing the spelling to prose', says(missingLine, 'do not relax the') && says(noClaim, 'do not relax the'));
1820+
t('…and both quote the fixed spelling so the remedy is executable', says(missingLine, 'Clause-②: yes') && says(noClaim, 'Clause-②: yes'));
1821+
// The summary line is what a round report quotes, so the two readings are
1822+
// counted apart there too — from the same reader the rows use.
1823+
const TALLY = [
1824+
pair({ cardComments: [CLAIM('Clause-②: no')] }),
1825+
pair({ cardComments: [CLAIM('Domain: x')] }),
1826+
pair({ cardComments: [CLAIM('Domain: y')] }),
1827+
pair({ cardComments: [{ body: 'a triage note, and nothing that begins a line with the claim key', created_at: '2026-08-31T10:00:00Z' }] }),
1828+
pair({ cardComments: null }),
1829+
];
1830+
t('the sweep counts the two not-read states separately', declarationLimbTally(TALLY).missing === 2 && declarationLimbTally(TALLY).absent === 1, JSON.stringify(declarationLimbTally(TALLY)));
1831+
t('…and counts a DECLARED card into neither', declarationLimbTally([pair({ cardComments: [CLAIM('Clause-②: yes')] })]).absent === 0 && declarationLimbTally([pair({ cardComments: [CLAIM('Clause-②: yes')] })]).missing === 0);
1832+
t('…and an UNREADABLE thread into neither — it is UNJUDGED, never a not-read declaration', declarationLimbTally([pair({ cardComments: null })]).absent === 0 && declarationLimbTally([pair({ cardComments: null })]).missing === 0);
1833+
t('…and a MISPLACED declaration into neither — it has its own row and its own remedy', declarationLimbTally([pair({ cardComments: [CLAIM('Domain: x'), { body: 'Clause-②: yes', created_at: '2026-08-31T11:00:00Z' }] })]).missing === 0);
17271834
const nearMiss = c2DeclarationUnreadable(pair({ cardComments: [CLAIM('## Clause ②: **yes**')] }));
17281835
t('a prose declaration still produces the C2 row — prose is not a reading', typeof nearMiss === 'string');
17291836
t('…and the row quotes what WAS there, so the residue is actionable', says(nearMiss, 'Clause ②'));
@@ -2102,7 +2209,8 @@ export function selfTest() {
21022209
}
21032210
console.log(
21042211
`✓ check-clause2-carriers self-test: ${cases.length} cases pass (fixed-spelling reader, the ` +
2105-
'four declaration states, the 2026-08-31 seven-pair replay, the four gate-binding states ' +
2212+
'declaration states with the two not-read readings kept apart — no claim comment, and a claim '
2213+
+ 'comment with no line — the 2026-08-31 seven-pair replay, the four gate-binding states ' +
21062214
'replayed from the 2026-09-01 clear, the verdict-authorship pair and its legacy silence, ' +
21072215
'the three read paths with their offline reader, and the exit register).',
21082216
);

0 commit comments

Comments
 (0)