Skip to content

fix(pm): check-widening-tells resolves a FILE-LOCAL declaring factory through its own definition - #18750

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-18702-file-local-declaring-factories
Sep 17, 2026
Merged

os-justin merged 4 commits into
mainfrom
claude/issue-18702-file-local-declaring-factories

Conversation

@os-justin

@os-justin os-justin commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

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 one placeholderFree( row to SCHEMA_PROPERTY_FORMS with the card's own probe line as its counterfactual fixture, so the filing probe now exits 4 with one T1 at memory.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 under scripts/pm/**, which no package's files[] 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. memberTellKind answers null, 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 correct Clause-②: no, which is the one failure shape this whole chain is written against.

Before-readings, taken on the tip (objectstack 30bac2880)

probe command reading
the card's probe: one added line snapshotPath: placeholderFree(z.string(), 'persistence.snapshotPath').optional(), on packages/spec/src/data/driver/memory.zod.ts node scripts/pm/check-widening-tells.mjs --declaration no --diff FILE exit 0, 1 file JUDGED, zero tells — reproduced exactly as filed
the lit control: the identical line spelled snapshotPath: z.string().optional(), same exit 4, one T1 at memory.zod.ts:112
the file-local probe: one added line snapshotObject: strictIdent('Object whose snapshot is taken (snake_case)'), on packages/spec/src/ai/solution-blueprint.zod.ts, whose strictIdent is declared in that same file same exit 0, 1 file JUDGED, zero tells

The 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_FORMS has no row for, the factory's own definition is resolved out of the judged file and classified by what its body RETURNS:

  • returns a z. schema, a name ending in Schema, a form the shared vocabulary already declares writable (strictObject(, lazySchema(), or the factory's OWN ARGUMENT handed back (placeholderFree returns the schema it was given) ⇒ a WRITABLE key, and the line FIRES T1 with its file:line;
  • returns z.never( or a z.custom whose predicate refuses everything — the same primitives UNWRITABLE_FORMS already names — ⇒ DECLINES, the same reading declaresUnwritableKey gives retiredKey(, bound to the same line-local evidence (a live arm chained onto the call fires);
  • anything else ⇒ unresolved, reported by name.

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 diff writes it into its index OLD..NEW line, and a /pulls/N/files row carries it as sha. It is read with git cat-file blob; only when that fails is the working tree consulted at all, and then only after git hash-object proves the file on disk IS that blob, byte for byte.

The shared list stays the fast path and is consulted first. keyValueFactoryName answers null for any line SCHEMA_PROPERTY already reads, so a form with a row is never re-judged here and the two registers cannot disagree about one line.

The no criterion 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

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:

  ⚠️ 1 key line(s) name a declaring factory this reader could not resolve — a STATED silence: no tell fired on them and nothing cleared them, so this verdict is evidence about neither.
      packages/spec/src/data/driver/memory.zod.ts:112 — `placeholderFree(` — no definition in this file — an IMPORTED factory is outside this reading

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 on packages/spec/src/**:

factory key lines file-local what its body returns register
placeholderFree( 23 0 return schema.superRefine(…) — the schema it was handed WRITABLE
strictIdent( 12 12 z.string().regex(SNAKE_CASE).describe(…) WRITABLE
ruleArrayFilterError( 11 0 return (issue) => {…} — a $ZodErrorMap not a schema
INLINE_CREDENTIAL_REFUSED( 10 0 a template string not a schema
objectBlockHistory( 9 9 a template string not a schema
emptyProps( 9 9 strictObject(…), itself a declared form WRITABLE
strictIdentOrNull( 8 8 z.string().regex(SNAKE_CASE).nullable()… WRITABLE
belongsInConfig( 8 8 a template string not a schema

Two readings follow, and both change what the repair is rather than decorating it:

  1. FOUR of the eight mint prose or an error map, not a schema — 38 key lines that declare no author-writable key at all. A name-shaped heuristic would have fired on every one of them.
  2. The sibling board settles the heuristic question outright. At objectui 15f01223d, file-local to packages/types/src/zod/complex.zod.ts: chatbotRequestBodyArm( (2 key lines) returns z.record(…) ⇒ WRITABLE, chatbotEnableMarkdownArm( (2) and chatbotEnableFileUploadArm( (2) return z.boolean() ⇒ WRITABLE, while chatbotOnClearArm( (2) returns handlerKeyRefusal(…) ⇒ REFUSING and retiredDeclarativeKanbanKey( (1) returns retirementTombstone(…) ⇒ 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

placeholderFree is declared in packages/spec/src/data/driver/common.zod.ts and IMPORTED at all 23 of its key positions, memory.zod.ts:9 included. 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 from SCHEMA_PROPERTY_FORMS, which is #18560's instrument — a row with a counterfactual fixture — and not this one. That is why the relation above is Part of and #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 now Fixes — 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:

  • a frozen fixture roster asserted EQUAL to the eight factories the card names, so a factory added with no fixture reds and one silently dropped reds;
  • each fixture carries that factory's REAL definition from the tip (signature verbatim, return expression verbatim at its opener, prose truncated) and its quoted returns is asserted to be text the definition actually contains, so a fixture cannot drift into describing a definition it does not hold;
  • RESOLUTION asserted for every fixture against its own arm, then the VERDICT: a writable factory FIRES with its own file:line, an unclassifiable one fires nothing AND is reported by name;
  • the REFUSING arm read off a z.never(…) definition (refusedInlineCredentialKey) rather than off a name, with the chained-arm control that fires;
  • THE COUNTERFACTUAL: the card's probe LINE on the file where its factory is DECLARED — fires after, and silent with the resolver blind, which is the before-reading taken by disabling the resolver rather than by editing the fixture;
  • BOUNDARY ONE: the card's literal probe on memory.zod.ts — no tell, one named unresolved row, and the reader PRINTS it;
  • BOUNDARY TWO: two definitions of one name are AMBIGUOUS; a body with no readable return is unresolved; a return inside a nested callback is NOT read as the factory's own;
  • the head-blob readings: the API row's sha, the local index line, 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;
  • the direction: the fast path is consulted first, a line that fires without the resolver still fires with it, the ADDED-side-only asymmetry, and the price of that asymmetry;
  • the controls: Clause-②: yes never blocked, the objectui board not judging an objectstack path, [finding] check-clause2-carriers T1 reads a function PARAMETER annotated ctx: z.RefinementCtx as 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;
  • the two objectui *Arm( factories in opposite registers.

Ablation

One line reverted — the single call that consults the resolver, const localForms = localDeclaringForms(file, lines, onContractSource, readSource, unresolved); becoming const localForms = null; — from the committed state at 1b1c7a6c5c, with the mutation proved on disk by blob hash before the run and the restore proved by blob hash and an empty git diff HEAD after it.

HEAD blob for scripts/pm/check-widening-tells.mjs: 24b8494cf3c4aa2058436de293ad6976f96e134a
--- anchor occurrences AFTER:  0  (expect 0)
--- injected occurrences:      1  (expect 1)
--- mutated blob hash: 7d3aa266ed95e1382b3423b5ebe999b4701c56d0 (HEAD was 24b8494cf3c4aa2058436de293ad6976f96e134a)
ABLATED self-test exit: 1
✗ check-widening-tells self-test: 15 of 456 case(s) failed.
--- restored blob hash: 24b8494cf3c4aa2058436de293ad6976f96e134a
RESTORED: byte-identical to HEAD, git diff HEAD empty
RESTORED self-test exit: 0 — 456 cases pass

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-tells403 cases / 24 batteries at 30bac2880, 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/objectstack from the worktree (no hand-fed path list; the change set is the tool's own, one path, three-dot against the merge base 30bac2880) derived 29 commands. All 29 were run, each exit code captured by redirect-then-$?, and all 29 exited 0. Reconciliation with --ran recording command :: exit N:

✓ dispatch-gates --ran: 29 derived famil(ies) accounted for — 29 run, 0 NOT-MEASURED
  (a DERIVED zero — all 29 recorded an exit code and none of them is 3).

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, and pnpm 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 lint exit 0 (eslint . --no-inline-config, the whole tree, run after the final commit). The control-byte self-scan over the edited file reports nothing, and pnpm check:nul-bytes exits 0.


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 17, 2026
@os-justin
os-justin marked this pull request as ready for review September 17, 2026 19:12
@os-justin
os-justin added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit e2050ce Sep 17, 2026
40 checks passed
@os-justin
os-justin deleted the claude/issue-18702-file-local-declaring-factories branch September 17, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants