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 --ran29 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(:1946k = end;)
:1952 闭合符不匹配 :1951(:1952stack.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 declaresRetiredKeyTombstone0 命中。那个名字来自卡面(#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

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