Skip to content

Commit eb10fcf

Browse files
claude[bot]claude
andauthored
feat(tooling): sweep the react-page useAdapter() contracts over the docs corpus too (#11584)
One wrong read -- `result.records` off an `ObjectStackAdapter.find()` result, where the normalized `QueryResult` only ever declares `data` -- was repaired three separate times: two app-showcase pages and `content/docs/ui/react-pages.mdx`. The guard left behind after the second one had the right detectors and the wrong population: it read the app-showcase page registry, so the react samples in `content/docs` -- the copy a customer starts from -- were invisible to it. `recordsOnlyReads()` and `unprefixedQueryKeys()` MOVE into `scripts/check-react-page-adapter-contract.mjs`, which sweeps both populations. They move rather than being copied: two definitions of one detector double the places a future fix has to land, which is the defect, not a fix for it. The example app's test keeps the half a text scan cannot do -- it executes the renewals-pipeline rollup against a contract-faithful adapter double. A gate rather than that test with a wider reach, decided by measurement: `content/docs/**` as a declared cross-package test input would put the example app's suite on 22 of the last 132 commits (against 3 that touch the app), which is the cost `check-cross-package-test-inputs`'s own roster refuses twice; and the per-page narrowing it prefers instead rebuilds this defect, since a list someone must remember to extend goes silently incomplete the day a react sample lands on a second page. The census control is re-pointed: an empty sweep of EITHER half fails, and each of the three files the defect was repaired in is pinned as an anchor, so a population that is non-empty but has lost coverage fails too. Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c01426 commit eb10fcf

4 files changed

Lines changed: 699 additions & 121 deletions

File tree

.github/workflows/lint.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,35 @@ jobs:
885885
- name: Docs redirect destinations resolve, and no chains
886886
run: pnpm check:docs-redirects
887887

888+
# #10751 react-page `useAdapter()` contracts, swept over the app-showcase
889+
# page modules AND the react-page samples in content/docs. Both traps are
890+
# DROP-SHAPED — an unprefixed `top:` is discarded by the adapter and the
891+
# read runs unbounded, and `.records` off a `QueryResult` is `undefined`
892+
# forever — so nothing throws, nothing warns, and the page renders a
893+
# plausible number either way.
894+
#
895+
# This job rather than the example app's own test suite, decided by
896+
# measurement rather than preference: the guard that came before this
897+
# (#10288) lived in `examples/app-showcase/test/` and swept that app's page
898+
# registry, so the docs samples were invisible to it and the SAME wrong
899+
# read survived a third time in `content/docs/ui/react-pages.mdx`. Giving
900+
# that test the docs corpus needs a `check:cross-package-test-inputs`
901+
# declaration plus turbo input hashing, and both spellings are wrong here:
902+
# `content/docs/**` puts the example app's whole suite on 22 of the last
903+
# 132 commits (against 3 that touch the app itself), which is the cost that
904+
# gate's own roster refuses in those words, and the per-page narrowing it
905+
# prefers instead rebuilds this defect — a list someone must remember to
906+
# extend the day a react sample lands on a second page. A gate in this job
907+
# has no radius to maintain: it runs on every PR over the whole tree.
908+
#
909+
# Its --self-test is where the detectors are observed FIRING. The live
910+
# corpus is green (that is the point), so a passing run over real data
911+
# cannot tell a working scanner from one that finds nothing — and the
912+
# census control, which fails when either half of the population comes
913+
# back empty, is what stops a vacuous green from reading as coverage.
914+
- name: React pages honour the useAdapter() query and result contracts
915+
run: pnpm check:react-page-adapter-contract
916+
888917
# #9632 published-README links: a README in a package's `files` array with
889918
# `private` unset is rendered on npm and on GitHub as well as here, and
890919
# NOTHING read its links. Measured before the gate was written: the lychee

examples/app-showcase/test/react-page-adapter-query-contract.test.ts

Lines changed: 17 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { describe, it, expect } from 'vitest';
44

5-
import * as pages from '../src/ui/pages/index.js';
65
import { RenewalsPipelinePage } from '../src/ui/pages/index.js';
76

87
/**
@@ -205,124 +204,21 @@ describe('renewals-pipeline hand-rolled rollup — the adapter contract, execute
205204
});
206205

207206
// ---------------------------------------------------------------------------
208-
// The same two contracts, swept across every react page this app ships
207+
// The static sweep of the same two contracts MOVED OUT of this file (#10751)
209208
// ---------------------------------------------------------------------------
210-
211-
const DECLARED_QUERY_PARAM_PREFIX = '$';
212-
213-
/** Top-level keys of an object-literal source slice. */
214-
function topLevelKeys(objSrc: string): string[] {
215-
const keys: string[] = [];
216-
let depth = 0;
217-
let i = 0;
218-
let expectKey = true;
219-
while (i < objSrc.length) {
220-
const c = objSrc[i];
221-
if (c === '{' || c === '[' || c === '(') { depth++; i++; continue; }
222-
if (c === '}' || c === ']' || c === ')') { depth--; i++; continue; }
223-
if (depth === 1) {
224-
if (c === ',') { expectKey = true; i++; continue; }
225-
if (c === ':') { expectKey = false; i++; continue; }
226-
if (expectKey) {
227-
const m = /^(['"]?)([A-Za-z_$][\w$]*)\1\s*:/.exec(objSrc.slice(i));
228-
if (m) { keys.push(m[2]); i += m[0].length; expectKey = false; continue; }
229-
}
230-
}
231-
i++;
232-
}
233-
return keys;
234-
}
235-
236-
interface QueryFinding { key: string; snippet: string }
237-
238-
/** Every unprefixed key handed to an `adapter.find`/`findOne` in one source. */
239-
function unprefixedQueryKeys(source: string): QueryFinding[] {
240-
const found: QueryFinding[] = [];
241-
const call = /\b(?:adapter|dataSource)\s*\.\s*(?:find|findOne)\s*\(/g;
242-
let m: RegExpExecArray | null;
243-
while ((m = call.exec(source))) {
244-
// Walk to the params object literal, staying inside this call's parens.
245-
let i = m.index + m[0].length;
246-
let depth = 1;
247-
let objStart = -1;
248-
while (i < source.length && depth > 0) {
249-
const c = source[i];
250-
if (c === '(') depth++;
251-
else if (c === ')') { depth--; if (depth === 0) break; }
252-
else if (c === '{' && depth === 1) { objStart = i; break; }
253-
i++;
254-
}
255-
if (objStart < 0) continue;
256-
let braces = 0;
257-
let objEnd = -1;
258-
for (let j = objStart; j < source.length; j++) {
259-
if (source[j] === '{') braces++;
260-
else if (source[j] === '}') { braces--; if (braces === 0) { objEnd = j; break; } }
261-
}
262-
if (objEnd < 0) continue;
263-
const obj = source.slice(objStart, objEnd + 1);
264-
for (const k of topLevelKeys(obj)) {
265-
if (!k.startsWith(DECLARED_QUERY_PARAM_PREFIX)) {
266-
found.push({ key: k, snippet: obj.replace(/\s+/g, ' ').slice(0, 100) });
267-
}
268-
}
269-
}
270-
return found;
271-
}
272-
273-
/**
274-
* A `.records` read with no `.data` beside it, off a find() result.
275-
*
276-
* Comment lines are skipped: a page that explains the trap in prose (and
277-
* `crm-workbench` does, right above the call it once got wrong) is documenting
278-
* the contract, not violating it. The read itself is what this looks for.
279-
*/
280-
function recordsOnlyReads(source: string): string[] {
281-
const out: string[] = [];
282-
for (const line of source.split('\n')) {
283-
const trimmed = line.trim();
284-
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
285-
if (!trimmed.includes('.records')) continue;
286-
if (trimmed.includes('.data')) continue;
287-
out.push(trimmed);
288-
}
289-
return out;
290-
}
291-
292-
const REACT_PAGES = Object.values(pages as Record<string, unknown>)
293-
.filter((p): p is { name: string; kind?: string; source?: string } =>
294-
!!p && typeof p === 'object' && (p as { kind?: string }).kind === 'react')
295-
.filter((p) => typeof p.source === 'string');
296-
297-
describe('every kind:"react" page in this app honours the useAdapter contracts', () => {
298-
it('found the react pages to sweep (census control)', () => {
299-
// A sweep over an empty list is vacuously green — this is what stops that.
300-
expect(REACT_PAGES.length).toBeGreaterThanOrEqual(2);
301-
expect(REACT_PAGES.map((p) => p.name)).toContain('showcase_renewals_pipeline');
302-
});
303-
304-
it('the scanners fire on a known-bad source (positive control)', () => {
305-
const bad = `
306-
const a = await adapter.find('showcase_project', { $filter: ['account', '=', sel], top: 500 });
307-
const b = await adapter.find('showcase_invoice', { limit: 200 });
308-
// a comment mentioning .records must NOT count as a read
309-
const rows = (a && a.records) || [];
310-
`;
311-
expect(unprefixedQueryKeys(bad).map((f) => f.key)).toEqual(['top', 'limit']);
312-
expect(recordsOnlyReads(bad)).toEqual(['const rows = (a && a.records) || [];']);
313-
});
314-
315-
it.each(REACT_PAGES.map((p) => [p.name, p.source as string] as const))(
316-
'%s passes only $-prefixed query options',
317-
(_name, source) => {
318-
expect(unprefixedQueryKeys(source)).toEqual([]);
319-
},
320-
);
321-
322-
it.each(REACT_PAGES.map((p) => [p.name, p.source as string] as const))(
323-
'%s reads rows off QueryResult.data',
324-
(_name, source) => {
325-
expect(recordsOnlyReads(source)).toEqual([]);
326-
},
327-
);
328-
});
209+
//
210+
// `recordsOnlyReads()` and `unprefixedQueryKeys()` now live in
211+
// `scripts/check-react-page-adapter-contract.mjs` (`pnpm check:react-page-adapter-contract`),
212+
// which sweeps this app's page modules AND the react-page samples in
213+
// `content/docs` — the copy a customer starts from, and the population gap
214+
// that let the same `.records` read survive a third time after the two fixes
215+
// this file's harness was written for.
216+
//
217+
// They moved rather than being copied. Two definitions of the same detector
218+
// double the places a future fix has to land, which IS the defect (#10751):
219+
// one wrong read, repaired three separate times. The scanners' positive
220+
// control moved with them, into that gate's `--self-test`.
221+
//
222+
// What stays here is the half a text scan cannot do: the block above EXECUTES
223+
// the real rollup effect against a contract-faithful adapter double, so it
224+
// judges the numbers a page produces rather than the shape of its source.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"check:docs-redirects": "node scripts/check-docs-redirects.mjs --self-test && node scripts/check-docs-redirects.mjs",
4444
"check:docs-image-tag": "node scripts/check-docs-image-tag.mjs --self-test && node scripts/check-docs-image-tag.mjs",
4545
"check:docs-image-tag-sync": "node scripts/sync-docs-image-tags.mjs --self-test",
46+
"check:react-page-adapter-contract": "node scripts/check-react-page-adapter-contract.mjs --self-test && node scripts/check-react-page-adapter-contract.mjs",
4647
"check:template-version-sync": "node scripts/sync-template-versions.mjs --self-test",
4748
"check:role-word": "node scripts/check-role-word.mjs --self-test && node scripts/check-role-word.mjs",
4849
"check:quick-reference-counts": "node scripts/check-quick-reference-counts.mjs --self-test && node scripts/check-quick-reference-counts.mjs",

0 commit comments

Comments
 (0)