Skip to content

Commit cb0c616

Browse files
claude[bot]os-salesclaude
authored
fix(pm): stop T1 reading a typed parameter, and a re-spelled closed set, as a new key (#17760)
Fixes #17618 ## What was wrong `check-widening-tells`' **T1** tell reads an added line shaped `identifier: z.Something` on the contract source surface as *"a new key on a Zod object schema — the accept set gains a spelling an author may now write"*. Two live pairs raised it against diffs that spell nothing new. Both were reproduced from the PRs' own pushed bytes **before** anything was written. **Instance 1 — PR #17616 (`packages/spec/src/ui/dashboard.zod.ts:470`).** The row fires on `ctx: z.RefinementCtx,`, the second **parameter** of an exported object-level refinement. `z.RefinementCtx` is a type; nothing constructs a shape there, and the diff it appears on *refuses* metadata that parses today. That signature is this repo's own prescribed one (the `#16489` convention — `checkListViewPageMount`, `checkPageSourceCompleteness`, `checkGlobalFilterDateDefaultValue`), so **every** diff adding a cross-field refusal raised a widening tell for the refusal itself. **Instance 2 — PR #17638 (`packages/spec/src/system/cache.zod.ts:197`).** The row fires on `strategy: z.enum(['eager', 'lazy'], {`, an in-shape key the same change block removed as `strategy: z.enum(['eager', 'lazy', 'scheduled']).default('lazy')`. Same key, one member **fewer**. That pair declares `Clause-②: no`, so it exited **4** and held a reviewed, green retirement PR out of the queue, where the only sanctioned clear is the false `yes` the file already refuses to ask an author for. ## The measurement that picked the fix Instance 2 is **not** the #16943 net-delta budget being too thin. The budget was *earned* — the removed `strategy:` line is itself T1-shaped and bought one T1 unit — and then refused at the **spend**, by `!CLOSED_SET_OPENER.test(text)`: "a line that DECLARES a closed set is never spent against the budget". That clause was written about an **opener**, and an opener never reaches it: `memberTellKind` already answers `null` for an opener-only line. So the only lines it ever caught were **keys** whose value opens `z.enum(` / `z.union(` / `z.discriminatedUnion(` / `z.literal(`. Measured on this tree with the pre-change reader: ``` field: z.string() -> field: z.string().optional() 0 row(s) (declines) kind: z.enum(['a']) -> kind: z.enum(['a']).optional() 1 row (fires) ``` The asymmetry was accidental. ## What changed (`scripts/pm/check-widening-tells.mjs`, one file) **The spend.** What the blanket refusal was protecting is real and is kept: an **inline** set has no per-member line for T2 to read, so a set widened in place is visible on the T1 row and nowhere else. A closed-set-valued key may now spend the budget only on three facts the **block** carries — a removed line naming the **same key**, both member lists readable on their own line, and the added list a **subset** of the removed one. `z.enum(['a', 'b'])` to `z.enum(['a', 'b', 'c'])` still fires; a list that opens on a later line is unreadable and still fires; another key's removal pays nothing. **The parameter.** The claim is deliberately smaller than the depth-aware `z.object({ … })` reader T1's own comment refuses — the one whose cheap version fails GREEN by truncating. A Zod shape body is `{`-delimited **by construction**, so the question is never "which shape is this line in" but "which bracket is innermost", read over the line's **own hunk**, plus a declaration head left of that paren. Every state the reader cannot carry honestly answers `null` — *keep the tell firing*: a closer arriving on an empty stack (the hunk began inside something it was never shown), a string literal that does not close on its line, a declaration head it does not recognise (a method shorthand, for one). Nothing it returns ever means "no longer inside a shape", which is why it has no truncating failure mode. The same reading is applied on the **old** side too, the way #16822's fragment rule is: a deleted parameter must not buy an added key the right to go unreported. ## Price of the change, measured Over the **233 commits** touching these surfaces in this tree's history, A/B'd row-for-row between the pre-change reader (`origin/main` `e9efc403`) and this one: | | rows | | --- | --- | | tell rows the previous reading raises | 20,193 | | the new reading raises | 20,170 | | **now decline** | **23** — all T1; no T2, T3 or T4 row moves | Of the 23: **15 are parameters** (twelve `ctx: z.RefinementCtx` / `z.core.$RefinementCtx`, three `input: z.input[typeof SomeSchema]`) and **8 are existing keys re-spelled** to carry `.meta({ title })` or a rewritten `.describe()` around an identical enum (e.g. `d2badf723`, `f502898a4`). Not one is a key or a member its diff added. On the tree itself, **16 of 8,974** T1-shaped lines under `packages/spec/src/**` sit inside a parameter list, 10 of them annotated `z.RefinementCtx`. The file's header prices this in the register it already uses, and states both quiet directions rather than leaving them to be discovered: 1. A parameter **added to an already-exported** function is a signature widening and now goes unreported here; nothing else in the file catches it (T3's listing records that an export EXISTS, and `api-surface-signatures.json` carries 27 `define*` helpers, none of them one of these checks). What is not lost is the function itself — a newly exported check adds its own T3 row, which is why #17616 still reports one. 2. A widening carried by the **chained methods** rather than the member list (`.optional()` first among them) now declines on a closed-set-valued key. Not a new class: #16943 already declines it for every key whose value is not a closed set; this removes the accidental exception rather than adding one. In the measured population all eight re-spellings are `.meta` / `.describe` rewrites and none adds `.optional()`. ## Acceptance (grading comment 5642818369) | item | before | after | | --- | --- | --- | | the parameter-list line reads no tell | T1 at `dashboard.zod.ts:470` | no row | | the in-shape re-spelling with a shrinking set reads no tell | T1 at `cache.zod.ts:197` | no row | | a genuinely new key on a shape STILL tells (control) | `manifest.zod.ts:44` | `manifest.zod.ts:44` | | the `#16489` signature as a named fixture | — | `FILE_REFINEMENT_SIGNATURE` in `--self-test` | | `--pair 17638` with its `Clause-②: no` unchanged | **exit 4**, one T1 row | **exit 0**, no tell | ``` $ node scripts/pm/check-clause2-carriers.mjs --pair 17638 check-clause2-carriers: PR #17638 / card #17157 — the clause-② declaration is readable in the fixed spelling and both carriers agree, and its diff carries no widening tell. (exit 0) ``` ⚠️ **One acceptance item could not be run as written, and the reason is a fact about the board, not about the tree.** `--pair 17616` cannot form a pair: PR #17616 **merged at 2026-09-11T07:41Z**, and the carriers script only judges an open PR — it exits **2** ("PR #17616 is not open"), both before and after this change, which is not a T1 reading in either direction. Instance 1 is therefore reproduced and pinned where the reading actually lives: `tellsInFile` over that PR's real pushed patch (the bytes are the `--self-test` fixture), and the merged commit `1f0b5659e` is one of the 23 declines in the history A/B above. ## Verification - `node scripts/pm/check-widening-tells.mjs --self-test` — **269 cases pass** (245 before; +24 in the new battery, registered in the roster so the floor cannot be silenced by deleting it). - `node scripts/pm/check-clause2-carriers.mjs --self-test` — **465 cases pass** (it imports this file). - Gate families derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final commit and reconciled with `--ran`; every derived command run, exit codes in the report. - The decline is bracketed on every side by controls that still fire — an inline enum widened in place, a different key carrying a subset set, a list that opens on a later line, a real key added after the parameter list closes — because a reading that can only suppress is untestable in the direction that matters. ## Gate record — run on the final commit `923aed38a` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives **32** families for the one changed path; all 32 were run and all 32 exited **0**, then reconciled with `--ran` (each line recorded as `command :: exit code`, captured before any pipe): ``` Run reconciliation — 32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED zero — all 32 recorded an exit code and none of them is 3). ``` Beyond the derived families, the two this diff owes by kind: `pnpm check:pm-widening-tells` (this file's own `--self-test`, which is what `lint.yml` runs) and `node scripts/pm/check-clause2-carriers.mjs --self-test` (the importing sibling) — both exit 0 and both are inside the 32. `git grep` finds no `*.test.ts` naming this script; its suite IS the self-test. Lint is delivered as a **proven narrowing**, not skipped: (i) the population is read from eslint's own config — the base entry's `files` glob is the whole tree (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`), which covers this path; (ii) `npx eslint --no-inline-config --format json scripts/pm/check-widening-tells.mjs` reports **1 file, 0 errors, 0 warnings** (count read from the JSON, exit 0); (iii) invariance — this repo runs one `eslint.config.mjs` which **never enables type-aware linting for any file** (no `parserOptions.project`, no typed `@typescript-eslint` rules; the config says so at `eslint.config.mjs:326`), so a one-file diff cannot move the verdict on any file it does not touch. The whole-repo `eslint . --no-inline-config` remains CI's run. ## The one judgment call, on the four axes The card offered two shapes and this PR takes the smaller one. **实际业务需求**: the population is measured, not speculative — 233 commits, 23 rows, and one PR currently blocked by the instrument. **项目长远合理性**: the reading adds one bracket fact a hunk genuinely carries and refuses the depth-aware shape reader whose cheap version truncates; `null` means "keep firing" everywhere, so it cannot degrade silently (Route & surface ownership §3). **防 AI 写错**: the defect pushed a seat *away* from tightening a contract — it reported the narrowing direction as the widening one — which is the inverse of what clause ② exists to catch; correcting it makes the strict direction the cheap one. **创业阶段不扩散**: one file, no new flag, no new label, no new claim-line syntax, and no exit code moves. ## Acceptance notes - No out-of-scope edits: `check-clause2-carriers.mjs`, `packages/spec/**` and `.claude/**` are untouched; PR #17638 is read-only evidence. - Changeset: `scripts/pm/**` publishes nothing from any package's `files[]` — this is the `skip-changeset` case the `Check Changeset` gate's own text calls textbook ("this PR edits a CI-internal script"). The label is applied on the PR. Authored by the skills-lane dev seat, session `session_01MCLBsUgfykL74aU716rzVK`, on claim comment 5642827410. --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: os-sales <sales@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 46dac81 commit cb0c616

1 file changed

Lines changed: 431 additions & 3 deletions

File tree

0 commit comments

Comments
 (0)