Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -885,6 +885,35 @@ jobs:
- name: Docs redirect destinations resolve, and no chains
run: pnpm check:docs-redirects

# #10751 react-page `useAdapter()` contracts, swept over the app-showcase
# page modules AND the react-page samples in content/docs. Both traps are
# DROP-SHAPED — an unprefixed `top:` is discarded by the adapter and the
# read runs unbounded, and `.records` off a `QueryResult` is `undefined`
# forever — so nothing throws, nothing warns, and the page renders a
# plausible number either way.
#
# This job rather than the example app's own test suite, decided by
# measurement rather than preference: the guard that came before this
# (#10288) lived in `examples/app-showcase/test/` and swept that app's page
# registry, so the docs samples were invisible to it and the SAME wrong
# read survived a third time in `content/docs/ui/react-pages.mdx`. Giving
# that test the docs corpus needs a `check:cross-package-test-inputs`
# declaration plus turbo input hashing, and both spellings are wrong here:
# `content/docs/**` puts the example app's whole suite on 22 of the last
# 132 commits (against 3 that touch the app itself), which is the cost that
# gate's own roster refuses in those words, and the per-page narrowing it
# prefers instead rebuilds this defect — a list someone must remember to
# extend the day a react sample lands on a second page. A gate in this job
# has no radius to maintain: it runs on every PR over the whole tree.
#
# Its --self-test is where the detectors are observed FIRING. The live
# corpus is green (that is the point), so a passing run over real data
# cannot tell a working scanner from one that finds nothing — and the
# census control, which fails when either half of the population comes
# back empty, is what stops a vacuous green from reading as coverage.
- name: React pages honour the useAdapter() query and result contracts
run: pnpm check:react-page-adapter-contract

# #9632 published-README links: a README in a package's `files` array with
# `private` unset is rendered on npm and on GitHub as well as here, and
# NOTHING read its links. Measured before the gate was written: the lychee
Expand Down
138 changes: 17 additions & 121 deletions examples/app-showcase/test/react-page-adapter-query-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,6 @@

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

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

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

// ---------------------------------------------------------------------------
// The same two contracts, swept across every react page this app ships
// The static sweep of the same two contracts MOVED OUT of this file (#10751)
// ---------------------------------------------------------------------------

const DECLARED_QUERY_PARAM_PREFIX = '$';

/** Top-level keys of an object-literal source slice. */
function topLevelKeys(objSrc: string): string[] {
const keys: string[] = [];
let depth = 0;
let i = 0;
let expectKey = true;
while (i < objSrc.length) {
const c = objSrc[i];
if (c === '{' || c === '[' || c === '(') { depth++; i++; continue; }
if (c === '}' || c === ']' || c === ')') { depth--; i++; continue; }
if (depth === 1) {
if (c === ',') { expectKey = true; i++; continue; }
if (c === ':') { expectKey = false; i++; continue; }
if (expectKey) {
const m = /^(['"]?)([A-Za-z_$][\w$]*)\1\s*:/.exec(objSrc.slice(i));
if (m) { keys.push(m[2]); i += m[0].length; expectKey = false; continue; }
}
}
i++;
}
return keys;
}

interface QueryFinding { key: string; snippet: string }

/** Every unprefixed key handed to an `adapter.find`/`findOne` in one source. */
function unprefixedQueryKeys(source: string): QueryFinding[] {
const found: QueryFinding[] = [];
const call = /\b(?:adapter|dataSource)\s*\.\s*(?:find|findOne)\s*\(/g;
let m: RegExpExecArray | null;
while ((m = call.exec(source))) {
// Walk to the params object literal, staying inside this call's parens.
let i = m.index + m[0].length;
let depth = 1;
let objStart = -1;
while (i < source.length && depth > 0) {
const c = source[i];
if (c === '(') depth++;
else if (c === ')') { depth--; if (depth === 0) break; }
else if (c === '{' && depth === 1) { objStart = i; break; }
i++;
}
if (objStart < 0) continue;
let braces = 0;
let objEnd = -1;
for (let j = objStart; j < source.length; j++) {
if (source[j] === '{') braces++;
else if (source[j] === '}') { braces--; if (braces === 0) { objEnd = j; break; } }
}
if (objEnd < 0) continue;
const obj = source.slice(objStart, objEnd + 1);
for (const k of topLevelKeys(obj)) {
if (!k.startsWith(DECLARED_QUERY_PARAM_PREFIX)) {
found.push({ key: k, snippet: obj.replace(/\s+/g, ' ').slice(0, 100) });
}
}
}
return found;
}

/**
* A `.records` read with no `.data` beside it, off a find() result.
*
* Comment lines are skipped: a page that explains the trap in prose (and
* `crm-workbench` does, right above the call it once got wrong) is documenting
* the contract, not violating it. The read itself is what this looks for.
*/
function recordsOnlyReads(source: string): string[] {
const out: string[] = [];
for (const line of source.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
if (!trimmed.includes('.records')) continue;
if (trimmed.includes('.data')) continue;
out.push(trimmed);
}
return out;
}

const REACT_PAGES = Object.values(pages as Record<string, unknown>)
.filter((p): p is { name: string; kind?: string; source?: string } =>
!!p && typeof p === 'object' && (p as { kind?: string }).kind === 'react')
.filter((p) => typeof p.source === 'string');

describe('every kind:"react" page in this app honours the useAdapter contracts', () => {
it('found the react pages to sweep (census control)', () => {
// A sweep over an empty list is vacuously green — this is what stops that.
expect(REACT_PAGES.length).toBeGreaterThanOrEqual(2);
expect(REACT_PAGES.map((p) => p.name)).toContain('showcase_renewals_pipeline');
});

it('the scanners fire on a known-bad source (positive control)', () => {
const bad = `
const a = await adapter.find('showcase_project', { $filter: ['account', '=', sel], top: 500 });
const b = await adapter.find('showcase_invoice', { limit: 200 });
// a comment mentioning .records must NOT count as a read
const rows = (a && a.records) || [];
`;
expect(unprefixedQueryKeys(bad).map((f) => f.key)).toEqual(['top', 'limit']);
expect(recordsOnlyReads(bad)).toEqual(['const rows = (a && a.records) || [];']);
});

it.each(REACT_PAGES.map((p) => [p.name, p.source as string] as const))(
'%s passes only $-prefixed query options',
(_name, source) => {
expect(unprefixedQueryKeys(source)).toEqual([]);
},
);

it.each(REACT_PAGES.map((p) => [p.name, p.source as string] as const))(
'%s reads rows off QueryResult.data',
(_name, source) => {
expect(recordsOnlyReads(source)).toEqual([]);
},
);
});
//
// `recordsOnlyReads()` and `unprefixedQueryKeys()` now live in
// `scripts/check-react-page-adapter-contract.mjs` (`pnpm check:react-page-adapter-contract`),
// which sweeps this app's page modules AND the react-page samples in
// `content/docs` — the copy a customer starts from, and the population gap
// that let the same `.records` read survive a third time after the two fixes
// this file's harness was written for.
//
// They moved rather than being copied. Two definitions of the same detector
// double the places a future fix has to land, which IS the defect (#10751):
// one wrong read, repaired three separate times. The scanners' positive
// control moved with them, into that gate's `--self-test`.
//
// What stays here is the half a text scan cannot do: the block above EXECUTES
// the real rollup effect against a contract-faithful adapter double, so it
// judges the numbers a page produces rather than the shape of its source.
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@
"check:docs-redirects": "node scripts/check-docs-redirects.mjs --self-test && node scripts/check-docs-redirects.mjs",
"check:docs-image-tag": "node scripts/check-docs-image-tag.mjs --self-test && node scripts/check-docs-image-tag.mjs",
"check:docs-image-tag-sync": "node scripts/sync-docs-image-tags.mjs --self-test",
"check:react-page-adapter-contract": "node scripts/check-react-page-adapter-contract.mjs --self-test && node scripts/check-react-page-adapter-contract.mjs",
"check:template-version-sync": "node scripts/sync-template-versions.mjs --self-test",
"check:role-word": "node scripts/check-role-word.mjs --self-test && node scripts/check-role-word.mjs",
"check:quick-reference-counts": "node scripts/check-quick-reference-counts.mjs --self-test && node scripts/check-quick-reference-counts.mjs",
Expand Down
Loading
Loading