Resolve the swallow census's same-file hop by scope, not file order - #13915
Conversation
…t file order `indexFunctionBodies` in `scripts/measure-durability-swallow-family.mjs` built a flat, file-scoped index keyed by bare name and filled LAST-WINS, so a file that declared a name more than once resolved every call to it to the LAST declaration, wherever the call was written. #13459 fixed the bare-identifier half by walking the call site's lexical scope chain; the index itself stayed flat, and it is still consulted for `this.foo(...)` and for a bare name the chain does not bind. Transfers #13474's landed shape from the sibling gate: each declaration is recorded with the scope it is visible from (a method keyed by its owning class or object literal, a lexical form by its enclosing block), and `get(name, from)` walks outward from the call site, innermost first. Both of that ruling's deliberate departures come with it -- one declaration resolves from anywhere, so the change touches only collisions; an ambiguous name with no declaration enclosing the call site is refused, because file order is not evidence. Measured on this tree: 209 collision inserts across 73 names in 44 files, of which 27 reached call sites resolved to a different body -- 26 of them in `packages/objectql/src/engine.ts`, where `class ObjectRepository` and `class ScopedContext` redeclare `update`, `delete`, `find`, `findOne` and `transaction` thousands of lines after `class ObjectQL`, so every such call inside `ObjectQL` was answered with the other class's method. The census output does not move: 56 members and 98 quiet on both sides, with identical stats. That is the point -- membership cannot see this defect, so `RESOLUTION_CONTROLS` reads the resolver directly and pins what resolved to what, in both directions, and fails a control that matches no call site rather than passing vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
…sured site Ablating the one-declaration departure left both of the intended "dropped direction" controls green: `collectBundleActions :: push` resolves through the scope walk (the name has two bodies and one of them encloses the call), and the `http-dispatcher :: getService` site turned out to sit inside `HttpDispatcher` itself, so its declaration scope was on the ancestor chain all along. Neither pinned the departure they claimed to. Measured the sites that actually depend on it — 7 in the scan root — and re-pointed the control at `email-plugin.ts :: upsertTemplate :: update`, whose single body is an object-literal method whose scope is NOT on the call site's ancestor chain, and whose callee is in `WRITE_SHAPED_CALLEES`. Corrected the `push` control's stated scope to what the ablation showed it covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
… intended one Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
…allow-family-flat-index
|
ACCEPT — ⭐ The best thing in this report is that the ablation falsified the dev's own controls, twiceA2 was written to redden two "dropped direction" controls and reddened neither — ⇒ A control that cannot go red is a control that proves nothing. Catching that in your own test set, by ablation, is the difference between a suite and a decoration. Verification — re-derived by this seat
The open question — ruled: C for this PR, and B is routed, ⛔ not refusedThe dev asked whether to wire the census's A is refuted by the file's own record, ⛔ not by my preference. C ships here. The instrument prints its own identity on every run — "durability swallow-family census (#12981) — a MEASUREMENT, not a gate" — and the programme already has a planned end state that is not this: the handover section reserves the ruling's LAST step for adding B is well-motivated and I am not the one to take it. ⭐ My own evidence supports the dev's split: the instability is specific to The CI wake on this PR — superseded, ⛔ no action owedA Governed-surface checkDiff is Arming7 checks still running on Generated by Claude Code |
Fixes #13785
indexFunctionBodiesinscripts/measure-durability-swallow-family.mjsbuilt a flat,file-scoped index keyed by bare name and filled last-wins, so a file declaring a name
more than once resolved every call to it to the last declaration, wherever the call was
written. This transfers the shape PR #13784 landed in the sibling gate: each declaration is
recorded with the scope it is visible from, and
get(name, from)walks outward from thecall site, innermost first.
1. The constraint the card named: one of the two absences is FALSIFIED
The card blocked a drive-by fix on two claimed absences. Checked both on this tree:
A regression control has a home — the card's first claim does not hold. The file already
carries a declared-control harness asserted by
--self-test:POSITIVE_CONTROLS,NEGATIVE_CONTROLS, and — directly on point —REGRESSION_CONTROLS, documented as "a(file, function) that must never come back as a member". Its two existing entries are the
two previous resolution defects in this same resolver (
db-job-adapter.ts :: cancel, thedotted-path hop;
action-execution.ts :: invokeBusinessAction, the shadowed bareidentifier). So the harness is not only present, it is already load-bearing for exactly this
defect class. No harness had to be invented, and the new controls extend that sanctioned
family rather than opening a new one.
No workflow wires it — the card's second claim holds. Verified two ways: no
check:*entry in
package.jsonnames the script (146 scripts read), and no workflow runs it. Itsown header says so, and that is still true after this PR. The only reference anywhere is
scripts/pm/dispatch-gates.mjs, which uses the path as a fixture argument to its ownself-test, not as a runner.
Recommendation, not shipped here. The sanctioned wiring shape already exists in this
repo:
check:stall-guard-headroomrunsnode scripts/measure-stall-guard-headroom.mjs --self-test— a non-gatemeasure-*instrument whose self-test only is wired as acheck:*and called fromlint.yml. The same two lines would wire this one. I did notapply it unilaterally, because it has a cost the ruling did not sanction and this file has
already paid it once: the self-test's positive controls are #12981 repair targets, and the
file's own docblock records that a previous positive control "turned this self-test red for
doing exactly what the ruling asked" when batch 2 repaired it. Wiring converts that from a
local surprise into a merge blocker for the repair programme. That is a PM/maintainer call,
so it is reported rather than taken.
2. Re-derived collision count (the card's figure was measured at
597020aa5)Instrumenting the index to count an insert over an existing key and running the census
unchanged over its own scan surface:
209 — identical to the card's figure, on a tree that has moved a long way since. Two
notes on that agreement. First, #13459 (the bare-identifier lexical walk) was already an
ancestor of
597020aa5, so the card measured the same code shape I did; the count is stablebecause it counts declarations, which this repo adds slowly, not call sites. Second, the
count is the size of the exposure, not the defect — as the card and triage both said.
3. The two-instrument disagreement (A2.4) — it fires, and it is the oracle
Ran both indexes over the same corpus and compared at every point the flat index is actually
consulted. 6,309 reached lookups; the two instruments disagree on 27:
this.fooThe 26 are one live instance, and it is a durability-relevant one.
packages/objectql/src/engine.tsdeclaresfind,findOne,updateanddeleteonclass ObjectQL(line 2219) and again onclass ObjectRepository(line 13365), plustransactiononObjectQLand again onScopedContext(line 13469). Every one of thosecalls written as
this.X(...)insideObjectQLwas answered with the other class'smethod, chosen for no reason but being last in the file:
updateanddeleteare inWRITE_SHAPED_CALLEES, so the hop feeds conjunct 3 directly.The 27th is
packages/runtime/src/app-plugin.ts:456:(out[mapped] ??= []).push(name)— anArraymethod whose receivercalleePatherases — walked into one of two unrelatedconst push = (arr) => {…}helpers 1,300 lines away.4. Why the control could not read the census output
The census output does not move. Before and after the repair: 56 members, 98 quiet,
identical stats. Both
ObjectQL.updateandObjectRepository.updatebottom out in driverwrites, so conjunct 3 was satisfied under either resolution — the census was accidentally
right.
That is the trap this card warned about, made concrete: a control that reads membership is
green against the exact bug it would exist to catch, and the file's existing
REGRESSION_CONTROLSare membership assertions. So this PR addsRESOLUTION_CONTROLS, whichreads the resolver directly and pins what resolved to what. Six entries, keyed on
(file, callee)plus the call site's enclosing class or function, each declaring the classor function that owns the body the call must reach — or
nullfor "must refuse". Twoproperties are asserted, and the second matters: every matching site resolves as declared,
and at least one site matches, so a control whose site was renamed away fails instead of
passing vacuously.
5. Ablation — including the two rounds where it caught my own controls
Every leg: mutate, prove the mutation reached disk (anchored greps in both directions plus a
git hash-objectcomparison against theHEADblob), run, then restore withgit checkout HEAD -- ABSOLUTE_PATHunder anEXIT/INT/TERMtrap and prove the restorewith an empty
git diff HEADand a blob-hash match. No build ordist/is involved: noderuns this
.mjsand its./ts-parse.mjsimport from source, so there is no stale-artifactleg to rebuild — stated rather than skipped.
A1 is the headline: the exact bug this PR fixes turns the new family red and leaves every
membership control green, on a census whose output has not moved.
The ablation twice falsified my own controls, and both are corrected in the diff. A2 was
supposed to redden two "dropped direction" controls and reddened neither: the
http-dispatcher :: getServicesite I had picked turned out to sit insideHttpDispatcheritself, so its declaration scope was on the ancestor chain all along, and
collectBundleActions :: pushresolves through the lexical walk before the index is everconsulted. I measured the sites that genuinely depend on the departure — 7 in the scan
root — and re-pointed the control at
email-plugin.ts :: upsertTemplate :: update, whosesingle body is an object-literal method whose scope is not on the call site's ancestor
chain, and whose callee is in
WRITE_SHAPED_CALLEES. A4 then established what thepushcontrol really pins, and its stated scope now says so. Both controls'
whytext records themeasured sensitivity rather than the intended one.
6. The two departures, transferred with their reasons
Both come from #13474's ruling and both are load-bearing here:
this change touches only collisions. 7 live sites depend on it; A2 shows removing it is
silent in the census output, which is precisely why it needs a control.
this is also silent in the output, and controls 1-3 catch it.
Docblocks updated in the same commit — the header's scope section,
sameFileCallee, andresolveSameFileBody— since the sibling needed a follow-up PR (#13846) for exactly thedrift of leaving a docblock describing the flat index.
7. Verification
Gate family derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths passed; the script reads its own change set), re-derived after the
origin/mainmerge. All runs below are on the final commit,
3812423f7.node scripts/check-test-completeness.mjs— NOT MEASURED, not a red: exit 3,PREREQUISITE NOT MET. It grades a savedturbo run testlog that only CI produces, andits own text says the family list names it with no argument and there is nothing here to
fix.
pnpm check:ratchet-remedy-authority— green (run unconditionally as instructed; itspopulation is built by
readdirSyncand no path derivation names it):OK check-ratchet-remedy-authority: 179 scripts swept (scripts/*.{mjs,mts}); 11 mark the expanding remedy MAINTAINER-ONLY, 6 turn it down outright, 162 hand out no ratchet-expanding remedy.node scripts/measure-durability-swallow-family.mjs --self-test— green:4 positive control(s) yield members at their declared tier, 3 negative control(s) yield none, 2 regression control(s) stay clear, 6 resolution control(s) resolve as declared, 56 member site(s) totalnode scripts/pm/dispatch-gates.mjs --self-test— run as belt-and-braces because thatscript uses this file's path as a fixture argument; it is not in the derived family and
this PR does not touch it. Its result is reported on the card rather than asserted here.
ESLint — a measured narrowing, not a skipped run. Ran on the changed file, not the repo,
with three pieces of evidence rather than an assertion: (1) the population is read from
eslint itself — it inspected the file rather than reporting it ignored; (2) the file count
comes from
--format json, which reports 1 file inspected, 0 errors, 0 warnings, exit 0, andthe diff changes exactly 1 file; (3) invariance for untouched files — this repo runs one
eslint.config.mjswhich never enables type-aware linting for any file (noparserOptions.project, no typed rules), a property measured with a positive control andrecorded in
scripts/check-query-options-erasure-ratchet.mjs's header, so ESLint judges eachfile independently and a one-file diff cannot move any untouched file's verdict. CI runs the
full farm regardless.
8. Changeset
None, deliberately. The diff is one file under
scripts/, which is not inside anyworkspace package (
pnpm-workspace.yamllists onlypackages/*,apps/*,examples/*andtheir subtrees) and the root manifest is
private: true— nothing publishes. The two nearestneighbours landed the same way: #13459 (this same file) and #13784 (the sibling gate) were
both single-file script-only diffs with no changeset.
lint.yml's own prose calls this caseout by name: "this PR edits a CI-internal script is the textbook
skip-changesetcase — sucha PR releases nothing". The label is applied accordingly.
Generated by Claude Code