Skip to content

Commit 9500ba4

Browse files
os-helpclaude
andauthored
fix(spec-tooling): a hard-wrapped mention is not a migration label (#7094) (#7805)
Branch 1 of the ADR-0087 completeness gate reads the one character to the left of the `FROM`/`TO` placeholder to tell a label from a mention. Prose here is hard-wrapped at ~80 columns, so a mention that wraps onto a fresh line has nothing to its left and was read as a declaration -- the over-match direction, which hard-blocks an author with no disposition left but rewording true prose. When the placeholder opens its line the question is now asked of the line above, and it is a narrower question there: within a line adjacency is the evidence, but a line break is ambiguous, so only a closed class of determiners, possessives and complement-taking prepositions counts. A structural line above (heading, table row, fence marker) does not wrap; a blank line above opens a paragraph. Both keep their previous reading. Measured over the whole 1792-changeset stock before and after: 176 hits / 132 declared-breaking -> 174 / 132. The two that leave are the reported specimen `changelog-ships-in-tarball.md` and `notification-retirement-evidence- corrected.md`, both mentions on inspection and neither declaring a breaking change; the `--audit-stock` worklist is byte-identical at residue 97 / 52 candidates. The wider version -- reusing the in-line "any letter governs" test across the wrap -- was written first and killed by its own positive control, now pinned as P52. Self-test 142 -> 153 assertions. Claude-Session: https://claude.ai/code/session_01PJS4XSauE4SRNAvRUMyw2Z Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78ed1f4 commit 9500ba4

2 files changed

Lines changed: 195 additions & 15 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec-tooling): a hard-wrapped mention no longer reads as a migration label (#7094)
6+
7+
Branch 1 of the ADR-0087 completeness gate decides whether the `FROM`/`TO`
8+
placeholder is being **used** as a label or merely **mentioned** in a sentence, by
9+
looking at the one character to its left. Prose in this repo is hard-wrapped at ~80
10+
columns, so a mention that happens to wrap onto a fresh line has *nothing* to its
11+
left — its governing word is on the line above — and was read as a declaration.
12+
That direction hard-blocks an author: all four dispositions close at once and the
13+
only remaining move is to reword a sentence that was true.
14+
15+
When the placeholder opens its line, the question is now asked of the line above
16+
instead — and it is a **narrower** question there. Within a line, adjacency is the
17+
evidence and any attached word governs. A line break is ambiguous by construction, so
18+
across it only a closed class of determiners, possessives and complement-taking
19+
prepositions counts: `carry their` continues into the next line, `ends the line here`
20+
does not. A structural line above (heading, table row, fence marker) does not wrap at
21+
all, and a blank line above opens a paragraph — both keep their previous reading.
22+
23+
Measured over the whole 1792-changeset stock before and after: 176 hits / 132
24+
declared-breaking → 174 / 132. The two that leave are `changelog-ships-in-tarball.md`
25+
(the reported specimen) and `notification-retirement-evidence-corrected.md`, both
26+
mentions on inspection and **neither declaring a breaking change**. The
27+
`--audit-stock` worklist is byte-identical — residue 97, `!` candidates 52 — so no
28+
declared-breaking changeset changed state. The gate's diff-only posture (#6129) is
29+
untouched, and no ledger entry is added or implied.
30+
31+
The wider version of this fix — reusing the in-line "any letter governs" test across
32+
the wrap — was written first and rejected by its own positive control, which is
33+
pinned as an assertion rather than described: it turned every label wrapped under an
34+
ordinary sentence into a mention. The self-test gains 11 assertions (142 → 153)
35+
covering both directions.

scripts/check-adr-0087-registration.mjs

Lines changed: 160 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -543,13 +543,23 @@ export function breakingDeclaration(parsed) {
543543
// above is exactly that shape. So corroboration is a FALLBACK for the ambiguous
544544
// case, never the criterion.
545545
//
546-
// The residual blind spot, stated rather than hidden: prose here is HARD-WRAPPED at
547-
// ~80 columns, so a mention that happens to wrap onto a fresh line puts its
548-
// placeholder at column 0 with its governing word on the line above, and reads as a
549-
// label. `changelog-ships-in-tarball.md` is that shape (`…carry their\nFROM → TO
550-
// migration because…`) and stays a false positive. It declares no breaking change,
551-
// so it is never judged; a cross-line prefix was not attempted because a wrapped
552-
// LABEL is just as common and nothing distinguishes the two from the line above.
546+
// That blind spot is CLOSED (#7094). Prose here is HARD-WRAPPED at ~80 columns, so a
547+
// mention that wraps onto a fresh line puts its placeholder at column 0 with its
548+
// governing word on the line above; `labelPositioned` now asks that line the
549+
// question when the placeholder opens its own and the line above is prose (not a
550+
// heading, table row or fence marker). #7078 declined this because "a wrapped LABEL
551+
// is just as common", and it is RIGHT about that for the in-line predicate: reusing
552+
// `GOVERNING_WORD_RE` across the wrap reads every line-final letter as governance
553+
// and turns a label under any ordinary sentence into a mention. That version was
554+
// written first and its own positive control killed it (P52). What separates the two
555+
// shapes is a NARROWER question asked only across the break -- a closed class of
556+
// words that cannot END a segment (`carry their` continues; `ends the line here`
557+
// does not). See `WRAPPED_GOVERNOR_RE`. Measured over the full 1792-changeset stock:
558+
// 176 hits / 132 breaking -> 174 / 132. TWO changesets leave, NEITHER declared
559+
// breaking, and both are mentions on inspection -- `changelog-ships-in-tarball.md`
560+
// (the specimen) and `notification-retirement-evidence-corrected.md` (a docs-only
561+
// correction narrating which commits touched a published FROM → TO). The residue and
562+
// the `--audit-stock` `!` candidate list are UNCHANGED at 97 / 52.
553563
//
554564
// Anyone widening this later should start with the first two, with these numbers to
555565
// beat, and should re-measure the false-positive surface FIRST -- as #6419, #6497
@@ -787,6 +797,46 @@ const GOVERNING_WORD_RE = /(?:[A-Za-z]|的)$/;
787797
const FRAMING_TAIL_RE =
788798
/(?:||||migrat(?:e|es|ed|ing|ion|ions)|rename[sd]?|rewrit(?:e|es|ten|ing)|upgrade[sd]?)$/i;
789799

800+
/**
801+
* A line whose sentence CANNOT run on into the line below it -- markdown structure
802+
* rather than prose. Consulted only by `labelPositioned`'s cross-line arm (#7094):
803+
* a placeholder opening the line under one of these is opening a segment, so it is
804+
* read exactly as it was before that arm existed.
805+
*
806+
* A bullet or a blockquote is deliberately NOT here: those DO wrap, and their
807+
* continuation line is exactly the shape the arm exists to read. A blank line needs
808+
* no entry either -- it governs nothing through the ordinary path.
809+
*/
810+
const STRUCTURAL_LINE_RE = /^\s{0,3}(?:#{1,6}\s|```|~~~|\|)/;
811+
812+
/**
813+
* Does the line ABOVE end on a word that cannot be the last word of a segment --
814+
* so the noun phrase it opens must continue on the line below? (#7094)
815+
*
816+
* ⚠️ This is deliberately NOT `GOVERNING_WORD_RE`, and the difference is the whole
817+
* safety of the cross-line arm. WITHIN a line, adjacency is the evidence: a letter
818+
* immediately left of the placeholder means a word is attached to it, whatever word
819+
* it is. A LINE BREAK is not adjacency -- it is ambiguous by construction, because
820+
* a label may perfectly well open the line under a finished thought. So the
821+
* cross-line test demands a stronger signal than "a word ended here": a CLOSED
822+
* CLASS of determiners, possessives and complement-taking prepositions, none of
823+
* which can end an English segment. `carry their` continues; `ends the line here`
824+
* does not, and neither does `## Migration`.
825+
*
826+
* Reusing `GOVERNING_WORD_RE` across the wrap was written first and REJECTED by its
827+
* own positive control (P52): it reads ANY line-final letter as governance, so
828+
* every wrapped LABEL under an ordinary sentence became a mention -- the precise
829+
* failure #7078 predicted when it declined to attempt this at all. The class below
830+
* is what makes the two shapes separable; over the stock all three wrapped mentions
831+
* end on `their`, `the` and `a` respectively, and nothing else in 1792 changesets
832+
* ends a line in this class before a placeholder.
833+
*
834+
* The Chinese arm needs no class: `的` is the attributive particle and is already
835+
* the one-character signal in-line (P40), so it carries across the wrap unchanged.
836+
*/
837+
const WRAPPED_GOVERNOR_RE =
838+
/(?:^|[^A-Za-z])(?:the|an?|its|their|our|your|his|her|this|these|those|each|every|any|some|no|same|own|another|of|with|for|in|on|by|to|and|or)$|$/i;
839+
790840
/**
791841
* The two halves of a VERTICAL `FROM`/`TO` pair -- the placeholder spelled as two
792842
* consecutive blocks rather than across one arrow. Each must OPEN its line (past a
@@ -842,18 +892,56 @@ const VERTICAL_TO_RE = /^\s{0,3}(?:(?:\/\/|#|-|\*|>)\s*)*\**TO\**\s*(?::|—|-|$
842892
*
843893
* ⚠️ Prose in this repo is HARD-WRAPPED at ~80 columns, so "starts its line" is NOT
844894
* the test and never could be -- `carry their\nFROM → TO migration` puts a mention
845-
* at column 0. The prefix examined is the prefix WITHIN the line, which is why a
846-
* mention wrapped onto a fresh line is the one shape this rule cannot see. Stated
847-
* rather than hidden: `changelog-ships-in-tarball.md` is exactly that shape and
848-
* stays a false positive (it declares no breaking change, so it is never judged).
895+
* at column 0 with nothing at all to its left. #7078 left that as a stated blind
896+
* spot; #7094 closes it. When the placeholder OPENS its line, the word that governs
897+
* it -- if one does -- is the last word of the line ABOVE, so that is where the
898+
* question is asked instead.
899+
*
900+
* ⚠️ It is a DIFFERENT and narrower question there, and that asymmetry is the point.
901+
* Adjacency within a line is evidence on its own; a line break is not, because a
902+
* label may open the line under a finished thought. So the cross-line arm asks
903+
* `WRAPPED_GOVERNOR_RE` -- a closed class of determiners, possessives and
904+
* complement-taking prepositions -- where the in-line arm asks `GOVERNING_WORD_RE`.
905+
* `carry their` continues into the next line; `ends the line here` does not.
906+
*
907+
* ⚠️ It is asked ONLY of a line the sentence can actually run on from. A structural
908+
* line above -- a heading, a table row, a fence marker -- does not wrap into the one
909+
* below, and a BLANK line above (46 of the stock's 49 line-opening occurrences)
910+
* opens a paragraph, whose first token is a label by position. In all three the
911+
* reading is exactly what it was before this arm existed.
912+
*
913+
* ⚠️ Measured over the whole 1792-changeset stock before it was believed: of the 49
914+
* occurrences that open their line, 46 sit under a blank line and are untouched
915+
* here; of the 3 with prose above, ALL THREE are mentions on inspection, and only
916+
* TWO change their changeset's verdict --
917+
* `changelog-ships-in-tarball.md` (the #7094 specimen, `carry their\nFROM → TO
918+
* migration`) and `notification-retirement-evidence-corrected.md` (`the evidence and
919+
* the\nFROM → TO), and one on the #4651 app-area entry`). BOTH declare no breaking
920+
* change. `apimethod-enum-shrink.md` carries the third and keeps its hit on an
921+
* EARLIER genuine label (`**Migration (FROM → TO).**`) -- it is the positive control
922+
* for this arm, a body holding a real label and a wrapped mention at once. Zero
923+
* declared-breaking changesets change state, so the residue and the `!` candidate
924+
* list are byte-identical before and after.
925+
*
926+
* ⚠️ Still out of reach, stated rather than hidden: prose wrapped inside a FENCED
927+
* block. The fence's opening line is structural and excluded, but a line INSIDE the
928+
* fence reads as prose, and this predicate is given one line of context rather than
929+
* the block structure. No stock occurrence has that shape.
849930
*
850931
* @param {string} line the line the `FROM` token sits on
851932
* @param {number} col its column within that line
933+
* @param {string} [prev] the line above it, when there is one (#7094)
852934
*/
853-
function labelPositioned(line, col) {
935+
function labelPositioned(line, col, prev) {
854936
if (/^\s{0,3}#{1,6}\s/.test(line)) return true;
855-
const prefix = line.slice(0, col).replace(/\s+$/, '').replace(FRAMING_TAIL_RE, '');
856-
return !GOVERNING_WORD_RE.test(prefix);
937+
const prefix = line.slice(0, col).replace(/\s+$/, '');
938+
if (prefix !== '') return !GOVERNING_WORD_RE.test(prefix.replace(FRAMING_TAIL_RE, ''));
939+
// The placeholder OPENS its line -- bare or merely indented, so a wrapped list
940+
// item counts. There is no character to its left, so the governing word, if there
941+
// is one, is the last word of the line above; and only a line that is prose the
942+
// sentence can run on from is asked (#7094).
943+
if (prev === undefined || STRUCTURAL_LINE_RE.test(prev)) return true;
944+
return !WRAPPED_GOVERNOR_RE.test(prev.replace(/\s+$/, ''));
857945
}
858946

859947
/**
@@ -933,7 +1021,7 @@ export function findMigrationPrescription(body) {
9331021
const at = m.index + m[0].indexOf('FROM');
9341022
const lineStart = body.lastIndexOf('\n', at - 1) + 1;
9351023
const lineNo = body.slice(0, at).split(/\r?\n/).length - 1;
936-
if (!labelPositioned(lines[lineNo] ?? '', at - lineStart)) {
1024+
if (!labelPositioned(lines[lineNo] ?? '', at - lineStart, lineNo > 0 ? lines[lineNo - 1] : undefined)) {
9371025
if (corroborated === null) corroborated = carriesConcreteRewrite(body);
9381026
if (!corroborated) continue;
9391027
}
@@ -2485,6 +2573,63 @@ function selfTest() {
24852573
'P50: the evidence line is the placeholder\'s OWN line -- the match may open on the newline that ends the line above, which used to be reported instead',
24862574
);
24872575

2576+
// --- P51-P60: the HARD-WRAPPED mention, and the floors that keep the cure from
2577+
// --- being worse than the disease (#7094).
2578+
//
2579+
// Branch 1 reads the ONE character before the placeholder. When the placeholder
2580+
// opens its line there is no such character, and #7078 read that as "a label
2581+
// opens a segment" -- true under a blank line, false under a sentence that simply
2582+
// ran out of columns. These pin BOTH directions: the wrapped MENTION now loses,
2583+
// and every other way of opening a line keeps what it had. P52 is the positive
2584+
// control the specimen assertions are worthless without -- if it ever goes red
2585+
// with P51 green, the arm has stopped seeing rather than started discriminating.
2586+
assert(
2587+
!hasMigrationPrescription(
2588+
'The AGENTS.md post-task checklist requires breaking changesets to carry their\nFROM → TO migration because "this text ships to consumers as `CHANGELOG.md`\ninside the npm package and is what an upgrading agent greps after the tombstone\nerror."\n',
2589+
),
2590+
'P51: the #7094 specimen VERBATIM -- `carry their` ends the line above, so the wrap does not make a mention a label (`changelog-ships-in-tarball.md`)',
2591+
);
2592+
assert(
2593+
findMigrationPrescription('prose that wraps at eighty columns and ends the line here\nFROM → TO: delete the block\n')?.branch === 'from-to-label',
2594+
'P52: POSITIVE CONTROL -- a wrapped LABEL under a line ending on a word that governs nothing still matches, with NO concrete rewrite in the body to fall back on',
2595+
);
2596+
assert(
2597+
findMigrationPrescription('The RLS compiler never read it.\nFROM → TO: a set a policy needs is now supplied\n')?.branch === 'from-to-label',
2598+
'P53: ...and so does one under a FINISHED sentence -- punctuation is a boundary across the wrap exactly as it is within the line (P43)',
2599+
);
2600+
assert(
2601+
findMigrationPrescription('an ordinary paragraph about something else\n\nFROM → TO:\n')?.branch === 'from-to-label',
2602+
'P54: a placeholder opening a PARAGRAPH is a label -- 46 of the stock\'s 49 line-opening occurrences are this shape and none of them moved',
2603+
);
2604+
assert(
2605+
findMigrationPrescription('## Migration\nFROM → TO:\n')?.branch === 'from-to-label',
2606+
'P55: a HEADING does not wrap into the line below it, so the placeholder under one still opens a segment',
2607+
);
2608+
assert(
2609+
findMigrationPrescription('| You wrote | Write instead |\nFROM → TO:\n')?.branch === 'from-to-label',
2610+
'P56: nor does a table row -- structure above is not a sentence running on',
2611+
);
2612+
assert(
2613+
!hasMigrationPrescription('这次改动只是把发布说明搬了个地方,真正的\nFROM → TO 落在部署方自己的代理配置上。\n'),
2614+
'P57: the Chinese spelling wraps too -- `的` ending the line above governs the placeholder below it (P40 across a wrap)',
2615+
);
2616+
assert(
2617+
!hasMigrationPrescription('- the checklist requires every breaking changeset to carry its own\n FROM → TO guide, which is where the prescriptions live\n'),
2618+
'P58: a wrapped BULLET is prose that runs on -- the arm reads the line above whether the placeholder is at column 0 or merely indented',
2619+
);
2620+
assert(
2621+
findMigrationPrescription('- a bullet that ended cleanly\n\n FROM → TO: delete the block\n')?.branch === 'from-to-label',
2622+
'P59: ...and an INDENTED label under a blank line is still a label -- indentation is not the test, which is the tightening this fix had to avoid',
2623+
);
2624+
assert(
2625+
findMigrationPrescription('the checklist requires each changeset to carry their\nFROM → TO guide\n\n- `objectPermissions` → `objectPermission`\n')?.branch === 'from-to-label',
2626+
'P60: the corroboration floor survives the wrap -- a wrapped mention is still taken at face value once the body SHOWS a concrete rewrite (P47)',
2627+
);
2628+
assert(
2629+
findMigrationPrescription('**Migration (FROM → TO).** Replace each legacy value with the primitive\n\nit is NOT a parse error: `stripLegacyApiMethods` strips it with a\nFROM→TO warning (canonicalize-and-warn)\n')?.line === '**Migration (FROM → TO).** Replace each legacy value with the primitive',
2630+
'P61: a body holding a real label AND a wrapped mention keeps the LABEL as its evidence -- `apimethod-enum-shrink.md`, declared-breaking, the stock\'s own control for this arm',
2631+
);
2632+
24882633
// ---- S1-S5: the `--audit-stock` classifier (#6350) ------------------------
24892634
//
24902635
// The stock audit's classifier decides which rows a human ever reads, so a

0 commit comments

Comments
 (0)