From da8c088ab04facf345070f5b3d281563098dc720 Mon Sep 17 00:00:00 2001 From: os-steve Date: Sun, 20 Sep 2026 22:53:16 +0000 Subject: [PATCH] fix(pm): read every `Blocked-by:` directive on a line, and every ref in one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-half-states.mjs` took ONE target from a line stating several, and for a directive whose remainder is prose it took whichever number the ref walk reached first. Both readings under-report a block that H19's row then releases: a live body with nine `·`-separated backticked directives resolved 1 target and printed 「1 of 1 CLOSED」, and a body whose directive opens with a possessive resolved that possessive — a card that was never a blocker — and dropped both real blockers. Two halves, in the two places they belong: - the shared decorated-directive reader now splits a line at every FURTHER key that follows a separator run, so each directive yields its own value and the whole directive family gains it. A key after a WORD is still prose. - the `Blocked-by:` walk now takes EVERY ref in a directive's value instead of the leading run. The run was taken to keep a context `#N` out of the index, and it did not: a prose-opening value filed a phantom AND dropped the real blockers. An extra target can only withhold a discharge; a dropped one releases a live block. Self-test: the two measured live shapes as fixtures (9 targets, both real blockers), the two dark controls (one directive is one target; prose outside a directive invents nothing), the family pin on a non-`Blocked-by:` key, and the end-to-end count 「1 of 2」 from a two-directive body line. Claude-Session: https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi Co-authored-by: Claude --- scripts/pm/check-half-states.mjs | 123 +++++++++++++++++++++++++------ 1 file changed, 101 insertions(+), 22 deletions(-) diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 9bd213db644..e322a97c31f 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -1873,6 +1873,19 @@ function stripMatchingDecoration(value, opener) { return out; } +/** + * What may sit BETWEEN two directives on one line: decoration, spaces and a + * list separator, nothing else. That class is the whole guard — a key after a + * WORD stays the mid-sentence mention the anchoring above protects (「seats + * park the `Blocked-by: #1` line in comments」), a key after 「` · `」 is the + * second directive of a list a seat really wrote. + */ +const DIRECTIVE_SEPARATOR = String.raw`[ \t]*(?:${DIRECTIVE_MARKER}[ \t]*)*[·•|,;、]+[ \t]*`; + +/** A FURTHER directive on the same line, its own opening decoration captured. */ +const nextDirectiveOnLine = (key) => + new RegExp(`${DIRECTIVE_SEPARATOR}((?:${DIRECTIVE_MARKER}[ \\t]*)*)${directiveKey(key)}[ \\t]*`); + /** * Every value carried by a `:` directive line in this text, decoration * removed, empties dropped — the one reader H9 and the `Blocked-by:` index @@ -1883,16 +1896,37 @@ function stripMatchingDecoration(value, opener) { * silently kept its trailing backtick is exactly the regression this shares a * cause with. * + * ## EVERY directive on the line, never the first one alone + * + * A seat fits as many directives on a line as it likes — nine `·`-separated + * backticked ones is a MEASURED live shape — and this used to hand back ONE + * value per line: the first key, then the rest of the line as its value. Every + * family therefore read the others as that value's prose, which for the + * `Blocked-by:` index meant eight blockers dropped in silence and a block that + * reads as EXPIRED (「1 of 1」 on a card stating nine). So the line splits at + * every FURTHER key after a separator run; ⛔ a key after a WORD is prose. + * * @param {string} text * @param {'Blocked-by'|'Restart-when'|'Maintainer-action'|'Unlock-action'} key * @returns {string[]} */ export function directiveValues(text, key) { const re = new RegExp(`^${DIRECTIVE_PREFIX}${directiveKey(key)}[ \\t]*(\\S.*)$`, 'gm'); + const next = nextDirectiveOnLine(key); const out = []; - for (const m of String(text ?? '').matchAll(re)) { - const value = stripMatchingDecoration(m[2], m[1]); + const take = (raw, opener) => { + const value = stripMatchingDecoration(raw, opener); if (value) out.push(value); + }; + for (const m of String(text ?? '').matchAll(re)) { + let opener = m[1]; + let rest = m[2]; + for (let split = next.exec(rest); split; split = next.exec(rest)) { + take(rest.slice(0, split.index), opener); + opener = split[1]; + rest = rest.slice(split.index + split[0].length); + } + take(rest, opener); } return out; } @@ -3671,13 +3705,21 @@ export function h13DomainWithoutPmState(issue, nowMs = Date.now()) { * line because it sits in code, this one reads a line whose code markers * are the author formatting a directive. Both serve the same test — would * the unlock sweep's grep act on this line — and its answer here is yes. - * 2. **Only the LEADING ref run is taken.** Real lines carry trailing prose — - * 「Blocked-by: #9689 (the relocation it needs is the same edit)」 — and - * prose can name a second card that is context, not a blocker. Scanning - * the whole value would manufacture a dependent for it, and the cost lands - * on a THIRD card (a phantom "missing cache" row against someone who did - * nothing wrong). So the scan walks refs and separators from the start of - * the value and stops at the first token that is neither. + * 2. **EVERY ref in a directive's value is a target**, never the leading run + * alone. That run was taken to keep a `#N` in trailing prose out of the + * index (a phantom "missing cache" row against a third card), and it does + * not buy that: a value whose FIRST ref is prose — the measured + * 「Blocked-by: objectui#7434's PR #8090 … and whatever PR repairs + * objectui#8065」 — indexed the possessive, a card that was never a + * blocker, and dropped both real blockers. So the run files a phantom too; + * what it ADDS is substitution, and a block whose real blockers are + * invisible reads as expired. Reading every ref can only ADD a target, and + * an extra target can only WITHHOLD a discharge (H19 counts it open and + * names it on the row) — ⛔ never found a release: this file's standing + * posture on an ambiguity (#4690), applied to the value rather than to the + * resolution. The context ref that rides along is the price, it is named + * on the row, and unlike the comment archive the line carrying it is + * rewritable by the seat that wrote it. * * The key is matched case-sensitively and line-anchored, byte-stable like H4 * and H9: a lowercase or mid-sentence spelling is a line the real scan cannot @@ -3686,18 +3728,18 @@ export function h13DomainWithoutPmState(issue, nowMs = Date.now()) { * @param {string} body * @returns {{ repo: string|null, number: number }[]} */ +const BLOCKED_BY_REF = /(? `${r.repo ?? ''}#${r.number}`).join(','), 'objectui#7434,#8090,objectui#8065'); + // The dark controls: one directive is one target, and prose OUTSIDE a + // directive is not a directive however many numbers it names. + t('blockedByTargets: ONE directive still yields exactly one target', numbersOf(blockedByTargets('`Blocked-by: #4242`')), '4242'); + t('blockedByTargets: prose naming `#n` OUTSIDE any directive invents nothing', numbersOf(blockedByTargets('We will wait for #9 and #10 before starting.')), ''); + t('blockedByTargets: a URL fragment inside a value is not a target', numbersOf(blockedByTargets('Blocked-by: #12 (trail: https://example.test/issues/8347#40)')), '12'); + // The whole directive family reads through `directiveValues`, so all of it + // gains the multiplicity — pinned on a NON-`Blocked-by:` key, beside the pin + // that a key after a WORD is still prose and invents nothing. + t('directiveValues: a second directive on one line is read for EVERY family', directiveValues('`Restart-when: closed acme/w#9` · `Restart-when: manual — x`', 'Restart-when').join('|'), 'closed acme/w#9|manual — x'); + t('directiveValues: …and a key after a WORD is still prose, not a second directive', directiveValues('Restart-when: closed acme/w#9 — seats park the `Restart-when: manual` line in comments', 'Restart-when').length, 1); + // The index. const idx = (issues) => buildBlockingIndex(issues, { repo: 'objectstack-ai/objectstack' }); t('index: a local ref creates an entry', idx([carded(9849, [], 'Blocked-by: #9823')]).get(9823).join(','), '9849'); @@ -27142,6 +27212,15 @@ async function selfTest() { t('H19: …names the target that is still open', partialRow.includes('`#3`'), true); t('H19: …and does not decide the card is unblocked', partialRow.includes('it does not decide it'), true); t('H19: two closed of two reads as 2 of 2', h19row(blockedCard(1), [target(2, 'closed'), target(3, 'closed')]).includes('2 of 2'), true); + // ⭐ End to end from the BODY, because the COUNT is what a reader trusts: two + // directives on ONE line, one closed and one open, must read 「1 of 2」 — the + // 「1 of 1」 it used to print is a FULL discharge on a card with an open + // blocker, which is the row the unlock scan releases from. + const twoOnOneLine = blockedCard(1, '`Blocked-by: #2` · `Blocked-by: #3`'); + const twoResolved = blockerTargetsFor(twoOnOneLine, null, REPO_OS).map((tg, i) => ({ ...tg, state: i === 0 ? 'closed' : 'open', closedAt: null, detail: null })); + t('H19: a two-directive line resolves TWO targets', twoResolved.map((tg) => tg.number).join(','), '2,3'); + t('H19: …so the row reads 1 of 2, never 1 of 1', h19row(twoOnOneLine, twoResolved, REPO_OS).includes('1 of 2 `Blocked-by:` target(s)'), true); + t('H19: …and calls it a PARTIAL discharge rather than a full one', h19row(twoOnOneLine, twoResolved, REPO_OS).includes('Every target it names is closed'), false); // UNRESOLVED — never reads as clean, and never reads as closed either. const unresolvedOnly = h19BlockOutlivedBlocker(blockedCard(1), [foreign('objectstack-ai/cloud', 88, 'unresolved', { detail: 'HTTP 404' })]);