Files
krow_talent_app/scripts/ssr-resolve.mjs
Aravind 02a2ab05ef chore(ts-migration): resolve source-text reads across .js/.jsx/.ts/.tsx
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
2026-09-17 22:56:45 +05:30

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;
}