fix(pm): check-widening-tells resolves a FILE-LOCAL declaring factory through its own definition - #18750
Merged
os-justin merged 4 commits intoSep 17, 2026
Conversation
…ition Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude <noreply@anthropic.com>
…ition T1 read a property's value with a NAMED list of shared, exported helpers (`SCHEMA_PROPERTY_FORMS`, #18560) and stayed blind to a factory declared inside the one file that uses it: `memberTellKind` answered `null`, the row neither fired nor spent the #16943 budget, and nothing in the output said a thing. The repair is structural, never name-shaped: when a key line's value opens with an identifier call the shared list has no row for, the factory's own definition is resolved out of the judged file's HEAD BLOB (`index <old>..<new>` on the local path, `sha` on an API row; `git cat-file blob`, falling back to the working tree only after `git hash-object` proves it IS that blob) and classified by what its body returns at the body's own top level. Two boundaries, both a STATED silence the reader now prints with the file:line, the factory and the reason: an imported factory (imports are not chased) and a body this reader cannot classify. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude <noreply@anthropic.com>
… heuristic Four `*Arm(` factories in ONE objectui file land in opposite registers: `chatbotRequestBodyArm(` returns `z.record(…)` and `chatbotOnClearArm(` returns `handlerKeyRefusal(…)`. A name-shaped reading is wrong about one of them whichever way it guesses, and nothing in the name says which. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude <noreply@anthropic.com>
…bulary row Measured on the tip: `placeholderFree` is declared in packages/spec/src/data/driver/common.zod.ts and IMPORTED at all 23 of its key positions across six driver files. It was never file-local at any of them, so no reading of one file could reach it and the structural resolver is not the instrument for it. It takes a `SCHEMA_PROPERTY_FORMS` row, with the filing probe line as its counterfactual fixture. The row stops there: the four sibling factories measured beside it return prose or a `$ZodErrorMap`, never a schema, so rows for them would mint 38 false T1 positives. Which instrument a factory belongs to is a fact about where it is DECLARED; whether it belongs to either is a fact about what it RETURNS. The #18702 battery's counterfactual and imported-boundary fixtures re-anchor onto factories the list has no row for, because a counterfactual that passes through the fast path pins nothing about the resolver. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude <noreply@anthropic.com>
os-justin
marked this pull request as ready for review
September 17, 2026 19:12
This was referenced Sep 17, 2026
os-justin
deleted the
claude/issue-18702-file-local-declaring-factories
branch
September 17, 2026 19:36
This was referenced Sep 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #18702 — both halves. Round 1 (
1b1c7a6c5c) is the file-local resolver described below; round 2 (d852dae1fb, on the seat's ruling 1=C, 2=A, 3=A on the card) adds the oneplaceholderFree(row toSCHEMA_PROPERTY_FORMSwith the card's own probe line as its counterfactual fixture, so the filing probe now exits 4 with one T1 atmemory.zod.ts:112. The section "What this leaves open, and why it is a different instrument" is round 1's reading; on the relation it is superseded by the round-2 report on the card (comment 5719726758), and this opening line is the seat's edit.Clause-②: no
skip-changeset: nothing published moves. The whole diff is one file underscripts/pm/**, which no package'sfiles[]ships.The defect
T1 decides that a property line declares a schema member by reading the property's VALUE against a named vocabulary of shared, exported declaring helpers —
SCHEMA_PROPERTY_FORMS, landed by PR #18700. A list of shared helpers cannot name a factory declared inside the one file that uses it, so such a line is not a line judged leniently: it is not a key line at all.memberTellKindanswersnull, the row neither fires nor spends the #16943 replacement budget nor earns it on the removed side, and nothing in the output says a word. That silence is indistinguishable from a correctClause-②: no, which is the one failure shape this whole chain is written against.Before-readings, taken on the tip (objectstack
30bac2880)snapshotPath: placeholderFree(z.string(), 'persistence.snapshotPath').optional(),onpackages/spec/src/data/driver/memory.zod.tsnode scripts/pm/check-widening-tells.mjs --declaration no --diff FILEsnapshotPath: z.string().optional(),memory.zod.ts:112snapshotObject: strictIdent('Object whose snapshot is taken (snake_case)'),onpackages/spec/src/ai/solution-blueprint.zod.ts, whosestrictIdentis declared in that same fileThe third row is the one this change turns: after it, the same diff exits 4 with one T1 at
solution-blueprint.zod.ts:343.The reading rule
When a key line's value opens with an identifier CALL that
SCHEMA_PROPERTY_FORMShas no row for, the factory's own definition is resolved out of the judged file and classified by what its body RETURNS:z.schema, a name ending inSchema, a form the shared vocabulary already declares writable (strictObject(,lazySchema(), or the factory's OWN ARGUMENT handed back (placeholderFreereturns the schema it was given) ⇒ a WRITABLE key, and the line FIRES T1 with its file:line;z.never(or az.customwhose predicate refuses everything — the same primitivesUNWRITABLE_FORMSalready names — ⇒ DECLINES, the same readingdeclaresUnwritableKeygivesretiredKey(, bound to the same line-local evidence (a live arm chained onto the call fires);How the file is read: the HEAD BLOB, through git, never "the file of that name in whatever tree this process stands in." #17300 measured that second reading wrong for this whole family — a seat's worktree is not the diff's head — and a blob id is content, so a reading taken through one can be missing but can never be about the wrong commit. Both input paths carry the id:
git diffwrites it into itsindex OLD..NEWline, and a/pulls/N/filesrow carries it assha. It is read withgit cat-file blob; only when that fails is the working tree consulted at all, and then only aftergit hash-objectproves the file on disk IS that blob, byte for byte.The shared list stays the fast path and is consulted first.
keyValueFactoryNameanswersnullfor any lineSCHEMA_PROPERTYalready reads, so a form with a row is never re-judged here and the two registers cannot disagree about one line.The
nocriterion does not loosen. The resolver only ever ADDS a recognition, and it is consulted on the ADDED side ONLY: a removed local-factory key line buys nothing, so no line that fires today can stop firing because a removal newly pays for it. The price of that asymmetry is stated rather than left to be found — a block that REPLACES one local-factory key with another fires on the added one, a false positive, which is the cost #16448 accepted and the loud direction. It is pinned as a case, and the overturn condition (the first LANDED diff whose only tell is such a replacement) is written into the header.The two boundaries — both a STATED silence
returnit can read at the body's own top level, or one that is a template string, an arrow, a number. Areturnbelonging to a NESTED callback is not read as the factory's own answer either: brace depth decides, because reading a nested refusal as the factory's verdict is exactly the quiet direction.Neither is the old invisible silence. Every unresolved key line is now reported with its file:line, the factory's name and the reason, under its own heading, and it moves no exit code:
Census — report-only, no re-grade of any landed PR, no state write
The eight factories the card names, at objectstack
30bac2880, counted at KEY POSITIONS onpackages/spec/src/**:placeholderFree(return schema.superRefine(…)— the schema it was handedstrictIdent(z.string().regex(SNAKE_CASE).describe(…)ruleArrayFilterError(return (issue) => {…}— a$ZodErrorMapINLINE_CREDENTIAL_REFUSED(objectBlockHistory(emptyProps(strictObject(…), itself a declared formstrictIdentOrNull(z.string().regex(SNAKE_CASE).nullable()…belongsInConfig(Two readings follow, and both change what the repair is rather than decorating it:
15f01223d, file-local topackages/types/src/zod/complex.zod.ts:chatbotRequestBodyArm((2 key lines) returnsz.record(…)⇒ WRITABLE,chatbotEnableMarkdownArm((2) andchatbotEnableFileUploadArm((2) returnz.boolean()⇒ WRITABLE, whilechatbotOnClearArm((2) returnshandlerKeyRefusal(…)⇒ REFUSING andretiredDeclarativeKanbanKey((1) returnsretirementTombstone(…)⇒ REFUSING. Four factories with the same*Arm(shape, in ONE file, in OPPOSITE registers — a name-shaped reading is wrong about one of them whichever way it guesses, and nothing a name carries says which. That is the measurement that retires the heuristic rather than declining it on principle.What this leaves open, and why it is a different instrument
placeholderFreeis declared inpackages/spec/src/data/driver/common.zod.tsand IMPORTED at all 23 of its key positions,memory.zod.ts:9included. So the card's probe LINE is BOUNDARY ONE, not the class the card's title names: it exits 0 still — but no longer in silence, because the line is now reported by name with its file:line and the reason, which is why this PR does not leave the probe where it found it.The class the card's TITLE names is closed over the 29 file-local key lines that carry a schema (
strictIdent(12,strictIdentOrNull(8,emptyProps(9), and the other 17 file-local key lines are now a NAMED silence instead of an unseen one. What remains is a shared, exported helper absent fromSCHEMA_PROPERTY_FORMS, which is #18560's instrument — a row with a counterfactual fixture — and not this one. That is why the relation above isPart ofand #18702 remains open; adding such a row was outside this card's dispatch, and the reasoning belongs to whoever grades it rather than to this diff.Round 2 (seat's note): the seat ruled the row in (option C), the dev landed it in
d852dae1fb, and the relation is nowFixes— see the opening line and the round-2 report on #18702.Pins — PR #18700's shape, one fixture per factory, both arms
A new self-test battery,
#18702 — a declaring factory PRIVATE to one file, resolved through its own DEFINITION, 53 cases:returnsis asserted to be text the definition actually contains, so a fixture cannot drift into describing a definition it does not hold;z.never(…)definition (refusedInlineCredentialKey) rather than off a name, with the chained-arm control that fires;memory.zod.ts— no tell, one named unresolved row, and the reader PRINTS it;returnis unresolved; areturninside a nested callback is NOT read as the factory's own;sha, the localindexline, an all-zero id refused, a patch naming neither answering null (which is why every other fixture in this file never touches an object store), and a path climbing out of the tree never read;Clause-②: yesnever blocked, the objectui board not judging an objectstack path, [finding]check-clause2-carriersT1 reads a function PARAMETER annotatedctx: z.RefinementCtxas a new authorable key — so every PR that adds an object-level refusal raises a widening tell for the refusal itself #17618's parameter decline untouched, a comment still not a key line, a file off the contract source surface reading no blob at all;*Arm(factories in opposite registers.Ablation
One line reverted — the single call that consults the resolver,
const localForms = localDeclaringForms(file, lines, onContractSource, readSource, unresolved);becomingconst localForms = null;— from the committed state at1b1c7a6c5c, with the mutation proved on disk by blob hash before the run and the restore proved by blob hash and an emptygit diff HEADafter it.All 15 red cases are in the new battery, and every one of them is a case about the READING: the four writable factories firing, the four unclassifiable ones being reported by name, the chained-arm control, the counterfactual with its file:line and its refusal verdict, boundary one's stated silence and its printed line, and the price case. The other 38 cases in the battery are resolution-level and hold either way, and the 403 cases standing before this round are green under the ablation, every one of them — which is the direction claim, measured rather than argued.
Self-test
pnpm check:pm-widening-tells— 403 cases / 24 batteries at30bac2880, 456 cases / 25 batteries here.pnpm check:pm-clause2-carriers, which consumes this file's verdict, is 786 cases, exit 0.Derived gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom the worktree (no hand-fed path list; the change set is the tool's own, one path, three-dot against the merge base30bac2880) derived 29 commands. All 29 were run, each exit code captured by redirect-then-$?, and all 29 exited 0. Reconciliation with--ranrecordingcommand :: exit N:The 29:
check-ci-filter-parity,check-closing-keyword-parity(scan + self-test),check-comment-mask-corpus,check-declaration-mirrors(scan + self-test),check-scripts-symbol-anchors(scan + self-test),check-self-test-wired(scan + self-test),check-self-test-workflow-commands(scan + self-test),check-whole-set-label-write(scan + self-test),bare-root-worklist --self-test, andpnpm check:agent-test-spelling,bash32-floor,cli-command-ids,cross-package-test-inputs,driver-memory-census,entry-guard,nul-bytes,parse-guard,pm-dispatch-gates,pm-widening-tells,pnpm-filter-targets,ratchet-remedy-authority,refd-timer-probe,watch-hint-literal.Repo-wide
pnpm lintexit 0 (eslint . --no-inline-config, the whole tree, run after the final commit). The control-byte self-scan over the edited file reports nothing, andpnpm check:nul-bytesexits 0.Generated by Claude Code