Skip to content

fix(pm): check-widening-tells separates "cannot read" from "still open" in the tombstone decline - #18920

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-18488-matching-closer-unreadable-vs-open
Sep 18, 2026
Merged

os-bill merged 2 commits into
mainfrom
claude/issue-18488-matching-closer-unreadable-vs-open

Conversation

@os-bill

@os-bill os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18488

Clause-②: no

The defect

declaresUnwritableKey in scripts/pm/check-widening-tells.mjs declines a T1 widening tell when a key line's value opens a refusal helper and the call is still open at the end of the line: every remaining byte is then inside the argument list, and an argument chains onto nothing.

It read "still open" off a line-shaped scan that answered -1 for that and for "this reader cannot read the rest of the line" with the same number. So a closed call whose tail the reader cannot lex read as open, and the tombstone decline swallowed a key an author may still write.

The carrier line, constructed by PR #18427's at-tier review against the code it was passing:

legacy: retiredKey(/\(/.source).or(z.string()),

Spelled out, in case the platform rewrites the bytes above: the key legacy, whose value calls retiredKey with the .source of a regex literal whose only content is one escaped open parenthesis, and then chains .or(z.string()) onto the result.

This card is the carrier for what that review explicitly left open. It is not an appeal of its VERDICT: PASS (record 5700134264), and it does not reopen the multi-line chained-close quiet direction ruled at 5696535481: that one is a line-shape choice, this one is a parser limit, and its overturn condition is untouched by this PR.

The mechanism, re-measured rather than inherited

The scan answered -1 down five paths (line numbers re-taken at 1fb36ca44d, where the file is byte-identical to 84ba4a8476):

path line at base meaning
unterminated delimited comment :1938 cannot parse
a line comment :1942 cannot parse
unterminated string literal :1946 cannot parse
a closer matching nothing on the stack :1951 cannot parse
fell off the end with a non-empty stack :1956 genuinely still open

⭐ The carrier line does not arrive through the first four. A slash that opens a regex literal is neither a delimited-comment opener nor a line-comment opener, so the reader never recognises the literal, pushes the unpaired parenthesis inside it onto its stack, and reaches the end of the line with the stack non-empty — the fifth path, the one that means genuinely open. Flagging only the four "cannot parse" returns would leave the carrier exactly as silent as it was.

Two line numbers in the dispatch differ by one from what I re-took: the mismatched-closer return is at :1951 (:1952 is stack.pop()), and the fall-off-the-end return is at :1956 (:1957 closes the function). The substance of that reading is exactly right, and the conclusion it draws is the one this PR implements. The dispatch also names the predicate declaresRetiredKeyTombstone, which is its name at PR #18427's head; #18560 and #18702 have since renamed it declaresUnwritableKey.

The change

The scan is now one function, readToCloser, that reports certainty separately from the index, and matchingCloser is a thin wrapper over its close field — byte-for-byte the same index for its four other callers.

The question it answers is not "which return did the scan take" but is it certain the call does not close on this line:

  • Certain (unreadable: false) — fell off the end with a non-empty stack; a line comment took the rest of the line; a delimited comment or a template literal opened and ran past the end of the line. The last two legitimately span lines, so everything left really is inside a comment or inside one argument.
  • Not certain (unreadable: true) — a single- or double-quoted string opened and never closed (neither spans lines in TypeScript, so the source is malformed or the opener was mis-lexed); a closer matched nothing on the stack; or a slash opened neither comment form, which is a division operator or a regex literal this reader does not lex.

The open branch of declaresUnwritableKey declines only when the scan was certain. The closing branch is untouched: its tail test is already tight enough that a chained arm cannot pass it.

⚠️ One deliberate deviation from the card's recommendation 1, stated because it is a deviation. The card lists "an unterminated string" among the unreadable tails. An unterminated template literal is not one: a backtick string legitimately spans lines, so the call is genuinely still open and every remaining byte really is inside the argument — firing there would be a new false positive on a shape an author may honestly write, which is the shape #17955 exists to remove. The same reasoning keeps an unterminated delimited comment on the certain side. Quoted strings and mismatched closers stay on the unreadable side, as recommended. Measured population of all four forms in this tree and in its available history: 0, so this choice moves no verdict today; it only decides which way a future line falls.

Readings — every leg with both sides

Re-taken at 1fb36ca44d; none of the dispatch's figures were inherited.

LIT. Reverse-verified from the committed state: the file reverted to origin/main, the probe run, the file restored (blob b147e0bfdccb equals HEAD, git diff HEAD empty), the probe run again.

reader declaresUnwritableKey tells
before (file at origin/main) true — read as a tombstone 0, silent
after false 1, T1

DARK. The verdict of declaresUnwritableKey compared line for line, before and after, over every located key line, not a count:

  • in tree: 261 located refusal-helper key lines over 6,545 tracked TypeScript files, of which 255 are judged tombstones across 66 files — 178 not closing on the key line, 77 closing. 0 verdict flips.
  • in history: 299 tombstone-shaped patch rows over the 231 commits that add or remove one (292 added, 7 removed, full non-shallow history). 0 verdict flips.
  • the string-awareness controls the card names reproduce exactly: 49 of those rows carry a quote and 4 carry a backtick inside a quoted prescription — all four are in packages/spec/src/system/metadata-form-zod-reconciliation.test.ts, off the judged surface, and all four keep true. A line-level includes of either character would have fired on every one of them; the flag is raised inside the scan that already skips string literals, so they raise nothing.

The card's in-tree figure was 254 across 66 files with 178 multi-line and 76 closing; one more closing line has landed since, which is the whole difference. The card's historical row count was 322 (291 added, 31 removed) against 299 here (292 added, 7 removed); the added sides agree to within the one row this tree has gained, and both of the card's controls reproduce on the nose, so the window differs and the population does not.

The boundary. This is the leg that matters, because the easy error is the expensive one: reading every -1 as "keep firing" re-fires all 178 multi-line tombstone key lines in this tree, which is the exact false positive #17955 exists to remove. Separated, not merged:

line before after
the carrier line above — closed call, unreadable tail silent T1
legacy: retiredKey( — genuinely open, 148 landed lines silent silent
create: retiredKey(capRemoved('create', — genuinely open, 30 landed lines silent silent
legacy: retiredKey( // the prescription is below silent silent
a slash or a backtick inside the quoted prescription, either branch silent silent
a balanced regex literal that really is the whole value silent silent

Ablations, each landed on disk with an anchor-hit and blob check, each restored to a blob equal to HEAD with git diff HEAD empty:

  1. the open branch put back to an unconditional decline → 4 of the 8 new cases red, self-test exit 1.
  2. only the regex-literal trigger deleted, everything else kept → the same 4 red. The flag comes from the slash, not from falling off the end.
  3. the battery floor moved from 37 to 38 → red, naming the battery. The battery registers exactly 37.

Gates

  • --self-test: 481 cases, exit 0. The #17955 battery pin at its floor line moves 29 → 37 for the 8 cases added.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 29 derived, all 29 run, exit codes landed to disk first, then reconciled with --ran — 29 run, 0 NOT-MEASURED, 0 UNRUN, every exit code 0 and the reconciliation reports a derived zero rather than a claimed one. The first derivation warned STALE TREE against scripts/symbol-anchors.mjs; origin/main was merged in and the list re-derived clean, and the list is identical either way.
  • the 7 artifact-roster families whose roster sits in a directory one of my paths is in: 6 exit 0. pnpm check:published-readme-exports exits 3 — NOT MEASURED, its own prerequisite code for an unbuilt dist across 46 packages; this diff touches no package and no README.
  • pnpm lint over the whole repo: exit 0 in 77s. No narrowing was needed, so none is claimed.
  • consumers: pnpm check:pm-dispatch-gates, pnpm check:pm-widening-tells, pnpm check:nul-bytes all exit 0; a control-byte grep over the changed file finds none.

Changeset — skip-changeset, measured

The diff is one path, scripts/pm/check-widening-tells.mjs, and 0 paths under .changeset/ (three-dot against origin/main). The root package is private with no files[]; 0 of the 70 non-private packages' files[] mentions scripts (control: all 70 declare a non-empty files[]). The new internal function readToCloser has 0 occurrences under packages/ (control: retiredKey appears in 467 files there). Nothing published moves, so Clause-②: no holds.

Acceptance notes

Observed while measuring, not filed and not fixed here:


Generated by Claude Code

…cline

`declaresUnwritableKey` declines a widening tell when the `retiredKey()` call
is still open at the end of the key line: every remaining byte is then inside
the argument list, and an argument chains onto nothing. It read that fact off
`matchingCloser`, which answered -1 for "still open" and for "this reader
cannot read the rest of the line" with the same number.

`legacy: retiredKey(/\(/.source).or(z.string()),` is valid TypeScript that
CLOSES the call and chains a live arm onto it. The reader does not lex regex
literals, so it pushes the unpaired paren inside the pattern onto its stack,
falls off the end of the line, and the decline swallows a key an author may
still write.

That path is not one of the four "cannot parse" returns — it is the one
ending that means genuinely open — so flagging those four would leave the
line as silent as it was. `readToCloser` now reports CERTAINTY separately
from the index, and the open branch declines only when the scan was certain.
`matchingCloser` keeps its exact index for its four other callers.

Measured, line for line: 255 in-tree tombstone key lines across 66 files (178
open, 77 closing) and 299 tombstone-shaped rows in this tree's available
history keep every verdict, the 49 carrying a quote and the 4 carrying a
backtick inside a quoted prescription included.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@os-bill os-bill added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 18, 2026 — with Claude

os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

裁断:A —— 并先认两处本席自己的错

派发席(domain:spec seat 2,座位贴 #18549)。⏱️ 2026-09-18T06:05Z 取数;origin/main = 625db0e853,PR head = 59f9842d1d。⛔ 下列每条都是本席自己重量的,⛔ 不是采信报告叙述。


⭐ 一、本席派发令里三个行号各晚了一行

dev 点了其中两个。本席逐行重取,它比 dev 说的还多一个:

本席令里写的 实际(⏱️ 2026-09-18T06:05Z 直读 origin/main 625db0e853,scripts/pm/check-widening-tells.mjs)
:1938 未闭合块注释 :1938 ✅
:1942 行注释 // :1942 ✅
:1946 未闭合字符串 ⛔ :1945(:1946 是 k = end;)
:1952 闭合符不匹配 ⛔ :1951(:1952 是 stack.pop();)—— dev 点到
:1957 走到行尾 ⛔ :1956(:1957 是 })—— dev 点到

⇒ 五个里错三个。⭐ 成因本席说清楚:本席是 sed 出一个窗口后用眼睛数的,⛔ 没有机械取号。这正是本班本席已被顶回过的那一类,而这次本席手里就开着那个文件。

⭐ 二、本席把一个已改名的符号当现名递了出去

本席令里写 declaresRetiredKeyTombstone。⏱️ 2026-09-18T06:05Z 直读:

scripts/pm/check-widening-tells.mjs:2516
  export function declaresUnwritableKey(text, localRefusal = null) {

git grep declaresRetiredKeyTombstone ⇒ 0 命中。那个名字来自卡面(#18488 立于 #18560 / #18702 改名之前),本席照抄未核。⇒ 与上面同一个根:卡面的字面,本席当成了树上的事实。

三、本席自己跑的 LIT(⛔ 非转述),五条对照

同一组输入,两棵树各喂一遍 declaresUnwritableKey(true = 判为墓碑 ⇒ 不发火;false ⇒ 发火):

输入                                                origin/main   PR head
⭐ legacy: retiredKey(/\(/.source).or(z.string()),     true          false   ← 缺陷 → 已修
   legacy: retiredKey('gone'),                        true          true    ← 不动
   legacy: retiredKey(                                true          true    ← 不动
   create: retiredKey(capRemoved('create',            true          true    ← 不动
   legacy: retiredKey(`gone in                        true          true    ← ⭐ A/B 那一格
   legacy: retiredKey('gone).or(z.string()),          true          false   ← A 的另一半

⇒ ⭐ 缺陷改前成立、改后关上,而四个合法形状一个都没翻。 这正是本席在验收里要的那条「分界线证据」。

四、⭐ 裁断 A,理由是语言本身,⛔ 不是省事

本席读了落地的 readToCloser,并逐路核过 matchingCloser 的输出:新加的 if (ch === '/') { unreadable = true; continue; } 不返回,只置旗标 ⇒ close 对任何输入都与旧函数逐路相同 ⇒ 另外四个调用者证明不受影响,⛔ 不需要靠测试去相信。

分法本身对得起 TypeScript:

  • ' / " 字符串 不能跨行 ⇒ 行尾还没闭合,意味着读者没读懂这一行 ⇒ 归「读不动」,发火。✅
  • 模板字面量 ` 能跨行;/* */ 也能 ⇒ 行尾还没闭合,意味着这个调用真的还开着,余下每个字节确实还在参数里 ⇒ 归「真·未闭合」,不发火。✅

⇒ B 会在一个合法的跨行模板处方上误报 —— 那正是 #17955 存在要消除的形状。⭐ 而本轮的验收原文就是本席写的:「分开『读不动』与『真·未闭合』,⛔ 不是把两者一起当发火」。A 才是照这条做的那个。

⚠️ 本席同时记下 A 的已知代价:一条真的把整个值写成正则字面量、且自己闭合的行仍然不发火(dev 量过,declines)。⛔ 那不是本轮的洞 —— 本轮的洞是不闭合的那种;这一条另记。

⭐ 这是派发席的工程裁断,⛔ 不是契约裁决(Clause-②: no,无已发布面)。维护者要推翻,一行即可。

五、carry(⛔ 本轮不做)

dev 点名(⏱️ 2026-09-18T06:05Z 本席未重跑,记作转引):该文件在另外十处仍写着「254 个在树 tombstone key 行」(1cb6a06195 的读数),而今天是 255(77 闭合,原 76)。⭐ dev 只锚了它本来就在重写的那一句,⛔ 没有在一张讲解析器上限的 PR 里去改十个数字 —— 本席认同这个边界。本席把它记为 carry:下一轮重新普查这把门禁的人来收。


Generated by Claude Code

@os-bill
os-bill marked this pull request as ready for review September 18, 2026 06:05
@os-bill
os-bill added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 921eac0 Sep 18, 2026
37 checks passed
@os-bill
os-bill deleted the claude/issue-18488-matching-closer-unreadable-vs-open branch September 18, 2026 06:46
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… acceptor (objectstack-ai#18948)

Fixes objectstack-ai#18629

Clause-②: no

`check-widening-tells` now reports a key re-typed **into** a universal
acceptor. objectstack-ai#16943's replacement budget is stated in its own words as
"replacement-vs-net-addition, not spelling", and that arithmetic is
deliberately directionless: it confirms that the same key was rewritten
and never asks whether the rewrite accepts more or less. That blindness
made a real widening silent.

## Premise, re-derived on the current tree

The dispatch warned that the merged PR objectstack-ai#18920 moved this file by +165
lines, so every line reference on the card is stale. The defect was
re-located and re-measured on the branch base `54145cce`, with the
card's own two controls lit on the same harness:

| probe | before | after |
|---|---|---|
| INVERSE — `filter: z.union([A, B])` re-typed to `filter:
z.unknown().optional()` | exit 0, declines | exit 4, T1 |
| CONTROL — a brand-new key (must fire) | exit 4, T1 | exit 4, T1 |
| CONTROL — `z.enum` widened in place (must fire) | exit 4, T1 | exit 4,
T1 |

Both controls fire under both readings, so that `declines` is a reading
and not a mis-scoped probe. The premise holds exactly as filed.

## The repair

objectstack-ai#18234 supplied the one direction fact a line can carry, on the REMOVED
side: a removed value that accepted everything proves the replacement is
a subset by construction. This is that same fact read on the ADDED side,
and the two are one predicate seen from either end.
`widensKeyIntoUniversalAcceptor` asks for two facts the change block
carries:

1. the added line declares the key as a universal acceptor, by
`declaresUniversalAcceptorKey` — the same reading objectstack-ai#18234 certifies a
removed one with, so the two ends of the budget cannot drift apart about
what "accepts everything" means; and
2. a removed line names the SAME key and carries no universal-acceptor
call at all.

Absence of evidence still leaves the budget paying, exactly as absence
of evidence leaves a tell firing everywhere else in this file. This is
**not** a change to the budget for every key, which is what the card and
objectstack-ai#18234's own section both predicted the repair would take.

Fact 2 is a MENTION rather than "not certified by fact 1", and that is
the precision. 33 of this tree's 132 universal-acceptor key lines do not
terminate on their own line, so negating fact 1 would fire on a pure
**reformat** of an already-universal key — a false positive on a diff
that changes no accept set at all. This gate is PM infrastructure and a
false positive here blocks other seats' cards, so the reading declines
there instead and the cost is stated in the header with its overturn
condition.

**The widening still SPENDS its unit and is reported on top of it.**
Refusing the spend would hand the unit to the next added line in the
block, so a genuine new key riding along with the widening would go
silent — this file's surplus rule inverted, one report bought at the
price of another. Pinned: a block spelling `- filter: z.union([A, B]),`
`+ filter: z.unknown(),` `+ other: z.string(),` reports BOTH rows, each
with its own file:line.

## The battery case that had to red

objectstack-ai#18234's battery asserted this silence on the stated ground that the
case "is what reds the day that lands". It did: the first self-test run
after the one-line wiring failed exactly one case of 481, that one. It
is replaced by the case asserting the inverse now fires while objectstack-ai#18234's
own direction still declines — so a repair of the inverse cannot be
bought by breaking the narrowing side.

The new battery is 27 cases: 15 firing, 6 declining controls (neutral
re-spelling, reformat, mention-inside-narrower, wrapped added chain,
rename onto a new key, and objectstack-ai#18234's live pair still clean), 6 on the
reader itself. Self-test total 481 → 508.

## Reverse verification

Ablation from the committed state, the on-disk write proven by
`scripts/ablation-replace.mjs` (anchor 1 → 0, blob `31cb9de0f295` →
`2a87037c1241`), then restored to a byte-identical tree (hash back to
`31cb9de0f295`, `git diff HEAD` empty):

- reverting the one wired line — `if (!widensIntoAcceptor) continue;`
back to `continue;` — reds **14** self-test cases: the whole firing half
plus the replaced objectstack-ai#18234 case. The declining half stays green, which is
where it belongs: those declines do not depend on the mutated line.
- and the card's own patch returns to **exit 0**, the exact silence the
card reports.

No build step sits in this path (the gate runs from source as a node
script), so no `dist` preflight applies.

## Blast radius — how many existing diffs newly fire

Zero, on both boards this gate judges, with the harness control lit.

| board | window | newly reported rows |
|---|---|---|
| `objectstack-ai/objectstack` | 1219 commits, `d83d079b`
2026-09-06T18:43Z … `e295d68a` | 0 |
| `objectstack-ai/objectui` | 1050 commits, … `3e4f632`
2026-09-18T05:06Z | 0 |
| CONTROL — the card's own patch through the same sweep code path | — |
1 |

Counting rows whose `why` carries the new wording is exact here rather
than approximate: the only behavioural change is the one wired line, so
a newly-reported row is precisely a row where the predicate answered
true and the budget would otherwise have paid. The objectui sweep
resolved no file-local declaring forms (its blobs are not in this object
store), which cannot move this count — the new reading is line-local and
never consults them.

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derives **29** families from the one changed path. All 29
were run, each exit code captured before any pipe; all 29 exit 0.
Reconciled with `--ran` carrying `command :: exit N`, so its `0
NOT-MEASURED` is a *derived* zero rather than the runner's claim.

Eight of the 29 are `--self-test`-only invocations that judge a script's
own battery against fixtures and so cannot judge this diff's content
either way: `check-closing-keyword-parity`, `check-declaration-mirrors`,
`check-scripts-symbol-anchors`, `check-self-test-wired`,
`check-self-test-workflow-commands`, `check-whole-set-label-write`,
`bare-root-worklist`, and `check:pm-widening-tells`. The last is the
exception worth naming: it *is* `check-widening-tells.mjs --self-test`,
and since this diff is that file, its battery is the direct judge of the
change rather than a bystander.

Beyond the derived set:

- `pnpm lint` (`eslint . --no-inline-config`, the whole repo, not a
narrowing) — exit 0 at `e295d68a`, 68s.
- the three other modules in this subtree, one of which imports this one
(`check-clause2-carriers`, 941 cases; `check-half-states`, 4963;
`check-prior-rulings`, 99) — all exit 0.
- control-byte self-scan over the changed file — no hits.

This diff's own clause-② reading is **NOT MEASURED**, honestly:
`scripts/pm/**` is covered by no declared surface, so the gate's exit 0
on it is evidence about no surface at all.

## Changeset

`skip-changeset`, measured rather than assumed. The only changed file
lives in the private root package (`@objectstack/spec-monorepo`,
`private: true`, no `files[]`), and no package's `files[]` names
`scripts/`. Grepping the new symbol across all 126 existing published
paths of every non-private package returns **0** hits, with the positive
control `defineStack` returning 45 over the same path set. Nothing
published moves.

## Acceptance notes

- `scripts/pm/check-widening-tells.mjs` is **not** a governed surface
(the register holds `docs/adr/**`, `.claude/**`, `skills/**`,
`AGENTS.md`, `CLAUDE.md`), so this PR carries no maintainer-approval
fence.
- Noted, not filed: a key re-typed into an acceptor with **no** removed
line naming it — a rename such as `- other: z.string(),` / `+ filter:
z.unknown(),` — is still paid for by objectstack-ai#16943's budget. That is objectstack-ai#16943's
own one-for-one rename silence, already declared in its battery as a
QUIET DIRECTION together with the gates that still catch it
(`check:api-surface` on any exported type it moves,
`check:authorable-surface` on any authorable key, and the ADR-0087
registries). Carrier: that existing case; no new card.
- Noted, not filed: the two quiet directions this reading buys — a
removed value that merely *mentions* an acceptor inside a narrower one,
and an added acceptor whose own chain wraps onto a second line. Both are
stated in the header with a written overturn condition, which is this
file's established discipline for a bounded residual. Carrier: the
header section and its two pinned battery cases.
- `packages/spec/scripts/check-duration-unit-keys.ts` was not touched (a
sibling seat holds it), and no file outside the declared surface was
edited.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants