Third cold cross-family review (BLOCKED) found the append/page-0-refresh races were being fixed one interleaving at a time — round 1 fixed page-0-settles-first, round 2's compare-and-set rollback fixed the duplicate-append case but introduced a permanently-skipped page, and the reviewer found the exact mirror of round 1's fix (page-1-settles-first, erasing page 1 with no cursor reset). Direction from the review: stop enumerating orderings, make the overlap structurally impossible. SINGLE-FLIGHT (web/src/builder/libraryBrowse.ts): a new `busyRef` guard is true from the moment ANY fetch (a page-0 refresh OR an append) for the current query generation is issued until it settles. `loadMore` checks it SYNCHRONOUSLY and returns immediately (ignored, not queued) if a fetch is already in flight — including a page-0 refresh, not just a prior append, so a "Load more" click that lands while a query change is still resolving is a no-op rather than starting a second, overlapping request. With overlapping fetches eliminated by construction, the append-failure rollback no longer needs the round-2 compare-and-set: single-flight guarantees nothing else could have moved `pageRef` since a given fetch started, so it now always rolls back and retries the exact page that failed, unconditionally. Visual feedback (the button showing loading/disabled during a page-0 refresh, not just an append) is set via `queueMicrotask(() => setLoadingMore (true))` rather than a bare synchronous call in the generation-change effect — `react-hooks/set-state-in-effect` flags the latter; a microtask-deferred call resolves before any human-perceptible input, satisfies the lint rule (the same reason `.then()` callbacks elsewhwere in this hook aren't flagged), and keeps the actual correctness guarantee (the ref check) perfectly synchronous regardless. TESTS REWRITTEN, not just added — the round-2 "HIGH-2" hook test explicitly asserted the NEXT request after a failed page 1 (following an overlapping page 2 success) should be page 3, i.e. it blessed page 1's permanent loss. Replaced with two hook-level tests: single-flight ignores a synchronous double `loadMore()` call (only one fetch issued), and a failed page is retried as the SAME page number. Replaced the round-2 component-level "HIGH-1" test (which drove the now-impossible overlap through the DOM) with one asserting the click during a pending page-0 refresh is ignored, and that once free, the correct page-1-then-page-2 sequence completes with both pages' rows present. Verified all three new/rewritten tests against the prior committed hook (7b1ae48b0): the two single-flight-specific tests fail as expected (`[0, 1]` requested when only `[0]` should have been); the retry-semantics test happens to pass against 7b1ae48b0 too (compare-and-set and unconditional rollback coincide in the non-overlapping case) but is kept because it is the correct "retry as page 1, not page 3" pin the review asked for, replacing the one that asserted the wrong thing. SCANNER (pageSizeScan.ts) — closed three documented false-negative classes: - Transparent TS wrappers around the initializer (`pageSize: 100 as const`, `100 satisfies number`, parenthesized) are now unwrapped before the NumericLiteral/Identifier check. - Non-Identifier property names: a quoted string key (`'pageSize': 100`) or a statically-resolvable computed key (`['pageSize']: 100`) are now accepted; a computed key that isn't a literal correctly stays unresolved. - `.mts`/`.cts` are no longer silently excluded from the guard's file discovery glob (tsconfig.app.json's `include` covers all of `src`; no such files exist in the repo today, but the glob shouldn't hide one if it ever does). 10 new fixture tests in pageSizeScan.test.ts pin each case (plus a rejection test confirming a forwarded call wrapped in `as` still doesn't match, and one confirming an unresolvable computed key stays unmatched). TEST LABELLING: relabeled the URL/M-3 and `??`/M-4 fixtures as CONTRACT fixtures rather than regression pins — a round-3 review found round 1's plain literal regex already handled those two exact inputs correctly on its own; only the combined multi-case fixture (and the string-contains-text, template-interpolation, same-line-identity, JSX, and destructuring fixtures) actually fail against round 1. Labeled the guard test's 4 tests as BASELINE assertions (they all pass on clean b90f8a3b) rather than implying they prove this round's specific fixes — pageSizeScan.test.ts's fixtures are what actually regression-pin the scanner. No server-side/C# change. Full local gate: lint clean, tsc clean, full vitest run 110 files / 1063 tests passed (re-run twice, stable).
248 lines
13 KiB
TypeScript
248 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { pageSizeSiteId, scanPageSizeSites, type PageSizeSite } from './pageSizeScan';
|
|
|
|
/**
|
|
* Fixture test for `scanPageSizeSites` itself — NOT a scan of the real repo (that's
|
|
* `pageSizeCallSites.guard.test.ts`). This is what actually protects the SCANNER going forward:
|
|
* a prior hand-rolled regex/bracket-tracking version passed the guard test against unmodified
|
|
* source at both #650 commits while still being defeated by every case below, because the guard
|
|
* only ever exercised today's snapshot of real call sites — it never proved the scanner handles
|
|
* the INPUT CLASSES that expose a text-level scanner's blind spots. Pinning the exact discovered
|
|
* set against synthetic source strings closes that gap.
|
|
*
|
|
* Not every fixture here is a REGRESSION pin against the prior (round-1, `b90f8a3b`) bracket-
|
|
* tracking scanner — a round-3 review found that round 1's simple `pageSize:\s*value` regex
|
|
* already handled a bare URL-string or a bare `??` context correctly on its own (a `//` inside a
|
|
* string, or the token immediately before `{`, only mattered to round 1's OWN heuristics, not to
|
|
* a plain regex match). Those two are labelled CONTRACT fixtures below — they pin the documented
|
|
* behavior going forward, not a fix. The fixtures that genuinely fail against round 1 (verified)
|
|
* are: the string CONTAINING the literal text `pageSize: 100`, the template-literal
|
|
* interpolation, the same-line ternary identity/multiplicity, the JSX shorthand container,
|
|
* parameter destructuring, nested destructuring, and the type-literal declaration — plus the
|
|
* combined multi-case fixture, which fails round 1 for several of those reasons at once.
|
|
*/
|
|
|
|
function ids(sites: PageSizeSite[]): string[] {
|
|
return sites.map(pageSizeSiteId);
|
|
}
|
|
|
|
describe('scanPageSizeSites', () => {
|
|
it('finds a literal pageSize: property in a plain object-literal call argument', () => {
|
|
const source = `getFoo({ pageSize: 100, query });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:10:literal:100']);
|
|
});
|
|
|
|
it('finds the ES6 shorthand pageSize property in a plain object-literal call argument', () => {
|
|
const source = `getFoo({ pageSize, query });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:10:shorthand:pageSize']);
|
|
});
|
|
|
|
it('is NOT fooled by a "//" inside a string literal — CONTRACT fixture, not a round-1 regression pin (M-3)', () => {
|
|
// NOTE: round 1's unconditional literal regex (`pageSize:\s*(\d+|identifier)`) already
|
|
// matched this exact input correctly on its own — a `//` inside a string never confused THAT
|
|
// narrower pattern. This pins the AST scanner's documented contract going forward; it is the
|
|
// COMBINED multi-case fixture below (and the M-3-shaped case buried inside it — a literal
|
|
// `//` immediately preceding a real call site on the SAME conceptual scan) that actually
|
|
// fails against round 1's comment-stripping step, not this input in isolation.
|
|
const source = [`const endpoint = 'https://example.test';`, `getFoo({ pageSize: 100 });`, ''].join('\n');
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['2:10:literal:100']);
|
|
});
|
|
|
|
it('does NOT match a string literal that merely CONTAINS the text "pageSize: 100" (L-7)', () => {
|
|
const source = `const label = "pageSize: 100";\n`;
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
it('finds an object literal passed inside a template-literal interpolation (M-5)', () => {
|
|
const source = 'const url = `${await getFoo({ pageSize })}`;\n';
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:31:shorthand:pageSize']);
|
|
});
|
|
|
|
it('finds an object literal in each branch of a ternary, even on the SAME line (M-4, M-6)', () => {
|
|
const source = `return ok ? getA({ pageSize }) : getB({ pageSize });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
// Two distinct occurrences on one line get distinct identities (different columns) — a
|
|
// single registry entry cannot silently cover both.
|
|
expect(ids(sites)).toEqual(['1:20:shorthand:pageSize', '1:41:shorthand:pageSize']);
|
|
expect(sites[0].column).not.toBe(sites[1].column);
|
|
});
|
|
|
|
it('finds an object literal on the right-hand side of ?? — CONTRACT fixture, not a round-1 regression pin (M-4)', () => {
|
|
// NOTE: like the URL fixture above, round 1's literal-form regex already matched this exact
|
|
// `pageSize: 50` text correctly on its own — `??` doesn't change what characters precede the
|
|
// match on the line. This pins the documented contract, not a round-1 regression.
|
|
const source = `getFoo(options ?? { pageSize: 50 });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:21:literal:50']);
|
|
});
|
|
|
|
it('finds an object literal inside a JSX expression container attribute (M-4)', () => {
|
|
const source = `const el = <Component options={{ pageSize }} />;\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.tsx');
|
|
expect(ids(sites)).toEqual(['1:34:shorthand:pageSize']);
|
|
});
|
|
|
|
it('does NOT match a parameter destructuring pattern (L-7)', () => {
|
|
const source = `function f({ pageSize }: { pageSize: number }) {}\n`;
|
|
// The destructured PARAMETER `{ pageSize }` is an ObjectBindingPattern, not an
|
|
// ObjectLiteralExpression — excluded by node kind. Its TYPE annotation `{ pageSize: number }`
|
|
// is a TypeLiteral (PropertySignature), also excluded by node kind — never an object literal.
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
it('does NOT match a nested destructuring pattern (L-7)', () => {
|
|
const source = `const { nested: { pageSize } } = input;\n`;
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
it('does NOT match a type-literal declaration (L-7)', () => {
|
|
const source = `type P = { pageSize: 100 };\n`;
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
it('does NOT match an interface property declaration', () => {
|
|
const source = `interface Params {\n pageSize?: number;\n}\n`;
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
it('does NOT match a forwarded call expression (a dynamic passthrough, not a fixed value)', () => {
|
|
const source = `getFoo({ pageSize: String(pageSize) });\n`;
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
it('is not confused by a pageSize reference inside a comment', () => {
|
|
const source = [`// pageSize: 999 — this is just prose, not code`, `getFoo({ query });`, ''].join('\n');
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
it('is not confused by a pageSize reference inside a block/JSDoc comment', () => {
|
|
const source = ['/**', ' * Uses `pageSize` under the hood — see also `{ pageSize: 100 }`.', ' */', 'getFoo({ query });', ''].join(
|
|
'\n'
|
|
);
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
it('does NOT match a React dependency array containing pageSize', () => {
|
|
const source = `useCallback(load, [pageNum, pageSize, sortField]);\n`;
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
it('finds a const-identifier literal value (not just a numeric literal)', () => {
|
|
const source = `getFoo({ pageSize: LIBRARY_BROWSE_PAGE_CAP });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:10:literal:LIBRARY_BROWSE_PAGE_CAP']);
|
|
});
|
|
|
|
it('covers every case above together in one multi-line fixture and pins the exact discovered set', () => {
|
|
const source = [
|
|
`const endpoint = 'https://example.test';`, // M-3: not a comment
|
|
`const label = "pageSize: 100";`, // L-7: string contents, not code
|
|
`// pageSize: 999 in a line comment`, // not code
|
|
`/** block comment mentioning \`pageSize\` */`, // not code
|
|
`type P = { pageSize: 100 };`, // L-7: type literal, not a value
|
|
`interface Q { pageSize?: number; }`, // not a value
|
|
`function f({ pageSize }: { pageSize: number }) {}`, // L-7: destructuring + its type
|
|
`const { nested: { pageSize } } = input;`, // L-7: nested destructuring
|
|
`useCallback(load, [pageNum, pageSize]);`, // dependency array, not an object literal
|
|
`getFoo({ pageSize: String(pageSize) });`, // forwarded call, not a fixed value
|
|
`getFoo({ pageSize: 100 });`, // REAL: literal
|
|
`getBar({ pageSize });`, // REAL: shorthand
|
|
`getBaz(options ?? { pageSize: 50 });`, // REAL: ?? context (M-4)
|
|
`const url = \`\${await getQux({ pageSize })}\`;`, // REAL: template interpolation (M-5)
|
|
`return ok ? getA({ pageSize }) : getB({ pageSize });` // REAL x2: ternary, same line (M-4/M-6)
|
|
].join('\n');
|
|
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual([
|
|
'11:10:literal:100',
|
|
'12:10:shorthand:pageSize',
|
|
'13:21:literal:50',
|
|
'14:31:shorthand:pageSize',
|
|
'15:20:shorthand:pageSize',
|
|
'15:41:shorthand:pageSize'
|
|
]);
|
|
// Anti-vacuity: the fixture packs in 10 non-matching traps ahead of the 6 real sites — a
|
|
// scanner that matched everything (or nothing) would fail this count, not just the ids above.
|
|
expect(sites.length).toBe(6);
|
|
});
|
|
|
|
it('scans .tsx source using the TSX script kind (JSX does not parse under plain .ts rules)', () => {
|
|
const source = `export function C() {\n return <div data={{ pageSize: 10 }} />;\n}\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.tsx');
|
|
expect(ids(sites)).toEqual(['2:23:literal:10']);
|
|
});
|
|
|
|
// ---- round-3 MEDIUM finding: transparent TS wrappers around the initializer -----------------
|
|
|
|
it('finds a literal wrapped in "as const" (transparent to the runtime value)', () => {
|
|
const source = `getFoo({ pageSize: 100 as const });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:10:literal:100']);
|
|
});
|
|
|
|
it('finds a literal wrapped in "satisfies number" (transparent to the runtime value)', () => {
|
|
const source = `getFoo({ pageSize: 100 satisfies number });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:10:literal:100']);
|
|
});
|
|
|
|
it('finds a parenthesized literal', () => {
|
|
const source = `getFoo({ pageSize: (100) });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:10:literal:100']);
|
|
});
|
|
|
|
it('finds a const identifier through a chain of "as"/"satisfies"/parens wrappers', () => {
|
|
const source = `getFoo({ pageSize: ((LIBRARY_BROWSE_PAGE_CAP as number) satisfies number) });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:10:literal:LIBRARY_BROWSE_PAGE_CAP']);
|
|
});
|
|
|
|
it('still rejects a forwarded call expression even when wrapped in "as"', () => {
|
|
const source = `getFoo({ pageSize: String(pageSize) as string });\n`;
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
// ---- round-3 MEDIUM finding: non-Identifier property names -----------------------------------
|
|
|
|
it('finds a quoted string property key ("pageSize": 100)', () => {
|
|
const source = `getFoo({ 'pageSize': 100 });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:10:literal:100']);
|
|
});
|
|
|
|
it('finds a statically-resolvable computed property key (["pageSize"]: 100)', () => {
|
|
const source = `getFoo({ ['pageSize']: 100 });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.ts');
|
|
expect(ids(sites)).toEqual(['1:10:literal:100']);
|
|
});
|
|
|
|
it('does NOT match a computed property key that cannot be resolved statically', () => {
|
|
const source = `const key = getKey();\ngetFoo({ [key]: 100 });\n`;
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
it('does NOT match a quoted string key for a DIFFERENT property name', () => {
|
|
const source = `getFoo({ 'pageSizeLimit': 100 });\n`;
|
|
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
|
|
});
|
|
|
|
// ---- round-3 MEDIUM finding: .mts/.cts are never silently skipped -----------------------------
|
|
|
|
it('scans .mts source (parses as plain TS, no JSX grammar)', () => {
|
|
const source = `export function loadPage() {\n return getFoo({ pageSize: 100 });\n}\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.mts');
|
|
expect(ids(sites)).toEqual(['2:19:literal:100']);
|
|
});
|
|
|
|
it('scans .cts source (parses as plain TS, no JSX grammar)', () => {
|
|
const source = `getFoo({ pageSize });\n`;
|
|
const sites = scanPageSizeSites(source, 'fixture.cts');
|
|
expect(ids(sites)).toEqual(['1:10:shorthand:pageSize']);
|
|
});
|
|
});
|