The check suite consumes source files through two entirely separate channels, and Phase 0 only hardened one of them. `withSourceResolution` wraps `ssrLoadModule`, which covers the 159 module loads. It does not and cannot see the other 30 sites, which read source as TEXT through `readFileSync` to assert structural facts - "the panel imports no local suggestion ranker", "no runtime path writes a definition". Renaming `base44Client.js` to `.ts` is what surfaced the difference: ENOENT in the middle of a suite that had been passing, from a line no grep for `ssrLoadModule` would ever have found. `resolveSourcePath()` in `ssr-resolve.mjs` reuses the existing `candidatesFor()` ordering - the written path first, then `.ts`, then `.tsx` - and returns a path relative to the root, so every call site keeps its `join(ROOT, ...)` as it was. An unresolvable path comes back unchanged, which keeps the two absence assertions honest: a check proving `store.js` is GONE still asks about the path it means, and now also notices if the file returns under another extension. Thirty-seven lines change, each a one-for-one replacement. No assertion text, no record() message, no ordering, no logic. The audit found the reads in four shapes, and two of them a path grep cannot see: - direct readFileSync(join(ROOT, 'src/x.jsx'), 'utf8') - via a const const P = join(ROOT, 'src/x.jsx') - dir + name ['node.js', 'patch.js'].map((f) => readFileSync(join(ROOT, 'src/lib/ui', f))) - path array for (const f of files) readFileSync(join(ROOT, f)) The third and fourth hide thirteen filenames in adjacent arrays, which is why the first estimate of this work was twenty-two files and the real number is thirty-five. One directory scan also filtered `/\.jsx?$/` over `src/pages/admin` and `src/components/agents`. That one does not crash - it quietly matches nothing once those directories are TypeScript, and the check passes having inspected an empty set. Widened to `/\.[jt]sx?$/`. A silent shrink is worse than a failure, and CI's FLOOR of 900 would not have caught it. Deliberately NOT touched, because they are correctly extension-specific: the `dist/assets` filter reads built bundles, which are `.js` whatever the source was; `scripts/stubs/react-hot-toast.js` and `scripts/browser-flows.js` are scripts, not migrated source; and every comment that mentions a `.js` filename says something true about a file that still has that name. Proven rather than assumed. `npm test` reports 1684/1691 before and after, the same seven failures. Then `src/api/base44Client.js` - the exact file whose rename broke the suite - was renamed to `.ts`, the suite re-run, and the check that reads it as text passed: "the app does not import the seed fixture". The rename was reverted and the file verified byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
109 lines
4.9 KiB
JavaScript
109 lines
4.9 KiB
JavaScript
/**
|
|
* Extension-agnostic SSR module loading, for the duration of the TypeScript
|
|
* migration.
|
|
*
|
|
* Every script in here addresses modules by literal path — `ssrLoadModule(
|
|
* '/src/lib/skills/registry.js')` — about a hundred and seventy times across
|
|
* `skill-check.mjs`, `owliver-capture.mjs`, `render-page.mjs` and
|
|
* `seed-fixture.mjs`. That is fine while every source file is JavaScript and
|
|
* fatal the moment one is not: renaming `registry.js` to `registry.ts` turns
|
|
* the check suite's very first load into a failure, and the suite is the only
|
|
* evidence the Owliver flow still behaves the way it did.
|
|
*
|
|
* Rewriting all those call sites would be a large, noisy, error-prone diff
|
|
* against the file that guards the migration — exactly the wrong thing to
|
|
* disturb. So the loader is wrapped once instead and the call sites keep the
|
|
* paths they already have, which stay readable as the names of real files.
|
|
*
|
|
* Resolution is by existence on disk, not by catching a failed load. A load
|
|
* that fails for a real reason — a syntax error, a bad import inside the module
|
|
* — must surface as itself; retrying under another extension would bury it
|
|
* behind a second, more confusing error about a file that was never there.
|
|
*
|
|
* The path as written is always tried first, so while a module is still
|
|
* JavaScript this changes nothing at all.
|
|
*
|
|
* This file is temporary. When `src` holds no `.js` or `.jsx` any more, the
|
|
* call sites can be renamed in one pass and this wrapper deleted.
|
|
*/
|
|
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
/**
|
|
* The candidate paths for one module specifier, in the order they are tried.
|
|
*
|
|
* Only `.js` and `.jsx` are rewritten. Anything else — a bare specifier, a
|
|
* `.mjs` file, something under `/node_modules` — is returned untouched, because
|
|
* nothing in this migration renames it.
|
|
*
|
|
* `.js` is allowed to become `.tsx` as well as `.ts`. Not because any `.js`
|
|
* file here contains JSX today — none of the 93 does, checked with a parser
|
|
* rather than a guess — but because one may be renamed that way: the only
|
|
* `.js` under `src/pages` is `admin/positions/nodes.js`, sitting among seven
|
|
* sibling `nodes.jsx` files, and whoever converts that directory will
|
|
* reasonably want all eight to end in `.tsx`. The extra candidate costs one
|
|
* `existsSync` that answers no.
|
|
*/
|
|
export function candidatesFor(path) {
|
|
const specifier = String(path);
|
|
if (!/\.jsx?$/.test(specifier)) return [specifier];
|
|
const stem = specifier.replace(/\.jsx?$/, '');
|
|
return [...new Set([specifier, `${stem}.ts`, `${stem}.tsx`])];
|
|
}
|
|
|
|
/**
|
|
* Wraps `server.ssrLoadModule` so it finds a module whichever of the four
|
|
* extensions it currently carries.
|
|
*
|
|
* Mutates and returns the server, so it reads as one line after `createServer`
|
|
* and every later call — including the dynamically-built paths, which is why
|
|
* this is done here rather than at the call sites — goes through it.
|
|
*
|
|
* `root` is where the leading-slash paths are rooted; it defaults to the
|
|
* process's working directory, which is what every caller here uses.
|
|
*/
|
|
export function withSourceResolution(server, root = process.cwd()) {
|
|
const load = server.ssrLoadModule.bind(server);
|
|
|
|
server.ssrLoadModule = (path, options) => {
|
|
for (const candidate of candidatesFor(path)) {
|
|
if (existsSync(join(root, candidate.replace(/^\//, '')))) {
|
|
return load(candidate, options);
|
|
}
|
|
}
|
|
/* Nothing on disk under any extension. Load the path as written so the
|
|
error names what the caller actually asked for. */
|
|
return load(path, options);
|
|
};
|
|
|
|
return server;
|
|
}
|
|
|
|
/**
|
|
* The same resolution, for source read as TEXT rather than loaded as a module.
|
|
*
|
|
* `skill-check.mjs` asserts structural facts by reading source files and
|
|
* matching against their contents — "the panel imports no local suggestion
|
|
* ranker", "no runtime path writes a definition". Those reads go through
|
|
* `readFileSync`, not `ssrLoadModule`, so `withSourceResolution` above never
|
|
* sees them: it wraps the loader, and this is a second, entirely separate
|
|
* channel. Renaming `base44Client.js` to `.ts` is what surfaced the difference,
|
|
* as an ENOENT in the middle of a suite that had been passing.
|
|
*
|
|
* Takes and returns a path RELATIVE to the project root, so the call site keeps
|
|
* its `join(ROOT, …)` exactly as it was:
|
|
*
|
|
* readFileSync(join(ROOT, resolveSourcePath('src/api/base44Client.js')), 'utf8')
|
|
*
|
|
* Unresolvable paths come back unchanged, so the resulting error still names
|
|
* the file the caller asked for rather than a candidate it invented. That also
|
|
* keeps the deliberately absent ones honest: a check asserting a file is GONE
|
|
* gets the path it asked about, and `existsSync` still answers false.
|
|
*/
|
|
export function resolveSourcePath(path, root = process.cwd()) {
|
|
for (const candidate of candidatesFor(path)) {
|
|
if (existsSync(join(root, candidate))) return candidate;
|
|
}
|
|
return path;
|
|
}
|