Skip to content

Commit a6a2af5

Browse files
claude[bot]claude
andauthored
feat(tooling): pin the Array.isArray limb with a third react-page detector (#13970) (#13989)
`ObjectStackAdapter.find()` cannot resolve to an array, so an `Array.isArray(<find result>)` limb is dead code that teaches a row shape the producer cannot emit. The shape was repaired three times (#11585 -> #13705 -> #13969) and each repair left nothing pinning it, so a reintroduction at any of the three sites got a green guard. `arrayIsArrayLimbs` is a THIRD detector, not a widening of `recordsReads`. The self-test case that pins `const records = result?.data ?? (Array.isArray(result) ? result : []);` to zero `recordsReads` findings is a false-positive control on the `.records` PROPERTY matcher — the `records` there is a local — and it is still correct. It is unchanged, byte for byte: the new detector sees the limb on that same line, and both statements are true. A subject qualifies on either route: bound from an `adapter`/`dataSource` find() in the same source, or read as `<subject>.data` on the same line. The second route is load-bearing — the renewals-pipeline repair's subject was a lambda parameter bound to no find() call anywhere. Measured over the pre-#13969 tree (bd8791f): exactly the three deleted sites across the whole population, no false positives. On `origin/main` today: zero. Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1b7d60 commit a6a2af5

1 file changed

Lines changed: 264 additions & 12 deletions

File tree

scripts/check-react-page-adapter-contract.mjs

Lines changed: 264 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
#!/usr/bin/env node
22
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
33
//
4-
// check-react-page-adapter-contract (#10751) -- the two `useAdapter()` contracts
5-
// a hand-rolled rollup owns, swept over EVERY react-page source this repo ships:
4+
// check-react-page-adapter-contract (#10751) -- the `useAdapter()` contracts a
5+
// hand-rolled rollup owns, swept over EVERY react-page source this repo ships:
66
// the app-showcase page modules AND the react-page samples in `content/docs`.
77
//
8+
// Three detectors: an unprefixed query option (#10288), a `.records` row read
9+
// (#10288, narrowed in #13705), and an `Array.isArray` limb on a find() result
10+
// (#13970). All three are DROP-SHAPED or DEAD -- nothing throws and the page
11+
// renders a plausible number either way, which is why neither `os validate`
12+
// nor `tsc` nor a smoke test catches them.
13+
//
814
// node scripts/check-react-page-adapter-contract.mjs
915
// node scripts/check-react-page-adapter-contract.mjs --self-test
1016
//
@@ -137,13 +143,22 @@ const CENSUS_ANCHORS = {
137143
};
138144

139145
// ---------------------------------------------------------------------------
140-
// The two detectors -- MOVED from
146+
// The detectors. Two were MOVED from
141147
// examples/app-showcase/test/react-page-adapter-query-contract.test.ts (#10288).
142148
// `unprefixedQueryKeys` is still verbatim. `recordsReads` is NOT: it arrived
143149
// carrying a `.data`-beside carve-out that made `data ?? records` invisible,
144150
// and that carve-out was narrowed to comment/string stripping in the same
145151
// edit that deleted the two aliases it was load-bearing for. See the
146152
// function's own header for why a tolerant alias is a finding.
153+
//
154+
// `arrayIsArrayLimbs` (#13970) is the third, and it is NEW here rather than
155+
// moved. Its history is the reason it exists: `Array.isArray(<find result>)`
156+
// is the SAME defect wearing a different name, it was repaired three times
157+
// (#11585 -> #13705 -> #13969), and each repair left nothing pinning it. It is
158+
// a separate function on purpose -- `recordsReads` matches the `.records`
159+
// property, and its self-test pins a line that carries the limb to ZERO,
160+
// correctly. Widening `recordsReads` would have had to flip that pin; a third
161+
// detector does not, and both statements about that line stay true.
147162
// ---------------------------------------------------------------------------
148163

149164
const DECLARED_QUERY_PARAM_PREFIX = '$';
@@ -231,6 +246,16 @@ export function unprefixedQueryKeys(source) {
231246
/** A `.records` / `?.records` PROPERTY read -- not the bare word, not a longer name. */
232247
const RECORDS_READ = /\??\.\s*records\b/;
233248

249+
/**
250+
* A real `find`/`findOne` on the objectui adapter -- never on `engine`/`client`.
251+
*
252+
* ONE definition, read by both consumers: `arrayIsArrayLimbs` (which needs to
253+
* know an identifier holds a find() result) and `isReactPageSample` (the docs
254+
* selector). Two copies of the marker would double the places a future
255+
* contract change has to land -- the shape of the defect this file exists for.
256+
*/
257+
const ADAPTER_CALL = /\b(?:adapter|dataSource)\s*\.\s*(?:find|findOne)\s*\(/;
258+
234259
/**
235260
* One line's executable text: string and template bodies blanked (quotes kept),
236261
* and a trailing `//` or block comment dropped.
@@ -262,6 +287,18 @@ export function codeOnly(line) {
262287
return out;
263288
}
264289

290+
/**
291+
* A WHOLE-LINE comment. Prose about the trap is not the trap -- `crm-workbench`
292+
* documents both traps in the comment block above the call it once got wrong,
293+
* and `contact-form` names `Array.isArray` in an unrelated design note.
294+
*
295+
* @param {string} trimmed a line, already trimmed
296+
* @returns {boolean}
297+
*/
298+
function isCommentLine(trimmed) {
299+
return trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
300+
}
301+
265302
/**
266303
* Every `.records` read off a find() result, judged on the line's CODE.
267304
*
@@ -297,23 +334,135 @@ export function recordsReads(source) {
297334
const out = [];
298335
for (const line of source.split('\n')) {
299336
const trimmed = line.trim();
300-
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
337+
if (isCommentLine(trimmed)) continue;
301338
if (!RECORDS_READ.test(codeOnly(trimmed))) continue;
302339
out.push(trimmed);
303340
}
304341
return out;
305342
}
306343

344+
/** An `Array.isArray(x)` test, capturing the identifier under test. */
345+
const ARRAY_ISARRAY_TEST = /\bArray\s*\.\s*isArray\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g;
346+
347+
/** `const rows = await adapter.find(...)` -- the declaration form of the binding. */
348+
const FIND_RESULT_DECL = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/;
349+
350+
/** `rows = await adapter.find(...)` -- the same binding as a bare reassignment. */
351+
const FIND_RESULT_ASSIGN = /^([A-Za-z_$][\w$]*)\s*=[^=]/;
352+
353+
/**
354+
* The identifiers a source binds to an `adapter`/`dataSource` find() result.
355+
*
356+
* Line-local by construction: a line that both calls the adapter and names its
357+
* target is the only binding this recognises. Published rather than left
358+
* implicit, because an unrecognised spelling produces no flag -- silently:
359+
*
360+
* const rows = await adapter.find(...) // and `let` / `var`
361+
* rows = await adapter.find(...) // reassignment at line start
362+
*
363+
* NOT recognised, stated rather than discovered later: a destructured binding
364+
* (`const { data } = await adapter.find(...)` -- which has no identifier to
365+
* test for array-ness anyway), a result handed through a `.then()`, and a
366+
* result passed into a helper. The last of those is real and occurs in this
367+
* tree, so it has a second route rather than a wider regex: see the `.data`
368+
* arm of `arrayIsArrayLimbs`.
369+
*
370+
* @param {string} source
371+
* @returns {Set<string>}
372+
*/
373+
export function findResultBindings(source) {
374+
const bound = new Set();
375+
for (const line of source.split('\n')) {
376+
const trimmed = line.trim();
377+
if (isCommentLine(trimmed)) continue;
378+
const code = codeOnly(trimmed);
379+
if (!ADAPTER_CALL.test(code)) continue;
380+
const decl = FIND_RESULT_DECL.exec(code);
381+
if (decl) { bound.add(decl[1]); continue; }
382+
const assign = FIND_RESULT_ASSIGN.exec(code);
383+
if (assign) bound.add(assign[1]);
384+
}
385+
return bound;
386+
}
387+
388+
/**
389+
* Every `Array.isArray()` test whose subject is an `adapter.find()` result.
390+
*
391+
* `ObjectStackAdapter.find()` cannot resolve to an array. Every return path is
392+
* an object literal or a `normalizeQueryResult(...)`, and normalizeQueryResult
393+
* WRAPS a bare array response into the same `{ data, total, page, pageSize,
394+
* hasMore }` envelope. So an `Array.isArray(<find result>)` limb is dead code:
395+
* it never executes, and what it costs is the same thing the `?? .records`
396+
* alias cost -- it teaches an author, and a coding assistant reading the page
397+
* as a sample, a row shape the producer cannot emit. The next author who
398+
* simplifies the chain then has to guess which limb was real.
399+
*
400+
* ⛔ THIS IS A THIRD DETECTOR, NOT A WIDENING OF `recordsReads`. That one
401+
* matches the `.records` PROPERTY; its self-test pins the repaired docs sample
402+
* -- `const records = result?.data ?? (Array.isArray(result) ? result : []);`
403+
* -- to ZERO findings, because the `records` there is a LOCAL, not a property
404+
* read. That pin is a false-positive control on the property matcher and it is
405+
* still correct, so it is unchanged: this function is what sees the limb on
406+
* that same line. The history is that this shape survived three rounds
407+
* (#11585 -> #13705 -> #13969) with nothing pinning it.
408+
*
409+
* A subject qualifies on either route, and a finding says which:
410+
*
411+
* 1. It is BOUND from an adapter/dataSource find() in this same source.
412+
* 2. It is read as `<subject>.data` / `<subject>?.data` on the SAME LINE --
413+
* the envelope read is what identifies it as a find() result. This is the
414+
* route that catches a subject with no binding to find at all: the
415+
* renewals-pipeline repair was `(res) => (Array.isArray(res) ? res :
416+
* (res && res.data) || [])`, whose subject is a LAMBDA PARAMETER.
417+
*
418+
* Neither route fires on an array narrowing that has nothing to do with the
419+
* adapter, which is the whole difficulty here: `Array.isArray` is ordinary
420+
* JavaScript. A page is free to test any other value for array-ness.
421+
*
422+
* Known exclusion, stated rather than discovered later: an ObjectQL
423+
* `engine.find` DOES resolve to an array-or-envelope union by contract, so
424+
* `Array.isArray` on one is correct. No page module in the swept population
425+
* holds one today (they hold `adapter.find` only, and the docs selector
426+
* refuses `engine.find` fences outright), and route 1 would not bind it. Route
427+
* 2 would fire on an `engine.find` result read as `.data` on the same line as
428+
* its own array test -- if that shape ever enters this population, extend the
429+
* detector in the same edit rather than routing around the gate.
430+
*
431+
* @param {string} source
432+
* @returns {{ subject: string, why: string, snippet: string, index: number }[]}
433+
*/
434+
export function arrayIsArrayLimbs(source) {
435+
const bound = findResultBindings(source);
436+
const out = [];
437+
let offset = 0;
438+
for (const line of source.split('\n')) {
439+
const start = offset;
440+
offset += line.length + 1;
441+
const trimmed = line.trim();
442+
if (isCommentLine(trimmed)) continue;
443+
const code = codeOnly(trimmed);
444+
for (const m of code.matchAll(ARRAY_ISARRAY_TEST)) {
445+
const subject = m[1];
446+
const envelopeRead = new RegExp(`\\b${subject}\\s*\\??\\s*\\.\\s*data\\b`);
447+
const why = bound.has(subject)
448+
? `\`${subject}\` is bound from an adapter/dataSource find() in this source`
449+
: envelopeRead.test(code)
450+
? `\`${subject}.data\` is read on this same line, so \`${subject}\` is the find() envelope`
451+
: null;
452+
if (why === null) continue;
453+
out.push({ subject, why, snippet: trimmed, index: start });
454+
}
455+
}
456+
return out;
457+
}
458+
307459
// ---------------------------------------------------------------------------
308460
// Population B -- fenced blocks in the docs corpus
309461
// ---------------------------------------------------------------------------
310462

311463
/** Languages a runnable react-page sample is tagged with. */
312464
const SAMPLE_LANGS = new Set(['jsx', 'tsx', 'js', 'ts', 'javascript', 'typescript']);
313465

314-
/** A real `find`/`findOne` on the objectui adapter -- never on `engine`/`client`. */
315-
const ADAPTER_CALL = /\b(?:adapter|dataSource)\s*\.\s*(?:find|findOne)\s*\(/;
316-
317466
/** The hook that hands a page the adapter. Case-sensitive, so `useAdapter` alone matches. */
318467
const USE_ADAPTER = /\buseAdapter\s*\(/;
319468

@@ -492,6 +641,15 @@ export function sweep(population) {
492641
+ `silently. In: ${line}`,
493642
);
494643
}
644+
for (const { subject, why, snippet, index } of arrayIsArrayLimbs(source)) {
645+
findings.push(
646+
`${at(lineOfIndex(source, index))}: tests \`Array.isArray(${subject})\` on a find() result `
647+
+ `(${why}). \`ObjectStackAdapter.find()\` resolves the same \`QueryResult\` envelope on every `
648+
+ `path — normalizeQueryResult WRAPS a bare array response into it — so the array limb is `
649+
+ `DEAD CODE teaching a row shape the producer cannot emit. Read \`${subject}.data\`. `
650+
+ `In: ${snippet}`,
651+
);
652+
}
495653
}
496654
}
497655
return findings;
@@ -527,17 +685,22 @@ function report({ population, findings, censusProblems }) {
527685
console.error(`✗ check-react-page-adapter-contract — ${findings.length} useAdapter() contract violation(s):\n`);
528686
for (const f of findings) console.error(` • ${f}`);
529687
console.error(
530-
`\n Both traps are DROP-SHAPED: nothing throws, nothing warns, and the page renders a\n`
531-
+ ` plausible number either way — which is why neither \`os validate\` nor \`tsc\` nor a\n`
532-
+ ` smoke test catches them. If a flagged fence is a deliberate counter-example, this\n`
688+
`\n Every trap here is DROP-SHAPED or DEAD: nothing throws, nothing warns, and the page\n`
689+
+ ` renders a plausible number either way — which is why neither \`os validate\` nor \`tsc\`\n`
690+
+ ` nor a smoke test catches them. An \`Array.isArray\` limb on a find() result never\n`
691+
+ ` executes at all: read \`.data\` and delete the limb rather than keeping both.\n`
692+
+ ` If a flagged fence is a deliberate counter-example, this\n`
533693
+ ` doc set writes those as prose (see the "$ prefixes are load-bearing" Callout in\n`
534694
+ ` ${DOCS_ROOT}/ui/react-pages.mdx); extend this gate with an opt-out in the same edit\n`
535695
+ ` rather than routing around it.\n`
536696
+ `\n Swept: ${scope}`,
537697
);
538698
return 1;
539699
}
540-
console.log(`✓ check-react-page-adapter-contract: ${scope} — every adapter query option is $-prefixed and every row read is off \`data\`.`);
700+
console.log(
701+
`✓ check-react-page-adapter-contract: ${scope} — every adapter query option is $-prefixed, every row read `
702+
+ `is off \`data\`, and no find() result is tested for array-ness.`,
703+
);
541704
return 0;
542705
}
543706

@@ -595,6 +758,78 @@ export function selfTest() {
595758
'the REPAIRED docs sample is silent — a local named `records` is not a `.records` read',
596759
);
597760

761+
// ── The THIRD detector: the `Array.isArray` limb (#13970) ────────────────
762+
// The assertion directly above is UNCHANGED, byte for byte, and that is the
763+
// point. It is a false-positive control on `recordsReads`'s PROPERTY matcher
764+
// -- the `records` on that line is a LOCAL, not a `.records` read -- and it
765+
// is still correct. `arrayIsArrayLimbs` is a separate function, so pinning
766+
// the limb cost that pin nothing: the same string is 0 findings for
767+
// `recordsReads` and 1 for the new detector, and both statements are true.
768+
//
769+
// The corpus below is the three lines PR #13969 actually deleted, carried
770+
// verbatim. Measured over the pre-#13969 tree (bd8791fd8), this detector
771+
// reports exactly these three across the whole population and nothing else.
772+
// The class survived three rounds (#11585 -> #13705 -> #13969) because
773+
// nothing pinned it; this block is the pin.
774+
assert(
775+
arrayIsArrayLimbs(`const records = result?.data ?? (Array.isArray(result) ? result : []);`).length === 1,
776+
'the DOCS line #13969 deleted IS a finding — the same string the assertion above pins to zero for `recordsReads`',
777+
);
778+
assert(
779+
arrayIsArrayLimbs(`const rows = Array.isArray(all) ? all : (all && all.data) || [];`).length === 1,
780+
'the crm-workbench line #13969 deleted IS a finding',
781+
);
782+
assert(
783+
arrayIsArrayLimbs(`const rows = (res) => (Array.isArray(res) ? res : (res && res.data) || []);`).length === 1,
784+
'the renewals-pipeline line #13969 deleted IS a finding — its subject is a LAMBDA PARAMETER bound to no find() call anywhere, so the same-line `.data` read is the only thing that identifies it',
785+
);
786+
const reintroduced = `
787+
const all = await adapter.find('showcase_project', { $top: 200 });
788+
const rows = Array.isArray(all) ? all : [];
789+
`;
790+
assert(
791+
arrayIsArrayLimbs(reintroduced).length === 1,
792+
'a reintroduction carrying NO `.data` limb is still a finding — the subject is bound from adapter.find() in the same source',
793+
);
794+
assert(
795+
JSON.stringify([...findResultBindings(reintroduced)]) === JSON.stringify(['all']),
796+
'the binding walk names the identifier the adapter call was assigned to',
797+
);
798+
assert(
799+
arrayIsArrayLimbs(`const rows = Array.isArray(all) ? all : [];`).length === 0,
800+
'the SAME line without the binding is silent — `Array.isArray` is ordinary JavaScript and a page may narrow any other value',
801+
);
802+
803+
// ...and the shapes it must NOT fabricate on. All four are text that sits in
804+
// this tree today.
805+
assert(
806+
arrayIsArrayLimbs(` // response into it, so an 'Array.isArray(all)' limb can never be taken.`).length === 0,
807+
'the comment #13969 left in crm-workbench explaining why the limb is unreachable is not the limb',
808+
);
809+
assert(
810+
arrayIsArrayLimbs(' // key discriminated by `Array.isArray`.').length === 0,
811+
'contact-form\'s unrelated design note naming Array.isArray in prose is not a finding',
812+
);
813+
assert(
814+
arrayIsArrayLimbs(`emit({ type: 'Array.isArray(result)', rows: result.data });`).length === 0,
815+
'text that merely SPELLS the test inside a string is not a test — codeOnly blanks string bodies',
816+
);
817+
assert(
818+
arrayIsArrayLimbs(`const rows = await engine.find('customer', { where: {} });\nreturn Array.isArray(rows) ? rows : rows.records;`).length === 0,
819+
'an ObjectQL `engine.find` result IS an array-or-envelope union by contract — app-showcase reads exactly this shape in its job runtime, and the detector must not fabricate a finding on it',
820+
);
821+
assert(
822+
arrayIsArrayLimbs(`const all = await adapter.find('showcase_project', { $top: 200 });\nconst rows = all?.data ?? [];`).length === 0,
823+
'the REPAIRED crm-workbench shape is silent — the detector reds the limb, not the adapter call',
824+
);
825+
826+
// ...and a finding is separable and actionable.
827+
const limb = arrayIsArrayLimbs(`const rows = Array.isArray(page) ? page : page.data;`);
828+
assert(
829+
limb.length === 1 && limb[0].subject === 'page' && limb[0].why.includes('.data'),
830+
'a finding names the identifier under test and WHICH route qualified it — two limbs in one file must be separable',
831+
);
832+
598833
// ...and the narrowing must not start firing on text that merely SPELLS it.
599834
assert(
600835
recordsReads(`emit({ type: 'data.records.updated' });`).length === 0,
@@ -711,6 +946,22 @@ export function selfTest() {
711946
'a docs finding names the line IN THE FILE, not in the fence body — a fence-relative line sends the author nowhere, got '
712947
+ fromDocs[0].split(':').slice(0, 2).join(':'),
713948
);
949+
const limbSource = `
950+
const all = await adapter.find('showcase_project', { $top: 200 });
951+
const rows = Array.isArray(all) ? all : (all && all.data) || [];
952+
`;
953+
const limbFromPage = sweep({ appShowcase: [{ file: 'p.ts', startLine: 1, source: limbSource }], docs: [] });
954+
assert(
955+
limbFromPage.length === 1 && limbFromPage[0].startsWith('p.ts:3'),
956+
'the sweep wires the THIRD detector to the app-showcase half and names the line, got '
957+
+ limbFromPage.map((f) => f.split(':').slice(0, 2).join(':')).join(' / '),
958+
);
959+
const limbFromDocs = sweep({ appShowcase: [], docs: [{ file: 'd.mdx', startLine: 100, source: limbSource }] });
960+
assert(
961+
limbFromDocs.length === 1 && limbFromDocs[0].startsWith('d.mdx:102'),
962+
'and to the DOCS half, naming the line IN THE FILE — the sample a customer copies from is the half this class kept surviving in, got '
963+
+ limbFromDocs.map((f) => f.split(':').slice(0, 2).join(':')).join(' / '),
964+
);
714965
assert(lineOfIndex('a\nb\nc', 4) === 3 && lineOfIndex('a\nb', 0) === 1, 'lineOfIndex counts newlines before the offset');
715966
assert(lineOfText('x\n hit\ny', 'hit') === 2 && lineOfText('x', 'nope') === 1, 'lineOfText matches on the TRIMMED line, and falls back to 1');
716967

@@ -720,7 +971,8 @@ export function selfTest() {
720971
return 1;
721972
}
722973
console.log(
723-
`✓ check-react-page-adapter-contract --self-test: ${checked} assertions — both detectors observed FIRING and observed silent, `
974+
`✓ check-react-page-adapter-contract --self-test: ${checked} assertions — all THREE detectors observed FIRING and observed silent, `
975+
+ `the \`Array.isArray\` limb pinned on the three lines #13969 deleted (the docs one on the very string \`recordsReads\` is pinned to IGNORE), `
724976
+ `the selector observed refusing the engine.find / useQuery / webhook shapes it must not fabricate on, `
725977
+ `and an empty sweep of EITHER half observed failing the census.`,
726978
);

0 commit comments

Comments
 (0)