Skip to content

Commit 8ae1635

Browse files
yinlianghuiclaude
andauthored
fix(gate): read-seam recognizer matched a callee NAME with no shape check, so Array.prototype.find pulled non-read catches into the census (#12137)
* fix(gate): read-seam recognizer matched a callee NAME with no shape check, so Array.prototype.find pulled non-read catches into the census (#11921) `isReadCall()` decided "is this a storage read?" by matching the callee name against `DRIVER_READ_CALLEES` and nothing else. `find` is `IDataDriver.find` and it is also `Array.prototype.find`, so a pure in-memory helper matched — and the same-file wrapper recursion then attributed that "read" to the helper's CALLER, pulling a try/catch that guards no storage read into the read-seam population. The fix is a declared fact about the contract already anchored above the vocabulary, not a new list of names: every `IDataDriver` read takes the object NAME first, and `Array.prototype.find`'s first argument is always a predicate. A call whose first argument is a function literal is therefore not a call to that contract. It can only decline to exclude, so it cannot drop a real driver read. Measured over the full census rather than tuned to the known instance: the receiver-based alternative drops 14 of 66 seams, 12 of them real driver reads. The shape test drops 2, and the second (`resolveDeferredUpdates`, a WRITE seam) was not known when the card was filed. Census restated, not silently invalidated: 66 -> 64, re-measured with the corrected recognizer over the #8845 anchor tree as well as today's. No baseline entry added. Also decides #8897 (option 1) as its `Restart-when:` requires, and names #8901 per its trigger-file clause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 * test(gate): de-vacuum the #11921 wrapper fixture — it stayed green under ablation The wrapper-hop fixture spelled its array `find` inside a `records.map(...)` callback. `isReadCall`'s wrapper recursion walks the SAME TICK only, so the call was never reached in either direction: the case passed pre-fix and post-fix, asserting nothing. Caught by the reverse verification, not by review. Respelled as a same-tick `for` loop, which is also the shape of the live instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f5a7f9c commit 8ae1635

1 file changed

Lines changed: 278 additions & 7 deletions

File tree

scripts/check-durability-degradation-log-level.mjs

Lines changed: 278 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
* failure is known to mean "the bytes did not land". Adding an entry is a
5959
* deliberate, reviewable act.
6060
*
61-
* Two honest limitations, stated up front rather than discovered later:
61+
* Three honest limitations, stated up front rather than discovered later:
6262
*
6363
* 1. It cannot FIND a durability seam whose operation is not in the
6464
* vocabulary. It guarantees the seams already paid for cannot regress to
@@ -70,6 +70,12 @@
7070
* path does end loudly — but it does mean an unrelated nested
7171
* `logger.error` would satisfy the gate. Narrowing this would fail the
7272
* legitimate recovering catch, which is the worse trade.
73+
* 3. A catch that reports through a receiver spelled anything other than
74+
* `logger` / `log` / `console` reads as SILENT to both rules — the
75+
* dangling-reference audit's `port.warn?.(…)` is the live example (#8897).
76+
* This is a DECIDED narrowness, not an undiscovered one: see the decision
77+
* recorded at `LOGGER_RECEIVERS` for what was weighed and why widening it
78+
* was declined.
7379
*
7480
* ## Why AST, not regex
7581
*
@@ -653,7 +659,9 @@ const FAILURE_PROPAGATION_SITES = new Map([
653659
// (66 seams to 67) and reported as `no invented answer`, gate green. Surveyed,
654660
// cleared, harmful — the #6116 shape a third time.
655661
//
656-
// SECOND — the census, narrowing one criterion at a time: 66 read seams; 46 have
662+
// SECOND — the census, narrowing one criterion at a time (⚠️ the 66 is the
663+
// PRE-#11921 recognizer's count — 2 of it were not read seams; see "RECOGNIZER
664+
// CORRECTION" below before quoting any figure in this ladder): 66 read seams; 46 have
657665
// no `return` anywhere in the catch; 41 have a valueless exit; 31 of those are
658666
// silent; 25 are silent AND undiscriminated; 15 also have an empty accumulator
659667
// declared above the `try`, written inside it, and read below.
@@ -703,7 +711,9 @@ const FAILURE_PROPAGATION_SITES = new Map([
703711
// arithmetic standing in for a census — the count is not the argument, and a
704712
// number nobody can re-run is worth less than a smaller one anchored to a
705713
// commit. The anchor is checkable: run this gate at 8664a2c and it still
706-
// answers `66 read seam(s)`; today's main answers 67, a seam the scan roots
714+
// answers `66 read seam(s)` — with the recognizer of that day; #11921 corrected
715+
// it to 64 over the same tree, see "RECOGNIZER CORRECTION" — today's main
716+
// answered 67, a seam the scan roots
707717
// gained after that commit and NOT from #8895 (which moves nothing — below).
708718
//
709719
// - `cascadeDeleteRelations` — REPAIRED by #8895. Its dependents probe now
@@ -791,7 +801,9 @@ const FAILURE_PROPAGATION_SITES = new Map([
791801
// ## Measured before it was added — 收窄先行, the #6451 discipline again
792802
//
793803
// Census over the three scan roots at `origin/main` @ 945ffbea8 (2026-08-24),
794-
// 66 read seams, narrowing one criterion at a time:
804+
// 66 read seams (⚠️ pre-#11921 recognizer; 64 corrected — the ladder BELOW is
805+
// unaffected, measured, see "RECOGNIZER CORRECTION"), narrowing one criterion at
806+
// a time:
795807
//
796808
// | narrowing | sites |
797809
// |-------------------------------------------------------|------:|
@@ -903,6 +915,58 @@ const FAILURE_PROPAGATION_SITES = new Map([
903915
// writes nothing at all, and the `push` / `++` accumulation shapes #8845
904916
// measured, which are unchanged.
905917

918+
// ── RECOGNIZER CORRECTION (#11921) — what "66" actually counted ─────────────
919+
//
920+
// Every census figure above is quoted against a 66-seam read population. Two of
921+
// those 66 were never read seams. `isReadCall` matched the callee NAME with no
922+
// further test, so `Array.prototype.find` on a plain local array counted as a
923+
// storage read — and, through the wrapper recursion, attributed that "read" to
924+
// the wrapper's CALLER. The corrected recognizer is `contradictsDriverReadShape`
925+
// below; this block restates the affected figures rather than leaving them to be
926+
// silently invalidated.
927+
//
928+
// RE-MEASURED, not derived by arithmetic — the corrected recognizer was run over
929+
// the SAME anchor tree, so the correction is a census and not a subtraction:
930+
//
931+
// | tree | old recognizer | corrected |
932+
// |-------------------------------|---------------:|----------:|
933+
// | `origin/main` @ 8664a2c (#8845 anchor) | 66 | 64 |
934+
// | `origin/main` @ 8619f9513 (#11921) | 66 | 64 |
935+
//
936+
// The old recognizer reproducing 66 at 8664a2c is the calibration: it is the
937+
// number this header already told the reader to re-run the gate and check, so
938+
// the instrument is verified against the record before it is trusted.
939+
//
940+
// THE TWO NON-MEMBERS, the same two functions on both trees:
941+
//
942+
// - `getMetaItems` (`metadata-protocol/src/protocol.ts`) — its try guards
943+
// `mergePackageAwareOverlay`, a PURE helper whose `list.find((c) => …)` runs
944+
// on a plain local array. The `try` holds exactly one `await`,
945+
// `metadataService.list(request.type)`, and no find/findOne/count of its own.
946+
// Catch shape: empty, returnless, silent, undiscriminated — so it was a
947+
// member of the 46 / 41 / 31 / 25 rungs, and it carries the shape the 15 rung
948+
// tests for (`let items: unknown[] = []` above the `try`, written inside,
949+
// read below).
950+
// - `resolveDeferredUpdates` (`metadata-protocol/src/seed-loader.ts`) — NOT
951+
// found when #11921 was filed, and the worse of the two: its try guards
952+
// `writeDeferredReference`, a WRITE. It is in this rule's population at all
953+
// only because `allResults.find(r => …)` updates an in-memory stats row. Its
954+
// catch reports at `error`, so it left the ladder at the "silent" rung and
955+
// sits in the 46 / 41 rungs only.
956+
//
957+
// ⛔ The #8845 and #9165 ladder rungs are NOT re-derived here, deliberately.
958+
// Re-running those narrowings is the census re-run #8901's restart conjunct (b)
959+
// reserves, and #8901's own ⛔ forbids re-opening the #8845 decision; #11921's
960+
// filing forbids this fix riding along with any other change. What is restated
961+
// is the DENOMINATOR every one of those figures is quoted against, plus enough
962+
// of each departing seam's shape that any rung's owner can place it exactly.
963+
//
964+
// Directly measured and unaffected: the #9165 assignment ladder (3 / 1 / 1 / 0)
965+
// and every parenthetical in the verdict line — 8 type-discriminated, 1
966+
// pass-through, 1 answer-by-assignment, 1 baselined — are byte-identical before
967+
// and after. Only the seam count moves, and ⛔ no baseline entry was added: an
968+
// entry there says a human read the seam, not that a rule guessed wrong.
969+
906970
/**
907971
* Where the read-seam rule looks. Narrowed on purpose — see above.
908972
*
@@ -953,8 +1017,55 @@ const DRIVER_READ_CALLEES = new Map([
9531017
* Two hops is what the real chain needs (`_find` → `engine.find`) plus one.
9541018
* Measured on the scan scope: following wrappers grew the SEAM census from 45
9551019
* to 66 and the VIOLATION set not at all — it buys robustness at zero
956-
* false-positive cost today.
1020+
* false-positive cost today. (#11921: that 66 included 2 non-reads the wrapper
1021+
* recursion manufactured from `Array.prototype.find`; corrected, 45 to 64. The
1022+
* conclusion is unchanged — the VIOLATION set is still untouched.)
9571023
*/
1024+
/**
1025+
* The one thing the READ vocabulary above cannot say by NAME, said by SHAPE.
1026+
*
1027+
* `find` is `IDataDriver.find` and it is also `Array.prototype.find`, and a
1028+
* callee NAME cannot tell them apart. Until #11921 nothing tried: `isReadCall`
1029+
* matched the name with no further test, so `list.find((c) => c.pkg === id)` on
1030+
* a plain local array read as a storage read — and, through the wrapper
1031+
* recursion below, attributed that "read" to the wrapper's CALLER, pulling a
1032+
* try/catch that guards no storage read at all into the census.
1033+
*
1034+
* A receiver ALLOWLIST is the wrong answer here, and measurably so. Measured
1035+
* over the whole census (`--list`, 90 matched reads across 66 seams): the
1036+
* genuine driver reads arrive through `this`, `this.engine`, `this.ql`,
1037+
* `driver`, `engine`, `engineAny`, `ledger` and `port`; the array reads through
1038+
* `list`, `allResults` and a `.map(...)` chain. Nothing in the SHAPE of a
1039+
* receiver separates those two lists — only what the name is BOUND to does, and
1040+
* that is a type-checker. Requiring a `this`-rooted receiver, the closest
1041+
* syntactic approximation, drops 14 of the 66 seams and 12 of the 14 are real
1042+
* driver reads (history-cleanup, build-probes, the dangling-reference audit,
1043+
* `checkGovernance`). That is the UNSAFE direction: a population that shrinks
1044+
* silently. Declaring the receiver names instead is a second vocabulary with
1045+
* its own staleness obligation — the failure mode this file's staleness checks
1046+
* exist for — and it still fails the same way, because the next driver binding
1047+
* gets a different name and its seam leaves the census without a word.
1048+
*
1049+
* What CAN be said is said from the contract already anchored above, not from a
1050+
* new list of names: every read on `IDataDriver` takes the object NAME first —
1051+
* `find(objectName, query)`, `findOne(objectName, …)`, `count(objectName,
1052+
* query?)`. A predicate is never its first argument. `Array.prototype.find`'s
1053+
* first argument is ALWAYS one. So a call whose first argument is a function
1054+
* literal is not a call to this contract, however it is spelled.
1055+
*
1056+
* Direction of the error, stated up front: this predicate can only DECLINE to
1057+
* exclude. It fires on a shape the declared contract cannot produce, so it
1058+
* cannot drop a real driver read; and a spurious match it does not recognise —
1059+
* `list.find(isTarget)`, a named predicate rather than a literal — stays in the
1060+
* census, over-counting rather than under-counting. That is the same safe
1061+
* direction every other narrowing in this file takes.
1062+
*/
1063+
function contradictsDriverReadShape(node) {
1064+
const first = node.arguments[0];
1065+
if (!first) return false;
1066+
return ts.isArrowFunction(first) || ts.isFunctionExpression(first);
1067+
}
1068+
9581069
const MAX_READ_WRAPPER_DEPTH = 2;
9591070

9601071
/**
@@ -1084,7 +1195,54 @@ function calleeName(node) {
10841195
return undefined;
10851196
}
10861197

1087-
/** The receiver names that make a `<recv>.<level>(…)` call a LOG. */
1198+
/**
1199+
* The receiver names that make a `<recv>.<level>(…)` call a LOG.
1200+
*
1201+
* ## DECIDED, 2026-08-25 (#8897, option 1) — narrow on purpose, and recorded
1202+
*
1203+
* #8897's `Restart-when:` is *"any PR touches
1204+
* scripts/check-durability-degradation-log-level.mjs (decide options 1/2/3 in
1205+
* that change)"*. #11921 touched it, so the three-way is settled here rather
1206+
* than passed by. The options were: (1) leave it and note the narrowness beside
1207+
* the other honest limitations; (2) follow a same-file injected-logger
1208+
* PARAMETER the way this file already follows same-file helper FUNCTIONS;
1209+
* (3) declare additional receiver names explicitly.
1210+
*
1211+
* **Taken: option 1.** The filer's own weak preference, but the reason recorded
1212+
* here is not the filer's — it is the measurement #11921 ran on the mirror-image
1213+
* question, which none of the three options had.
1214+
*
1215+
* #11921's whole subject is that matching a NAME with no provenance manufactures
1216+
* false members: `find` is `IDataDriver.find` and also `Array.prototype.find`.
1217+
* Asking the same question of the receiver side was measured over the full
1218+
* read-seam census — and receiver PROVENANCE is not syntactically available
1219+
* either. Requiring a `this`-rooted receiver drops 14 of 66 seams, 12 of them
1220+
* real driver reads; declaring the receiver names instead only postpones that,
1221+
* because the next binding gets a name the list does not have.
1222+
*
1223+
* Options 2 and 3 are both that instrument, pointed at `loggerLevel`. And the
1224+
* direction of their error is the UNSAFE one here, which the filer's analysis
1225+
* did not price: for the read-seam rule `collectLoggedLevels` powers an
1226+
* EXEMPTION — "the catch said something, so this is the other rule's question".
1227+
* Widening what counts as a logger widens an exemption, so a seam that invents
1228+
* an answer is excused because something that is not a logger happens to own a
1229+
* `.warn`. Option 1 keeps the error in the direction this file takes everywhere
1230+
* else: a genuinely loud catch may be over-counted as silent, and no invention
1231+
* is ever excused for a reason nobody declared.
1232+
*
1233+
* ⚠️ What option 1 costs, stated rather than hidden: the misclassification is
1234+
* still real and still latent — the read-seam rule consults the log exemption
1235+
* only after finding an invented answer, and the live injected-receiver seam
1236+
* (`packages/objectql/src/integrity/dangling-reference-audit.ts`, receiver
1237+
* `port`) invents none, so it never reaches a verdict. #8897's SECOND restart
1238+
* condition is unchanged and is the promote-immediately one: a seam reporting
1239+
* through an injected receiver going red with a "silent" message. If that fires,
1240+
* this decision was wrong and the evidence says so out loud.
1241+
*
1242+
* ⛔ Not decided here, and deliberately: the CALL-SHAPE half of the same
1243+
* function's narrowness (#9657, closed) — a different defect in `loggerLevel`,
1244+
* already answered by #9609 with a named same-file helper.
1245+
*/
10881246
const LOGGER_RECEIVERS = /^(logger|log|console)$/i;
10891247

10901248
/** Every level name the two vocabularies above know. */
@@ -1880,7 +2038,11 @@ function unwrapExpression(expr) {
18802038
function isReadCall(node, functionBodies, seen = new Set(), depth = 0) {
18812039
const name = calleeName(node);
18822040
if (!name) return false;
1883-
if (DRIVER_READ_CALLEES.has(name)) return true;
2041+
if (DRIVER_READ_CALLEES.has(name) && !contradictsDriverReadShape(node)) return true;
2042+
// A vocabulary NAME whose argument shape contradicts the contract is not
2043+
// treated as a dead end: it falls through to the wrapper recursion, so a
2044+
// same-file helper that happens to be called `find` is still followed. The
2045+
// shape test subtracts a false positive; it must not subtract a real read.
18842046
if (depth >= MAX_READ_WRAPPER_DEPTH || seen.has(name)) return false;
18852047
const body = functionBodies.get(name);
18862048
if (!body) return false;
@@ -4813,6 +4975,115 @@ function selfTestReadSeams() {
48134975
expectViolation: false,
48144976
expectInvents: [],
48154977
},
4978+
4979+
// ── #11921: the callee NAME is not enough — the shape test, both ways ──
4980+
//
4981+
// `find` is `IDataDriver.find` and it is also `Array.prototype.find`.
4982+
// These five pin the line `contradictsDriverReadShape` draws, including
4983+
// the two places it deliberately does NOT draw it. `expectSeams` is the
4984+
// load-bearing assertion in the passing cases: `expectViolation: false`
4985+
// would hold vacuously if the seam were merely graded green, and what
4986+
// is being asserted here is that it is not in the POPULATION at all.
4987+
{
4988+
name: 'passes: #11921 — `Array.prototype.find` on a local array is not a storage read',
4989+
code: `
4990+
class L {
4991+
async pick(id: string) {
4992+
const list = [{ id: 'a' }];
4993+
try {
4994+
const hit = list.find((c) => c.id === id);
4995+
return hit ?? null;
4996+
} catch { return null; }
4997+
}
4998+
}`,
4999+
expectViolation: false,
5000+
expectSeams: 0,
5001+
},
5002+
{
5003+
// The live instance, reduced: a PURE helper matched on `find`, and
5004+
// the wrapper recursion then attributed the "read" to its caller —
5005+
// whose try guards a MetadataService call and no storage read at all.
5006+
name: 'passes: #11921 — an array `find` inside a same-file helper does not make its CALLER a read seam',
5007+
code: `
5008+
function mergeOverlay(base: unknown[], records: any[]) {
5009+
const list = base as any[];
5010+
const out: unknown[] = [];
5011+
// SAME-TICK on purpose: the wrapper recursion does not descend
5012+
// into a nested function body, so spelling this as
5013+
// \`records.map((r) => list.find(...))\` makes the fixture pass
5014+
// for a reason that has nothing to do with the shape test —
5015+
// measured, it stayed green under ablation.
5016+
for (const r of records) {
5017+
const prev = list.find((c) => c.pkg === r.pkg);
5018+
out.push(prev ?? r);
5019+
}
5020+
return out;
5021+
}
5022+
class P {
5023+
async getMetaItems(type: string) {
5024+
let items: unknown[] = [];
5025+
try {
5026+
const runtime = await this.service.list(type);
5027+
items = mergeOverlay(runtime, items);
5028+
} catch {
5029+
// MetadataService not available
5030+
}
5031+
return items;
5032+
}
5033+
}`,
5034+
expectViolation: false,
5035+
expectSeams: 0,
5036+
},
5037+
{
5038+
// The other direction, and the one that keeps the shape test honest:
5039+
// it must SUBTRACT a false positive without ever subtracting a read.
5040+
name: 'flags: #11921 — a vocabulary name taking a callback is still followed as a same-file wrapper',
5041+
code: `
5042+
class L {
5043+
private async find(pred: (r: any) => boolean) {
5044+
const rows = await this.driver.find(this.tableName, { where: {} });
5045+
return rows.filter(pred);
5046+
}
5047+
async loadMany(type: string) {
5048+
try { return await this.find((r) => r.type === type); }
5049+
catch { return []; }
5050+
}
5051+
}`,
5052+
expectViolation: true,
5053+
expectSeams: 1,
5054+
},
5055+
{
5056+
name: 'flags: #11921 — the shape test reads the FIRST argument, so a function in a query option changes nothing',
5057+
code: `
5058+
class L {
5059+
async loadMany(type: string) {
5060+
try { return await this.driver.find(this.tableName, { where: { type }, map: (r: any) => r }); }
5061+
catch { return []; }
5062+
}
5063+
}`,
5064+
expectViolation: true,
5065+
expectSeams: 1,
5066+
},
5067+
{
5068+
// The documented limitation, pinned rather than described. The test
5069+
// reads function LITERALS, so a named predicate still counts — the
5070+
// safe direction (over-count, never under-count), and the next
5071+
// reader sees where the line is instead of assuming it is elsewhere.
5072+
name: 'flags (documented limitation): #11921 — an array `find` with a NAMED predicate is still counted',
5073+
code: `
5074+
function matches(c: { id: string }) { return c.id === 'a'; }
5075+
class L {
5076+
async pick(id: string) {
5077+
const list = [{ id: 'a' }];
5078+
try {
5079+
const hit = list.find(matches);
5080+
return hit ?? null;
5081+
} catch { return null; }
5082+
}
5083+
}`,
5084+
expectViolation: true,
5085+
expectSeams: 1,
5086+
},
48165087
];
48175088

48185089
let failures = 0;

0 commit comments

Comments
 (0)