fix(650): replace node:fs/path/url with import.meta.glob in the pageSize guard
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 30s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 33s
PR Gates / Docs update reminder (pull_request) Successful in 37s
Review verdict / Set review-verdict status (pull_request) Successful in 36s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m33s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 21m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 24m42s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 27m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ 17c25e7 (base: main)
PR Gates / decisions lifecycle (pull_request) Successful in 13s

The gate is npm run typecheck (tsc -b --pretty false, project-mode) — the
prior fix for the 3 node:*-unresolvable errors deferred a decision rather
than resolving it, per instruction not to change tsconfig unilaterally.

Evaluated and rejected: adding "node" to tsconfig.app.json's `types` (makes
Node globals type-visible to production browser code, dissolving a
deliberate separation the repo documents) and a file-local
`/// <reference types="node" />` (empirically leaks Node's ambient
`setTimeout` into the whole tsc -b program, breaking 3 unrelated
window.setTimeout mocks — confirmed by trying it).

Adopted instead: Vite's `import.meta.glob('/src/**/*.{ts,tsx,mts,cts}',
{ query: '?raw', import: 'default', eager: true })`, resolved natively by
Vite/vitest at transform time — no node:fs, no node:path, no node:url, no
tsconfig change, no cross-project references, and the guard test stays
exactly where it is (`src/api/`). `vite/client` types (hence
`import.meta.glob`) were already wired in via `src/vite-env.d.ts`.

`isScannableSourceFileName` is unchanged and still the single place the
glob's results are filtered through — the extension set moved into the glob
literal, but discovery still runs every matched file through this same
named, tested predicate rather than a second copy of the logic.

Verified the discovery mechanism swap changes nothing observable: a
side-by-side comparison test (old fs-based walk vs new glob-based walk, both
run against the current repo, not committed — temporary) found byte-identical
results — 19 call sites, 136 scannable files, zero set difference in either
direction. Re-ran both required plants against the new mechanism:
  1. Duplicate at-cap literal appended to builder/ChannelBuilder.tsx (an
     already-registered file): caught —
     `UNREGISTERED (1) ... + builder/ChannelBuilder.tsx:2058:54:literal:100`.
  2. New file with a `https://` string (no false match), a shorthand
     `{ pageSize }`, and two literal pageSize values in one ternary's two
     branches on a single line: all 3 real sites caught at distinct columns —
     `+ screens/_fakeDefectPlant.ts:4:30:shorthand:pageSize`,
     `+ screens/_fakeDefectPlant.ts:8:22:literal:111`,
     `+ screens/_fakeDefectPlant.ts:8:48:literal:222`.
Also re-verified the .mts/.cts discovery pin still fails when the extension
set is narrowed back to `.ts`/`.tsx` only (2 of 14 predicate cases fail, as
before). All three plants reverted after verification; registry/predicate
otherwise untouched.

Full local gate: `npm run lint` clean, `npm run typecheck` (tsc -b) clean —
zero errors, including the 3 node:* ones this commit resolves — `npx vitest
run` 110 files / 1078 tests passed (re-run three times; two runs hit
transient vitest worker-pool timeouts from overlapping background processes
on this machine, unrelated to the code — a clean sequential run passed in
full both before and after).
This commit is contained in:
2026-07-27 02:40:31 +02:00
parent 5b46214774
commit 17c25e75fa
+40 -24
View File
@@ -1,6 +1,3 @@
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { pageSizeSiteId, scanPageSizeSites } from './pageSizeScan';
@@ -265,8 +262,26 @@ const REGISTRY: RegistryEntry[] = [
}
];
// This file lives in `src/api/`; the scan root is `src/`.
const SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
// Enumerates every source file under `src/` via Vite's `import.meta.glob` — eagerly, as raw text
// (`query: '?raw', import: 'default'`) — INSTEAD OF Node's `fs`/`path`/`url` (#650 follow-up).
// This is the only file under `src` that ever needed real filesystem access, and `@types/node`
// isn't wired into `tsconfig.app.json`'s project (deliberately: it covers production browser code
// too, and a file-local `/// <reference types="node" />` was tried and reverted — under `tsc -b`'s
// single-program compilation it leaked Node's ambient `setTimeout` into the whole app project,
// breaking three unrelated `window.setTimeout` mocks that expect the DOM signature). `import.meta
// .glob` needs neither `node:fs` nor a tsconfig change: it's resolved by Vite at transform time,
// natively available in the browser/app project, and is the idiomatic Vite/vitest way to enumerate
// source files. Keys are POSIX paths from the project root, e.g. `/src/api/pageSizeScan.ts`.
const rawSourceModules = import.meta.glob('/src/**/*.{ts,tsx,mts,cts}', {
query: '?raw',
import: 'default',
eager: true
}) as Record<string, string>;
function basename(path: string): string {
const idx = path.lastIndexOf('/');
return idx === -1 ? path : path.slice(idx + 1);
}
// Extracted from `listSourceFiles`'s inline condition so it's independently testable (#650
// follow-up round 4): a plant that adds a real `.mts` FILE and observes the guard notice it
@@ -275,7 +290,10 @@ const SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
// because this repo has no committed `.mts`/`.cts` source and `scanPageSizeSites` parses any
// non-`.tsx` filename as plain TS regardless of extension. Testing this predicate directly, by
// filename, is what actually regression-pins the file-discovery fix rather than depending on the
// repo happening to contain (or not contain) a matching file.
// repo happening to contain (or not contain) a matching file. This predicate is still what the
// glob's results are filtered THROUGH below (`listSourceFiles`) — the extension SET moved into the
// glob literal, but discovery still runs every matched file through this same named, tested
// function, not a second copy of the logic.
export function isScannableSourceFileName(name: string): boolean {
// `.mts`/`.cts` are legal TS extensions `tsconfig.app.json`'s `include` covers alongside
// `.ts`/`.tsx` — none exist in this repo today, but the glob must not silently skip one if it
@@ -285,20 +303,22 @@ export function isScannableSourceFileName(name: string): boolean {
);
}
function listSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'generated') {
interface ScannableSource {
/** Path relative to `src/`, e.g. `api/pageSizeScan.ts` — matches the REGISTRY's `file` field. */
file: string;
text: string;
}
function listSourceFiles(): ScannableSource[] {
const out: ScannableSource[] = [];
for (const [key, text] of Object.entries(rawSourceModules)) {
if (key.includes('/generated/')) {
continue;
}
const full = join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...listSourceFiles(full));
if (!isScannableSourceFileName(basename(key))) {
continue;
}
if (isScannableSourceFileName(entry.name)) {
out.push(full);
}
out.push({ file: key.replace(/^\/src\//, ''), text });
}
return out;
}
@@ -312,15 +332,11 @@ interface DiscoveredSite {
}
function discoverPageSizeCallSites(): DiscoveredSite[] {
const files = listSourceFiles(SRC_DIR);
const sites: DiscoveredSite[] = [];
for (const absPath of files) {
const relPath = relative(SRC_DIR, absPath);
const text = readFileSync(absPath, 'utf8');
for (const site of scanPageSizeSites(text, absPath)) {
sites.push({ file: relPath, ...site });
for (const { file, text } of listSourceFiles()) {
for (const site of scanPageSizeSites(text, file)) {
sites.push({ file, ...site });
}
}
@@ -393,7 +409,7 @@ describe('pageSize call-site guard (#650)', () => {
);
it('scans a healthy number of source files (anti-vacuity: a broken glob must not pass on zero input)', () => {
const files = listSourceFiles(SRC_DIR);
const files = listSourceFiles();
expect(files.length).toBeGreaterThan(50);
});