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
149164const 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. */
232247const RECORDS_READ = / \? ? \. \s * r e c o r d s \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 (?: a d a p t e r | d a t a S o u r c e ) \s * \. \s * (?: f i n d | f i n d O n e ) \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 = / \b A r r a y \s * \. \s * i s A r r a y \s * \( \s * ( [ A - Z a - z _ $ ] [ \w $ ] * ) \s * \) / g;
346+
347+ /** `const rows = await adapter.find(...)` -- the declaration form of the binding. */
348+ const FIND_RESULT_DECL = / \b (?: c o n s t | l e t | v a r ) \s + ( [ A - Z a - z _ $ ] [ \w $ ] * ) \s * = / ;
349+
350+ /** `rows = await adapter.find(...)` -- the same binding as a bare reassignment. */
351+ const FIND_RESULT_ASSIGN = / ^ ( [ A - Z a - 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. */
312464const 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 (?: a d a p t e r | d a t a S o u r c e ) \s * \. \s * (?: f i n d | f i n d O n e ) \s * \( / ;
316-
317466/** The hook that hands a page the adapter. Case-sensitive, so `useAdapter` alone matches. */
318467const USE_ADAPTER = / \b u s e A d a p t e r \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