Skip to content

Commit 59cf244

Browse files
os-steveclaude
andauthored
fix(pm): check-half-states reads every Blocked-by: directive on a line, and every ref in one (#19455)
Fixes #19439 Clause-②: no `check-half-states.mjs` read **one** target from a line stating several, and for a directive whose remainder is prose it read whichever number the ref walk reached first. Both readings under-report a block, and H19's row is what the unlock scan's release act fires from. ## The two measured shapes, before and after (live bodies, read this pass) | shape | before | after | |---|---|---| | objectui#8347 body — NINE backticked `·`-separated directives on **one** line | `directiveValues` 1 value, `blockedByTargets` **1** target (`#8345`) — H19 printed 「1 of 1 … CLOSED」 | 9 values, **9** targets (`#8345, #8648 … #8655`) | | objectui#8093 body — **one** directive whose remainder is prose naming three numbers | **1** target, `objectui#7434` — a possessive that was never a blocker; both real blockers dropped | **3** targets, `objectui#7434, #8090, objectui#8065` — both real blockers read | | DARK control — one directive | 1 target | 1 target | | DARK control — prose naming `#9` and `#10` outside any directive | 0 | 0 | ⚠️ One correction to the reading this was dispatched on: the seat measured 「nine directives on TWO lines → 2 targets」 from the card's *quoted* (line-wrapped) rendering. objectui#8347's **live** body carries all nine on ONE line, so the reading there is **1** target, not 2 — which is exactly the 「1 of 1」 H19 published. The defect is one notch worse than the claim stated. ## What lands Two halves, in the two places they belong. `scripts/pm/check-half-states.mjs` only, net **+79** lines (101 added / 22 removed), including 16 self-test cases. 1. **The shared decorated-directive reader (`directiveValues`) yields EVERY directive on a line.** A line splits at every further key that follows a **separator run** — decoration, spaces and one of `·•|,;、`, and nothing else. ⛔ A key that follows a WORD is still prose, so the anchoring contract that keeps 「seats park the `Blocked-by: #1` line in comments」 out of the index is unchanged. Every directive family reads through this one reader — `Blocked-by`, `Restart-when` (H9), `Unlock-action`, `Maintainer-action`, `Prior-rulings`, `Reviewed-by`, `Seam` — so all of them gain the multiplicity, and the pin that they invent nothing from prose is asserted on a NON-`Blocked-by:` key. 2. **The `Blocked-by:` walk takes EVERY ref in a directive's value**, not the leading run. ## The prose-remainder decision, on the four axes The card offered two readings for a directive whose remainder is prose: every `#N` in the value is a target, or the value is reported UNJUDGED with the line quoted. **Every `#N` is a target.** - **实际业务需求** — measured, not assumed. The prose-remainder shape is live on both boards (8 of the 116 blocked cards that carry a directive), and in 4 of them the old reader extracted **zero** targets from a directive a seat really wrote: objectstack#14570, objectstack#13458, objectui#9273, objectui#5987. A stated block that the machine turns into nothing is the same silence this item exists to end. - **项目长远合理性** — one rule ("a directive's value names blockers; every reference in it is one") instead of two ("the leading run, unless…"). The UNJUDGED reading needs a second verdict channel out of a parser that returns `{repo, number}` refs; H19's `unresolved` state is produced by the RESOLVER, so feeding it from the extractor means a new return shape through `buildBlockingIndex`, `blockerTargetsFor` and `blockerChannelKeys` — and the card ⛔ forbids changing H19's UNJUDGED arm. - **防 AI 写错** — decisive. The leading run was taken to keep a context `#N` out of the index, and it does not buy that: on objectui#8093 it filed the possessive as a dependent AND dropped both real blockers. So it files a phantom either way; 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, names it on the row, and says PARTIAL) — ⛔ never found a release. That is this file's standing posture on an ambiguity (never resolve one in the direction of release), applied to the value instead of to the resolution. - **创业阶段不扩散** — no new surface, no new verdict, no new flag, no config. The declared file surface only. ⚠️ **The price, measured rather than asserted**: a context `#N` in a directive's prose becomes a target. On these boards that is 13 new reverse-index edges (see below) and, where a comment narrates in a line-initial `Blocked-by:` sentence, a row that says PARTIAL where it used to say 「every target it names is closed」. The row still fires and names the extra target, and — unlike the comment archive this file refuses to found a verdict on — the line carrying it is rewritable by the seat that wrote it. ## The census the card asks for as the first act Population: **140** open `pm:blocked` cards on both boards (objectstack 68, objectui 72), **body AND comments** (140 comment sets fetched), read 2026-09-20T23:10Z. Both readers run over the same texts; the old one is objectstack at `57ceb9d`. - **116** of the 140 carry at least one `Blocked-by:` directive (objectstack 62, objectui 54) — 167 directive values under the old reader (89 + 78). - **LINES carrying ≥2 directives: 3 — all on objectui, all on ONE card** (#8347: one body line, two comment lines). objectstack: **0**. ⇒ the MULTIPLICITY shape is one card on two boards today. - Texts whose extracted target set changes: **10**, on **9** cards (objectstack 3, objectui 6). Nothing is lost on either board (−0 refs, −0 index edges). - Cards whose **JUDGED** set changes — H19's own count, body-first carrier — **2 of 140**: objectui#8347 (1 → 9, the measured false-release row) and objectui#9273 (0 → 1, a body-less card whose comment states its blocker in prose). objectstack: **0 of 68**, because those narrating lines are comment-borne beside body lines and the carrier rule keeps the body authoritative. - Reverse index (H14): objectstack 83 → 87 edges (+4, −0), objectui 30 → 39 (+9, −0). Of the 13 new edges, 8 name a CLOSED target (H14 judges open cards, so no row), 1 names a card already carrying `pm:blocking` (objectstack#13457), **3 are open `pm:blocked` cards that really are #8347's blockers** (objectui#8649, #8652, #8654 — a correct missing-cache row that was invisible before), and 1 is objectstack#11333, named as the parent in a triage audit comment — the single edge whose blocker status is a judgement call. - ⭐ **Lit control**: objectui#8347 is in the population and moves 1 → 9; and 116 of 140 cards read as carrying a directive — a scan that read nothing prints 0 for both. **Dark control**: prose naming `#9`/`#10` outside a directive → 0 targets under BOTH readers; one directive → 1 under both. - ⛔ **Radius, stated**: open `pm:blocked` cards only. objectui#8093 — triage's substitution specimen — is **NOT** in this population: it carries `pm:queue` today, released since that reading. So the substitution class is under-counted here by construction, not absent. ## Verification - `node scripts/pm/check-half-states.mjs --self-test` :: exit 0 — 5160 cases pass (16 added), every battery above its pin; `SELF_TEST_BATTERY_FLOOR` untouched (8), no new battery opened. - The derived union, taken at the final commit `da8c088`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) → 42 commands, **42 run, every one exit 0**, reconciled with `--ran` over a record carrying each exit code: 「42 derived, 42 run, 0 NOT-MEASURED, 0 UNRUN … a DERIVED zero」. `pnpm check:pm-dispatch-gates` took 962.5s on this box and is in that 42. Every exit code was captured before any pipe. - New self-test cases: the two measured live shapes as fixtures (the #8347 line byte-identical to the live body), both dark controls, the family pin on `Restart-when` plus its prose control, the URL-fragment control, and the end-to-end count — a two-directive body line with one closed and one open target reads 「1 of 2」, ⛔ never 「1 of 1」. - The pin this overturns is rewritten in place, not deleted: `Blocked-by: #123 (see #456 for the background)` now reads `123,456`, with the reason beside it. ## 维护者速读(草稿) - **改了什么**:巡检脚本读 `Blocked-by:` 的两处修正——一行上写了几条指令就读几条;一条指令的值里有几个 `#N` 就算几个阻塞目标。 - **为什么改**:H19 的行是「解锁放行」的触发器。它在 objectui#8347 上打印「1 of 1 已关闭」,而该卡九个阻塞里三个还开着、卡面自己写着「未裁决完不得派发」;另一张卡把句中的所有格 `objectui#7434's` 当成了阻塞方,两个真阻塞被丢掉。 - **风险与代价(含回滚)**:代价已实测——指令值里的「顺带提到的卡号」也会算作目标,两板共新增 13 条反向索引边(其中 3 条是本来就该有的真阻塞),H19 可能把「全部关闭」改口为「部分关闭」并点名多出来的那张卡。方向只会扣住放行、不会造成放行。回滚 = revert 本 PR 的单文件改动。 - **席位意见**:(留空,待席位定稿) - **你要做的**:确认「宁可扣住放行,也不接受读错后放行」这条取舍;若要改为「读不准就报 UNJUDGED」,那是另一张卡(需要改 H19 的第三态入口,本卡明令不动)。 ## Acceptance notes - `noted, not filed`: four of the changed texts are **narration** lines in comments that begin with a decorated `Blocked-by:` key and then describe a re-point in prose (「`Blocked-by:` re-pointed `#14096` → `#15195`…」). The line-anchored reader counts them as directives — that is pre-existing and deliberate (it models the literal grep the unlock scan runs), and it is what makes their prose numbers reachable at all. Narrowing it is a change to WHICH LINES are read, which this card ⛔ does not authorise. Carrier: the next card that touches `directiveValues`' anchoring. - `noted, not filed`: a cross-repo qualifier separated from its `#` by decoration (`objectui**#7434**`) still reads as a LOCAL ref, before and after — the ref grammar is unchanged in that respect. Carrier: none today; no live specimen on either board in this census. --- _Generated by [Claude Code](https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d493ad commit 59cf244

1 file changed

Lines changed: 101 additions & 22 deletions

File tree

‎scripts/pm/check-half-states.mjs‎

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1873,6 +1873,19 @@ function stripMatchingDecoration(value, opener) {
18731873
return out;
18741874
}
18751875

1876+
/**
1877+
* What may sit BETWEEN two directives on one line: decoration, spaces and a
1878+
* list separator, nothing else. That class is the whole guard — a key after a
1879+
* WORD stays the mid-sentence mention the anchoring above protects (「seats
1880+
* park the `Blocked-by: #1` line in comments」), a key after 「` · `」 is the
1881+
* second directive of a list a seat really wrote.
1882+
*/
1883+
const DIRECTIVE_SEPARATOR = String.raw`[ \t]*(?:${DIRECTIVE_MARKER}[ \t]*)*[·•|,;、]+[ \t]*`;
1884+
1885+
/** A FURTHER directive on the same line, its own opening decoration captured. */
1886+
const nextDirectiveOnLine = (key) =>
1887+
new RegExp(`${DIRECTIVE_SEPARATOR}((?:${DIRECTIVE_MARKER}[ \\t]*)*)${directiveKey(key)}[ \\t]*`);
1888+
18761889
/**
18771890
* Every value carried by a `<key>:` directive line in this text, decoration
18781891
* removed, empties dropped — the one reader H9 and the `Blocked-by:` index
@@ -1883,16 +1896,37 @@ function stripMatchingDecoration(value, opener) {
18831896
* silently kept its trailing backtick is exactly the regression this shares a
18841897
* cause with.
18851898
*
1899+
* ## EVERY directive on the line, never the first one alone
1900+
*
1901+
* A seat fits as many directives on a line as it likes — nine `·`-separated
1902+
* backticked ones is a MEASURED live shape — and this used to hand back ONE
1903+
* value per line: the first key, then the rest of the line as its value. Every
1904+
* family therefore read the others as that value's prose, which for the
1905+
* `Blocked-by:` index meant eight blockers dropped in silence and a block that
1906+
* reads as EXPIRED (「1 of 1」 on a card stating nine). So the line splits at
1907+
* every FURTHER key after a separator run; ⛔ a key after a WORD is prose.
1908+
*
18861909
* @param {string} text
18871910
* @param {'Blocked-by'|'Restart-when'|'Maintainer-action'|'Unlock-action'} key
18881911
* @returns {string[]}
18891912
*/
18901913
export function directiveValues(text, key) {
18911914
const re = new RegExp(`^${DIRECTIVE_PREFIX}${directiveKey(key)}[ \\t]*(\\S.*)$`, 'gm');
1915+
const next = nextDirectiveOnLine(key);
18921916
const out = [];
1893-
for (const m of String(text ?? '').matchAll(re)) {
1894-
const value = stripMatchingDecoration(m[2], m[1]);
1917+
const take = (raw, opener) => {
1918+
const value = stripMatchingDecoration(raw, opener);
18951919
if (value) out.push(value);
1920+
};
1921+
for (const m of String(text ?? '').matchAll(re)) {
1922+
let opener = m[1];
1923+
let rest = m[2];
1924+
for (let split = next.exec(rest); split; split = next.exec(rest)) {
1925+
take(rest.slice(0, split.index), opener);
1926+
opener = split[1];
1927+
rest = rest.slice(split.index + split[0].length);
1928+
}
1929+
take(rest, opener);
18961930
}
18971931
return out;
18981932
}
@@ -3671,13 +3705,21 @@ export function h13DomainWithoutPmState(issue, nowMs = Date.now()) {
36713705
* line because it sits in code, this one reads a line whose code markers
36723706
* are the author formatting a directive. Both serve the same test — would
36733707
* the unlock sweep's grep act on this line — and its answer here is yes.
3674-
* 2. **Only the LEADING ref run is taken.** Real lines carry trailing prose —
3675-
* 「Blocked-by: #9689 (the relocation it needs is the same edit)」 — and
3676-
* prose can name a second card that is context, not a blocker. Scanning
3677-
* the whole value would manufacture a dependent for it, and the cost lands
3678-
* on a THIRD card (a phantom "missing cache" row against someone who did
3679-
* nothing wrong). So the scan walks refs and separators from the start of
3680-
* the value and stops at the first token that is neither.
3708+
* 2. **EVERY ref in a directive's value is a target**, never the leading run
3709+
* alone. That run was taken to keep a `#N` in trailing prose out of the
3710+
* index (a phantom "missing cache" row against a third card), and it does
3711+
* not buy that: a value whose FIRST ref is prose — the measured
3712+
* 「Blocked-by: objectui#7434's PR #8090 … and whatever PR repairs
3713+
* objectui#8065」 — indexed the possessive, a card that was never a
3714+
* blocker, and dropped both real blockers. So the run files a phantom too;
3715+
* what it ADDS is substitution, and a block whose real blockers are
3716+
* invisible reads as expired. Reading every ref can only ADD a target, and
3717+
* an extra target can only WITHHOLD a discharge (H19 counts it open and
3718+
* names it on the row) — ⛔ never found a release: this file's standing
3719+
* posture on an ambiguity (#4690), applied to the value rather than to the
3720+
* resolution. The context ref that rides along is the price, it is named
3721+
* on the row, and unlike the comment archive the line carrying it is
3722+
* rewritable by the seat that wrote it.
36813723
*
36823724
* The key is matched case-sensitively and line-anchored, byte-stable like H4
36833725
* 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()) {
36863728
* @param {string} body
36873729
* @returns {{ repo: string|null, number: number }[]}
36883730
*/
3731+
const BLOCKED_BY_REF = /(?<![A-Za-z0-9._\/-])([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?)?#(\d+)/gu;
3732+
36893733
export function blockedByTargets(body) {
36903734
const out = [];
3691-
// The shared decorated-directive reader: the value arrives trimmed and with
3692-
// a matching trailing marker already removed, so the ref walk below sees
3693-
// 「#9823」 whether the author wrote it bare, bulleted, bolded or in code.
3735+
// The shared decorated-directive reader: ONE value per directive, trimmed
3736+
// and with a matching trailing marker already removed, so the scan below
3737+
// sees 「#9823」 whether the author wrote it bare, bulleted, bolded or in
3738+
// code. A qualifier must TOUCH its `#` and no match may START inside a
3739+
// path-like token, so a URL fragment in a value names no target.
36943740
for (const value of directiveValues(body, 'Blocked-by')) {
3695-
let rest = value;
3696-
for (;;) {
3697-
const ref = /^[\s,;+、]*(?:and[ \t]+)?([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?)?#(\d+)/u.exec(rest);
3698-
if (!ref) break;
3741+
for (const ref of value.matchAll(BLOCKED_BY_REF)) {
36993742
out.push({ repo: ref[1] ?? null, number: Number(ref[2]) });
3700-
rest = rest.slice(ref[0].length);
37013743
}
37023744
}
37033745
return out;
@@ -26713,13 +26755,13 @@ async function selfTest() {
2671326755
numbersOf(blockedByTargets('Blocked-by: #9689 (the relocation it needs is the same edit; doing them in the other order means touching the line twice).')),
2671426756
'9689',
2671526757
);
26716-
// The reason only the LEADING run is taken: a `#N` inside the trailing prose
26717-
// is context, not a blocker, and indexing it would file a phantom
26718-
// missing-cache row against a third card that did nothing wrong.
26758+
// ⭐ The leading run is GONE: a `#N` in the trailing prose is now a target
26759+
// too. It can only WITHHOLD a discharge, while the run it replaces
26760+
// SUBSTITUTED a false blocker for two real ones (the #8093 shape below).
2671926761
t(
26720-
'blockedByTargets: a ref inside the trailing prose is NOT a blocker',
26762+
'blockedByTargets: a ref inside the trailing prose is a target too, and can only withhold',
2672126763
numbersOf(blockedByTargets('Blocked-by: #123 (see #456 for the background)')),
26722-
'123',
26764+
'123,456',
2672326765
);
2672426766
t('blockedByTargets: a comma-separated run is all blockers', numbersOf(blockedByTargets('Blocked-by: #6234, #6245')), '6234,6245');
2672526767
t('blockedByTargets: the `and` connector is a separator', numbersOf(blockedByTargets('Blocked-by: #1 and #2')), '1,2');
@@ -26748,6 +26790,34 @@ async function selfTest() {
2674826790
// live machinery and must be reported as part of the index it feeds.
2674926791
t('blockedByTargets: a fenced line still counts (this reader greps, it does not read prose)', numbersOf(blockedByTargets('```\nBlocked-by: #42\n```')), '42');
2675026792

26793+
// -- Multiplicity and substitution, both MEASURED on live bodies ----------
26794+
// objectui#8347: NINE `·`-separated backticked directives on ONE line. The
26795+
// reader took the first and H19 printed 「1 of 1 … CLOSED」 — the row the
26796+
// unlock scan releases from — while three of the nine were open.
26797+
const nineOnOneLine =
26798+
'`Blocked-by: #8345` · `Blocked-by: #8648` · `Blocked-by: #8649` · `Blocked-by: #8650` · ' +
26799+
'`Blocked-by: #8651` · `Blocked-by: #8652` · `Blocked-by: #8653` · `Blocked-by: #8654` · `Blocked-by: #8655`';
26800+
t('blockedByTargets: the #8347 shape — nine directives on one line are NINE targets', numbersOf(blockedByTargets(nineOnOneLine)), '8345,8648,8649,8650,8651,8652,8653,8654,8655');
26801+
t('directiveValues: …and each directive is its own CLEAN value, decoration stripped per directive', directiveValues(nineOnOneLine, 'Blocked-by').join('|'), '#8345|#8648|#8649|#8650|#8651|#8652|#8653|#8654|#8655');
26802+
// objectui#8093: ONE directive whose remainder is prose naming three
26803+
// numbers. The leading run returned the POSSESSIVE — never a blocker — and
26804+
// dropped both real ones: a substituted target answers the liveness
26805+
// question about the wrong card, which is the worse of the two failures.
26806+
const proseRemainder =
26807+
"`Blocked-by:` objectui#7434's PR #8090 (the `AGENTS.md` half, draft, awaiting human merge) " +
26808+
'and whatever PR repairs objectui#8065 (the `skills/objectui` half).';
26809+
t('blockedByTargets: the #8093 shape — both REAL blockers are read, not dropped', blockedByTargets(proseRemainder).map((r) => `${r.repo ?? ''}#${r.number}`).join(','), 'objectui#7434,#8090,objectui#8065');
26810+
// The dark controls: one directive is one target, and prose OUTSIDE a
26811+
// directive is not a directive however many numbers it names.
26812+
t('blockedByTargets: ONE directive still yields exactly one target', numbersOf(blockedByTargets('`Blocked-by: #4242`')), '4242');
26813+
t('blockedByTargets: prose naming `#n` OUTSIDE any directive invents nothing', numbersOf(blockedByTargets('We will wait for #9 and #10 before starting.')), '');
26814+
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');
26815+
// The whole directive family reads through `directiveValues`, so all of it
26816+
// gains the multiplicity — pinned on a NON-`Blocked-by:` key, beside the pin
26817+
// that a key after a WORD is still prose and invents nothing.
26818+
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');
26819+
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);
26820+
2675126821
// The index.
2675226822
const idx = (issues) => buildBlockingIndex(issues, { repo: 'objectstack-ai/objectstack' });
2675326823
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() {
2714227212
t('H19: …names the target that is still open', partialRow.includes('`#3`'), true);
2714327213
t('H19: …and does not decide the card is unblocked', partialRow.includes('it does not decide it'), true);
2714427214
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);
27215+
// ⭐ End to end from the BODY, because the COUNT is what a reader trusts: two
27216+
// directives on ONE line, one closed and one open, must read 「1 of 2」 — the
27217+
// 「1 of 1」 it used to print is a FULL discharge on a card with an open
27218+
// blocker, which is the row the unlock scan releases from.
27219+
const twoOnOneLine = blockedCard(1, '`Blocked-by: #2` · `Blocked-by: #3`');
27220+
const twoResolved = blockerTargetsFor(twoOnOneLine, null, REPO_OS).map((tg, i) => ({ ...tg, state: i === 0 ? 'closed' : 'open', closedAt: null, detail: null }));
27221+
t('H19: a two-directive line resolves TWO targets', twoResolved.map((tg) => tg.number).join(','), '2,3');
27222+
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);
27223+
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);
2714527224

2714627225
// UNRESOLVED — never reads as clean, and never reads as closed either.
2714727226
const unresolvedOnly = h19BlockOutlivedBlocker(blockedCard(1), [foreign('objectstack-ai/cloud', 88, 'unresolved', { detail: 'HTTP 404' })]);

0 commit comments

Comments
 (0)