Skip to content

Commit 22c94e5

Browse files
claude[bot]claude
andauthored
fix(pm): name PLACEMENT when a Clause-② key sits mid-line (#17201)
check-clause2-carriers read a claim header like `Domain: X · Clause-②: no` as `missing` and shipped a remedy naming only spelling -- so a seat was sent to hunt a typo on a line spelled exactly right. Both existing patterns anchor at `^`, so the line reached neither the declaration reader nor the near-miss reporter whose whole job is to stop a near miss reading as an absence. Adds a reporting-only detector for the fixed key preceded by something other than line-start decoration. The near miss now carries a reason, and the C2 row picks its remedy sentence from it: placement, with the offending line quoted and an explicit "there is no typo to find". CLAUSE2_KEY_LINE is untouched and the state union is unchanged -- the shape still reads `missing`, carries no value, and keeps its exit. What moved is the sentence, not the accept set: this reader decides "is this a declaration?" by position alone, so loosening position here would promote merely-describing prose into candidate declarations in the opposite direction. Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU Co-authored-by: Claude <noreply@anthropic.com>
1 parent f3b28eb commit 22c94e5

1 file changed

Lines changed: 138 additions & 5 deletions

File tree

scripts/pm/check-clause2-carriers.mjs

Lines changed: 138 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,11 @@
126126
* way H4 tolerates it (a leading blockquote, a list bullet, backticks, bold),
127127
* because that is markdown a seat writes without meaning anything by it; the
128128
* KEY and the VALUE are literal. A near-miss spelling is reported IN the row so
129-
* the residue is actionable, and it still does not count as a declaration.
129+
* the residue is actionable, and it still does not count as a declaration. So
130+
* is the other near miss, the one where the key is spelled exactly right and
131+
* sits mid-line after another field: that line is quoted back with a remedy
132+
* naming PLACEMENT rather than spelling, and it is not read as a declaration
133+
* either — what moved is the sentence, never the accept set.
130134
*
131135
* **It writes nothing.** No label, ever — hanging or clearing a review gate
132136
* from a checker would be issuing the review verdict, which is 自查放行 and is
@@ -528,6 +532,62 @@ const CLAUSE2_KEY_LINE = /^[ \t]*(?:>[ \t]*)?(?:[-*][ \t]+)?(?:\*\*)?`?Clause-
528532
*/
529533
const CLAUSE2_NEAR_MISS_LINE = /^[ \t]*(?:>[ \t]*)?(?:[-*#][ \t]*)*(?:\*\*)?`?\s*Clause[ \t-]*(?:|2|two)(?![\w]).*$/i;
530534

535+
/**
536+
* The SECOND near-miss shape, and the one both patterns above are blind to: the
537+
* key in the FIXED spelling, on a line that starts with something else.
538+
*
539+
* `Domain: \`domain:cli\` · Clause-②: no` is a natural way to write a compact
540+
* claim header, and it reaches neither pattern above — both anchor at `^` and
541+
* tolerate only line-start decoration before the key. So the line was invisible
542+
* TWICE: not read as a declaration (correct), and not quoted back as a near miss
543+
* either (the whole job of the mechanism above). What the seat was told instead
544+
* was that the SPELLING was wrong, on a line spelled exactly right.
545+
*
546+
* ⛔ This is a REPORTER, never a reader. It changes what this file SAYS about a
547+
* line it does not read; it changes nothing about what it ACCEPTS.
548+
* `CLAUSE2_KEY_LINE` is untouched, and must stay untouched: 「a predicate that
549+
* reads prose is a heuristic, and the measured terminus of that direction is a
550+
* check that can barely fail」.
551+
*
552+
* ⭐ And the reason that red line is structural rather than stylistic: this
553+
* reader decides "is this line a declaration?" by POSITION ALONE. Loosening the
554+
* position rule to catch the shape above would, by the same stroke, promote more
555+
* merely-DESCRIBING prose into candidate declarations — the opposite direction,
556+
* measured on the same regex. So the detector below deliberately fires only
557+
* where the key is NOT at the start of a line, and a key-initial line reaches it
558+
* never: whatever a key-initial line reads as, this file does not move it.
559+
*
560+
* The prefix set is the decoration the two patterns above already tolerate
561+
* (whitespace, a blockquote `>`, a list bullet, `#`, backtick/bold wrapping). A
562+
* line whose key is preceded by only that is a DECORATION near miss and keeps
563+
* the spelling remedy; a line whose key is preceded by anything else is a
564+
* PLACEMENT near miss and gets a remedy that names placement.
565+
*/
566+
const CLAUSE2_KEY_TEXT = 'Clause-②';
567+
const CLAUSE2_KEY_COLON = /^`?(?:\*\*)?[ \t]*:/;
568+
const CLAUSE2_LINE_START_DECORATION = /^[ \t>\-*#`]*$/;
569+
570+
/**
571+
* Does this line carry the fixed key, followed by its colon, at a position no
572+
* line-start decoration can explain?
573+
*
574+
* @param {string} line
575+
* @returns {boolean}
576+
*/
577+
function hasInlineClause2Key(line) {
578+
const s = String(line ?? '');
579+
let from = 0;
580+
for (;;) {
581+
const at = s.indexOf(CLAUSE2_KEY_TEXT, from);
582+
if (at < 0) return false;
583+
from = at + CLAUSE2_KEY_TEXT.length;
584+
// The key alone is not the shape; it is the key AND its colon, so a bare
585+
// mention of `Clause-②` in a sentence is left to the pattern above.
586+
if (!CLAUSE2_KEY_COLON.test(s.slice(from))) continue;
587+
if (!CLAUSE2_LINE_START_DECORATION.test(s.slice(0, at))) return true;
588+
}
589+
}
590+
531591
/**
532592
* The value token, read immediately after the colon.
533593
*
@@ -567,26 +627,38 @@ function quoteLine(line, cap = 160) {
567627
* @param {string} text
568628
* @returns {{ kind: 'declared', value: 'yes'|'no', line: string }
569629
* | { kind: 'malformed', value: string, line: string }
570-
* | { kind: 'near-miss', line: string }
630+
* | { kind: 'near-miss', reason: 'inline-key'|'spelling', line: string }
571631
* | null}
572632
*
573633
* Four-valued on purpose. `declared` and `malformed` are different facts about
574634
* a line that IS the key; `near-miss` is a fact about a line that is not. Any
575635
* collapse of these into "no" is the defect #13914 filed.
636+
*
637+
* The near miss carries a REASON because the two shapes owe opposite remedies:
638+
* `spelling` is a line that does not carry the fixed key at all, and `inline-key`
639+
* is a line that carries it exactly right but not at the start of a line. ⛔ The
640+
* reason changes the sentence, never the state — both are near misses, and a
641+
* near miss is not a declaration in either case.
576642
*/
577643
export function readClause2Line(text) {
578644
const lines = String(text ?? '').split(/\r?\n/);
579645
let nearMiss = null;
646+
let inlineKey = null;
580647
for (const line of lines) {
581648
const m = CLAUSE2_KEY_LINE.exec(line);
582649
if (m) {
583650
const value = readValueToken(m[1]);
584651
if (value !== null) return { kind: 'declared', value, line: quoteLine(line) };
585652
return { kind: 'malformed', value: quoteLine(m[1], 60), line: quoteLine(line) };
586653
}
654+
if (inlineKey === null && hasInlineClause2Key(line)) inlineKey = quoteLine(line);
587655
if (nearMiss === null && CLAUSE2_NEAR_MISS_LINE.test(line)) nearMiss = quoteLine(line);
588656
}
589-
return nearMiss === null ? null : { kind: 'near-miss', line: nearMiss };
657+
// The correctly-spelled key wins over a vocabulary near miss wherever the two
658+
// land in the body: it is the more actionable of the two residues, and reading
659+
// order is not a fact about which one the seat should be sent to.
660+
if (inlineKey !== null) return { kind: 'near-miss', reason: 'inline-key', line: inlineKey };
661+
return nearMiss === null ? null : { kind: 'near-miss', reason: 'spelling', line: nearMiss };
590662
}
591663

592664
/**
@@ -619,7 +691,7 @@ export function readClause2Line(text) {
619691
* @param {{ body?: string, created_at?: string }[]|null} commentRows — the REST
620692
* comment rows, or `null` when the thread could NOT be read.
621693
* @returns {{ state: 'declared'|'malformed'|'misplaced'|'missing'|'absent'|'unreadable',
622-
* value?: 'yes'|'no', detail?: string }}
694+
* value?: 'yes'|'no', detail?: string, nearMissReason?: 'inline-key'|'spelling' }}
623695
*/
624696
export function cardDeclaration(commentRows) {
625697
if (!Array.isArray(commentRows)) return { state: 'unreadable' };
@@ -657,7 +729,16 @@ export function cardDeclaration(commentRows) {
657729
// exists at all. `claimRows` is non-empty exactly when some comment matched
658730
// the imported claim predicate, and `pool` is derived from it — so this asks
659731
// the same question the reading above asked and cannot answer it differently.
660-
return { state: claimRows.length > 0 ? 'missing' : 'absent', detail: nearMiss?.line };
732+
// `nearMissReason` rides alongside the quoted line for exactly one purpose:
733+
// the row below picks its REMEDY sentence from it. ⛔ It is not part of the
734+
// state union and no verdict, count or exit reads it — a near miss with a
735+
// correctly-spelled key is the same `missing` this function has always
736+
// returned, and #12409's boundary moves by not one character.
737+
return {
738+
state: claimRows.length > 0 ? 'missing' : 'absent',
739+
detail: nearMiss?.line,
740+
nearMissReason: nearMiss?.reason,
741+
};
661742
}
662743

663744
// ---------------------------------------------------------------------------
@@ -810,6 +891,24 @@ export function c2DeclarationUnreadable(pair) {
810891
`${NEVER_WRITES}`
811892
);
812893
case 'missing':
894+
// The key is on the thread, spelled exactly right, and simply not at the
895+
// start of a line. The state is unchanged — it was not read, so it is
896+
// still `missing` and still exits 4 — but the remedy that ships with the
897+
// sentence below sends the seat to inspect SPELLING, which for this shape
898+
// is a search for a typo that is not there. Naming the placement is the
899+
// whole of the change; ⛔ nothing here accepts the line.
900+
if (d.nearMissReason === 'inline-key') {
901+
return (
902+
`${head} — NO READING on the declaration limb, and the PLACEMENT of the key is what is ` +
903+
`wrong, NOT its spelling: the thread carries the key in the fixed spelling, on ` +
904+
`${JSON.stringify(d.detail)}, but not at the START of a line. This limb is read ` +
905+
'line-anchored — only whitespace, a blockquote `>`, a list bullet and backtick/bold ' +
906+
'wrapping may precede the key — so a key that follows another field on a shared line is ' +
907+
'not read, however correctly it is spelled. ⛔ There is no typo to find on that line. ' +
908+
'Remedy: put the `Clause-②: yes|no` line on a line of its OWN in the card\'s claim ' +
909+
`comment, unchanged otherwise — ${fixed}. ${notADecision} ${NEVER_WRITES}`
910+
);
911+
}
813912
return (
814913
`${head} — NO READING on the declaration limb, and the DECLARATION LINE is what is ` +
815914
`missing: the card's claim comment is there and carries no \`Clause-②:\` line in the ` +
@@ -2254,6 +2353,21 @@ export function selfTest() {
22542353
t('a very long claim line is quoted back CAPPED, so one row cannot swamp the report', (readClause2Line(`Clause-②: maybe ${'x'.repeat(400)}`)?.line ?? '').length < 200);
22552354
t('a card that never mentions the clause reads null', readClause2Line('Claim: whatever\nBranch: x') === null);
22562355
t('⛔ the reader never invents a value from an adjacent word', readClause2Line('this card is clause 2 yes in substance')?.kind !== 'declared');
2356+
// The inline-key near miss. Each case below pins ONE line and says only what
2357+
// that line shows: the measured shape is `Domain: … · Clause-②: no`, which is
2358+
// how a compact claim header is written and which reached NEITHER pattern.
2359+
t('this exact shared-line header — `Domain: `domain:cli` · Clause-②: no` — is reported as a near miss', readClause2Line('Domain: `domain:cli` · Clause-②: no')?.kind === 'near-miss');
2360+
t('…reasoned INLINE-KEY, so the row that quotes it can name placement instead of spelling', readClause2Line('Domain: `domain:cli` · Clause-②: no')?.reason === 'inline-key');
2361+
t('…and ⛔ NOT read as a declaration: this line is exactly as unread as it was before', readClause2Line('Domain: `domain:cli` · Clause-②: no')?.kind !== 'declared');
2362+
t('…and the row quotes THAT line, not the key alone', says(readClause2Line('Domain: `domain:cli` · Clause-②: no')?.line, 'Domain:'));
2363+
// ⛔ The reporter fires only where the key is NOT at the start of a line. The
2364+
// key-INITIAL direction is a different card's (#17098) and is not moved here;
2365+
// this pins the property of THIS change — a key-initial line never reaches the
2366+
// inline-key reason — rather than pinning what that direction currently reads.
2367+
t('⛔ a key-INITIAL line is never reasoned inline-key — decoration before the key is not placement', readClause2Line('## Clause-②: yes')?.reason === 'spelling');
2368+
t('…nor is a bulleted, blockquoted or backticked key: `> - `Clause-②` : yes` still reads DECLARED, untouched', readClause2Line('> - `Clause-②` : yes')?.kind === 'declared');
2369+
t('⛔ a mid-line mention with NO colon is not the inline shape — the reporter looks for the key AND its colon', readClause2Line('Domain: x · Clause-② is not touched here') === null);
2370+
t('the inline-key residue wins over a vocabulary near miss written ABOVE it — reading order is not a fact about the remedy', readClause2Line('## Clause ②: **yes**\nDomain: x · Clause-②: no')?.reason === 'inline-key');
22572371

22582372
// -- the card-level declaration: every state, none collapsed into another --
22592373
battery('the card-level declaration: every state, none collapsed into another');
@@ -2342,6 +2456,25 @@ export function selfTest() {
23422456
const nearMiss = c2DeclarationUnreadable(pair({ cardComments: [CLAIM('## Clause ②: **yes**')] }));
23432457
t('a prose declaration still produces the C2 row — prose is not a reading', typeof nearMiss === 'string');
23442458
t('…and the row quotes what WAS there, so the residue is actionable', says(nearMiss, 'Clause ②'));
2459+
// The inline-key row. The measured claim comment is a compact header — the
2460+
// key correctly spelled, after another field, on a shared line.
2461+
const INLINE = 'Domain: `domain:cli` · Clause-②: no';
2462+
const inlineRow = c2DeclarationUnreadable(pair({ cardComments: [CLAIM(INLINE)] }));
2463+
t('a claim comment whose only Clause-② key sits mid-line produces a C2 row', typeof inlineRow === 'string');
2464+
t('…and that row names PLACEMENT as what is wrong', says(inlineRow, 'PLACEMENT of the key is what is wrong'));
2465+
t('…and says in as many words that the spelling is NOT it', says(inlineRow, 'NOT its spelling') && says(inlineRow, 'no typo to find'));
2466+
t('…and quotes the offending line, which is what makes the residue actionable', says(inlineRow, 'Domain: `domain:cli`'));
2467+
t('…and its remedy is to put the line on one of its OWN, not to hunt a misspelling', says(inlineRow, 'line of its OWN'));
2468+
t('⛔ it is a DIFFERENT sentence from the row a claim comment with no key at all gets', inlineRow !== missingLine);
2469+
t('⛔ and the state behind it is still MISSING — the sentence moved, the accept set did not', cardDeclaration([CLAIM(INLINE)]).state === 'missing');
2470+
t('⛔ …with no value read off that line', cardDeclaration([CLAIM(INLINE)]).value === undefined);
2471+
t('⛔ and the sweep still counts it as one NOT-READ card, exactly as before', declarationLimbTally([pair({ cardComments: [CLAIM(INLINE)] })]).missing === 1);
2472+
t('…and the row still carries the standing refusal to relax the spelling', says(inlineRow, 'do not relax the') && says(inlineRow, 'Do not fill the line in'));
2473+
// A thread with no claim comment owes the COMMENT, so that remedy does not
2474+
// change; what it gains is the quotation it never had.
2475+
const inlineNoClaim = c2DeclarationUnreadable(pair({ cardComments: [{ body: INLINE, created_at: '2026-08-31T10:00:00Z' }] }));
2476+
t('a NON-claim comment carrying the inline key still owes the CLAIM COMMENT', says(inlineNoClaim, 'CLAIM COMMENT is what is missing'));
2477+
t('…and now quotes that line as the nearest thing on the thread', says(inlineNoClaim, 'Domain: `domain:cli`'));
23452478
const misplaced = c2DeclarationUnreadable(pair({ cardComments: [CLAIM('Domain: x'), { body: 'Clause-②: yes', created_at: '2026-08-31T11:00:00Z' }] }));
23462479
t('a declaration outside the claim comment reads MISPLACED, with its own sentence', says(misplaced, 'MISPLACED'));
23472480
t('…and says the thinking was done, only in the wrong carrier', says(misplaced, 'not in a place') || says(misplaced, 'place the predicate does not look'));

0 commit comments

Comments
 (0)