Repository navigation
fix(pm): check-widening-tells separates "cannot read" from "still open" in the tombstone decline - #18920
Conversation
…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>
…tching-closer-unreadable-vs-open
裁断:A —— 并先认两处本席自己的错派发席( ⭐ 一、本席派发令里三个行号各晚了一行dev 点了其中两个。本席逐行重取,它比 dev 说的还多一个:
⇒ 五个里错三个。⭐ 成因本席说清楚:本席是 ⭐ 二、本席把一个已改名的符号当现名递了出去本席令里写
三、本席自己跑的 LIT(⛔ 非转述),五条对照同一组输入,两棵树各喂一遍 ⇒ ⭐ 缺陷改前成立、改后关上,而四个合法形状一个都没翻。 这正是本席在验收里要的那条「分界线证据」。 四、⭐ 裁断 A,理由是语言本身,⛔ 不是省事本席读了落地的 分法本身对得起 TypeScript:
⇒ B 会在一个合法的跨行模板处方上误报 —— 那正是 #17955 存在要消除的形状。⭐ 而本轮的验收原文就是本席写的:「分开『读不动』与『真·未闭合』,⛔ 不是把两者一起当发火」。A 才是照这条做的那个。 ⭐ 这是派发席的工程裁断,⛔ 不是契约裁决( 五、carry(⛔ 本轮不做)dev 点名(⏱️ 2026-09-18T06:05Z 本席未重跑,记作转引):该文件在另外十处仍写着「254 个在树 tombstone key 行」( Generated by Claude Code |
… 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>
Fixes #18488
Clause-②: no
The defect
declaresUnwritableKeyinscripts/pm/check-widening-tells.mjsdeclines 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
-1for 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:
Spelled out, in case the platform rewrites the bytes above: the key
legacy, whose value callsretiredKeywith the.sourceof 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(record5700134264), and it does not reopen the multi-line chained-close quiet direction ruled at5696535481: 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
-1down five paths (line numbers re-taken at1fb36ca44d, where the file is byte-identical to84ba4a8476)::1938:1942:1946:1951:1956⭐ 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(:1952isstack.pop()), and the fall-off-the-end return is at:1956(:1957closes 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 predicatedeclaresRetiredKeyTombstone, which is its name at PR #18427's head; #18560 and #18702 have since renamed itdeclaresUnwritableKey.The change
The scan is now one function,
readToCloser, that reports certainty separately from the index, andmatchingCloseris a thin wrapper over itsclosefield — byte-for-byte the same index for its four other callers.The question it answers is not "which
returndid the scan take" but is it certain the call does not close on this line: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.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
declaresUnwritableKeydeclines 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.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 (blobb147e0bfdccbequals HEAD,git diff HEADempty), the probe run again.declaresUnwritableKeyorigin/main)true— read as a tombstonefalseDARK. The verdict of
declaresUnwritableKeycompared line for line, before and after, over every located key line, not a count:packages/spec/src/system/metadata-form-zod-reconciliation.test.ts, off the judged surface, and all four keeptrue. A line-levelincludesof 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
-1as "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:legacy: retiredKey(— genuinely open, 148 landed linescreate: retiredKey(capRemoved('create',— genuinely open, 30 landed lineslegacy: retiredKey( // the prescription is belowAblations, each landed on disk with an anchor-hit and blob check, each restored to a blob equal to HEAD with
git diff HEADempty:Gates
--self-test: 481 cases, exit 0. The#17955battery 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 againstscripts/symbol-anchors.mjs;origin/mainwas merged in and the list re-derived clean, and the list is identical either way.pnpm check:published-readme-exportsexits 3 — NOT MEASURED, its own prerequisite code for an unbuiltdistacross 46 packages; this diff touches no package and no README.pnpm lintover the whole repo: exit 0 in 77s. No narrowing was needed, so none is claimed.pnpm check:pm-dispatch-gates,pnpm check:pm-widening-tells,pnpm check:nul-bytesall exit 0; a control-byte grep over the changed file finds none.Changeset —
skip-changeset, measuredThe diff is one path,
scripts/pm/check-widening-tells.mjs, and 0 paths under.changeset/(three-dot againstorigin/main). The root package is private with nofiles[]; 0 of the 70 non-private packages'files[]mentionsscripts(control: all 70 declare a non-emptyfiles[]). The new internal functionreadToCloserhas 0 occurrences underpackages/(control:retiredKeyappears in 467 files there). Nothing published moves, soClause-②: noholds.Acceptance notes
Observed while measuring, not filed and not fixed here:
1cb6a06195reading. The tree now holds 255. I anchored the one control sentence I was already rewriting and named the other reading beside it rather than rewriting ten figures in a card about a parser limit. Successor: the next round that re-censuses this gate.z.unknown()是真实扩大,却被 #16943 的替换预算无声付账 —— 实测在 #18626 前后皆然 #18629 — the inverse false-negative, still queued and undispatched. None of them is addressed here, and none of their numbers is a closing target of this PR.#16943's replacement budget is not touched.Generated by Claude Code