Files
ersatztv/web/src/api/pageSizeScan.ts
T
timothy ca99bedb1a fix(650): rewrite the pageSize guard on the TS compiler API; fix two append-ownership races
Second cold cross-family (Codex, BLOCKED) re-review of b90f8a3b found the
regex/bracket-tracking guard scanner still defeated in five ways, and two new
High-severity races introduced by the F3/F4 fixes. Addressed as a further
follow-up (b90f8a3b left untouched).

GUARD REWRITE (per the review's explicit direction — stop patching the regex,
use the compiler):

- New `web/src/api/pageSizeScan.ts`: `scanPageSizeSites` parses each file with
  `ts.createSourceFile` and walks the real AST for `pageSize`
  PropertyAssignment/ShorthandPropertyAssignment nodes inside an
  ObjectLiteralExpression. This eliminates categorically (not case-by-case):
    - M-3: comments and string/template CONTENTS are never revisited as code,
      so a `'https://...'` string can't be misread as an unterminated string
      that swallows the rest of the file.
    - M-4: an object literal nested in a ternary, `??`, or JSX expression
      container is still found — the walk visits every descendant node
      regardless of the syntactic context above the ObjectLiteralExpression.
    - M-5: template-literal interpolations are real AST children, not opaque
      text.
    - L-7: a type literal (`type P = { pageSize: 100 }`), an interface
      PropertySignature, and a destructuring ObjectBindingPattern (parameter
      or nested) are structurally different node kinds from
      ObjectLiteralExpression — excluded by kind, not by a
      preceding-character heuristic a stray `{`/`(`/`,` could fool.
  `getLineAndCharacterOfPosition` gives exact line+column (fixes M-6 identity
  granularity) instead of the prior line-only identity.
- `pageSizeCallSites.guard.test.ts` now imports the shared scanner; identity
  is `file:line:column:kind:value`, compared as a MULTISET (count, not
  membership) in both directions.
- Both directions (unregistered / stale) are computed and folded into ONE
  thrown Error so a failure always shows the complete picture in one run,
  addressing the line-churn "second direction never renders" concern.
- New `pageSizeScan.test.ts`: a FIXTURE test (inline source strings, no repo
  scan) pinning the exact discovered set for every case the review named —
  comment-in-string, string containing the literal text `pageSize: 100`,
  template interpolation, ternary, `??`, JSX container, same-line duplicates,
  parameter/nested destructuring, a type literal, an interface property, a
  forwarded call expression, a React dependency array. This is what actually
  protects the scanner going forward — the guard test alone only ever proved
  today's snapshot of real call sites, never the scanner's handling of input
  classes it hadn't happened to encounter yet.
- Re-verified both original plants (a duplicate at-cap call in an
  already-registered file, and a new file with both a literal and a
  shorthand site) against the rewritten scanner; both still fail with the
  new combined-direction message. Also verified a run with BOTH directions
  simultaneously non-empty renders both in one report.

HIGH-1 (ChannelBuilder.tsx useLibraryBrowse, now web/src/builder/libraryBrowse.ts):
`reqId` identifies a query GENERATION, not an individual fetch — a page-0
refresh and a "Load more" append can be outstanding simultaneously under the
same reqId (query changes while an append is in flight for the new
generation). Whichever settled first used to clear `loadingMore`, letting a
second click fire an out-of-order/duplicate page fetch. Fixed with a
per-fetch `fetchId` plus a `loadingFetchIdRef`/`loadingFetchReqIdRef` pair:
only the fetch that OWNS the currently-displayed spinner can clear it; a
same-generation page-0 refresh leaves a same-generation append's spinner
alone, while a page-0 refresh for a NEW generation still retires an
abandoned OLDER-generation append's spinner (preserving the original #650 F3
fix). Reproduced the exact interleaving from the review in a new test
(gate B's page-0 and page-1 fetches independently, click "Load more" while
B's page-0 is still in flight) and confirmed it fails without the fix
(button re-enables while the append is still pending).

HIGH-2 (same file): the append-failure rollback mutated whatever
`pageRef.current` currently held, rather than the specific page THIS fetch
requested — under an overlapping-append race, a later page's success
followed by an earlier page's failure could roll the cursor back past
already-appended progress, corrupting a retry into refetching a duplicate.
Fixed with a compare-and-set guard (`if (pageRef.current === pageNum)`) so
the rollback only fires when nothing has advanced the cursor since. Since
this overlap is UI-unreachable once HIGH-1's single-flight disabling is
wired up (verified empirically: two synchronous fireEvent.click calls in RTL
only produce one request, since act() flushes the disabling render between
them), the regression test drives `useLibraryBrowse` directly via
`renderHook` (now exported) to force the exact interleaving and confirms it
fails without the fix (page 2 gets duplicated, page 3 never requested).

Extracted `useLibraryBrowse` (plus `loadCollections`/`loadLibraryItems`/
`BrowseState`/the media-type const arrays) into a new non-JSX module
`web/src/builder/libraryBrowse.ts` — exporting a hook from a .tsx file
tripped `react-refresh/only-export-components`; this also makes the hook
importable by `renderHook` without pulling in the whole screen component.

No server-side/C# change. Full local gate: lint clean, tsc clean, full
vitest run 110 files / 1051 tests passed (one LibrariesScreen.test.tsx
flake reproduced under full-suite parallel load, confirmed pre-existing and
unrelated — passes in isolation, never touched that file).
2026-07-27 01:29:15 +02:00

84 lines
4.3 KiB
TypeScript

import * as ts from 'typescript';
/**
* #650 follow-up: an AST-based scanner for every `pageSize` property that appears inside a real
* object LITERAL expression. Extracted into its own module so both the enumerating guard
* (`pageSizeCallSites.guard.test.ts`, which scans the real repo) and a fixture test
* (`pageSizeScan.test.ts`, which scans synthetic source strings and does NOT touch the repo) can
* exercise the exact same scanning logic.
*
* A prior hand-rolled regex/bracket-tracking version of this scan was replaced after a review
* found it defeated by comments-in-strings, template-literal interpolations, ternary/`??`
* contexts, JSX containers, and same-line duplicates — each a DIFFERENT input class a text-level
* lexer has to special-case one at a time. The TypeScript compiler API sidesteps the whole
* category: comments and string/template CONTENTS are trivia/literal text the parser never
* revisits as code, and a real object-literal expression (`ObjectLiteralExpression`) is a
* structurally different AST node from a type literal (`type X = { pageSize: number }`,
* `PropertySignature` inside a `TypeLiteralNode`/`InterfaceDeclaration`) or a destructuring
* pattern (`ObjectBindingPattern`, e.g. `function f({ pageSize }) {}` or
* `const { pageSize } = x`) — so those are excluded by NODE KIND, not by a preceding-character
* heuristic that can be fooled by an unrelated `{`/`(`/`,`.
*/
export interface PageSizeSite {
/** 1-based source line of the `pageSize` property (name), matching editor line numbers. */
line: number;
/** 1-based source column of the `pageSize` property (name). */
column: number;
kind: 'literal' | 'shorthand';
/**
* For `kind: 'literal'`: the numeric-literal text or the referenced const identifier's name.
* For `kind: 'shorthand'`: always the literal string `'pageSize'` (the shorthand form only ever
* forwards whatever `pageSize` binding is in scope — there is no separate "value" to name).
*/
value: string;
}
function scriptKindFor(fileName: string): ts.ScriptKind {
return fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
}
export function scanPageSizeSites(sourceText: string, fileName: string): PageSizeSite[] {
const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, scriptKindFor(fileName));
const sites: PageSizeSite[] = [];
function positionOf(node: ts.Node): { line: number; column: number } {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
return { line: line + 1, column: character + 1 };
}
function visit(node: ts.Node): void {
if (ts.isObjectLiteralExpression(node)) {
for (const property of node.properties) {
if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'pageSize') {
const initializer = property.initializer;
// Only a numeric literal or a bare identifier (a const/variable reference) counts as a
// fixed value baked into THIS call site. A forwarded expression — `String(pageSize)`, a
// ternary, a template, a function call — is a dynamic passthrough of whatever the
// caller supplied, not a literal this site chose; it is deliberately not recorded here
// (see the module doc comment on `loadAllPages`/positional-argument residual gaps).
if (ts.isNumericLiteral(initializer)) {
const { line, column } = positionOf(property.name);
sites.push({ line, column, kind: 'literal', value: initializer.text });
} else if (ts.isIdentifier(initializer)) {
const { line, column } = positionOf(property.name);
sites.push({ line, column, kind: 'literal', value: initializer.text });
}
} else if (ts.isShorthandPropertyAssignment(property) && property.name.text === 'pageSize') {
const { line, column } = positionOf(property.name);
sites.push({ line, column, kind: 'shorthand', value: 'pageSize' });
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return sites.sort((a, b) => (a.line === b.line ? a.column - b.column : a.line - b.line));
}
export function pageSizeSiteId(site: { line: number; column: number; kind: string; value: string }): string {
return `${site.line}:${site.column}:${site.kind}:${site.value}`;
}