From 02a2ab05ef5e8a54e6240da33728527fe0a82665 Mon Sep 17 00:00:00 2001 From: Aravind Date: Thu, 17 Sep 2026 22:56:45 +0530 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8 --- scripts/skill-check.mjs | 74 ++++++++++++++++++++--------------------- scripts/ssr-resolve.mjs | 28 ++++++++++++++++ 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index dae668a..45c0fef 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -19,7 +19,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs'; import { buildFixture, FIXTURE_PATH } from './seed-fixture.mjs'; -import { withSourceResolution } from './ssr-resolve.mjs'; +import { resolveSourcePath, withSourceResolution } from './ssr-resolve.mjs'; const ROOT = process.cwd(); const results = []; @@ -1618,10 +1618,10 @@ record('...and stops asking a backend that does not have the route', now does — and the failure mode of it returning is not an error but a second opinion silently outranking the server's. */ record('the panel imports no local suggestion ranker', - !existsSync(join(ROOT, 'src/components/ai-assistant/matchPrompts.js')), + !existsSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/matchPrompts.js'))), 'matchPrompts.js removed'); -const panelSource = readFileSync(join(ROOT, 'src/components/ai-assistant/KrowAssistant.jsx'), 'utf8'); +const panelSource = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/KrowAssistant.jsx')), 'utf8'); record('...and does not rank, score or sort suggestions itself', !/rankPrompts|\.sort\(|score\(/.test(panelSource), 'no ranking in the panel'); @@ -1630,10 +1630,10 @@ record('...it asks the API instead', /* The production data client must not carry the fixture. */ record('the app does not import the seed fixture', - !/from '\.\/seed'/.test(readFileSync(join(ROOT, 'src/api/base44Client.js'), 'utf8')), + !/from '\.\/seed'/.test(readFileSync(join(ROOT, resolveSourcePath('src/api/base44Client.js')), 'utf8')), 'base44Client reads demoUser.js, not seed.js'); record('the local entity store is gone', - !existsSync(join(ROOT, 'src/api/store.js')), + !existsSync(join(ROOT, resolveSourcePath('src/api/store.js'))), 'store.js removed'); /* ── 11. A fresh Add Owliver Skill screen ───────────────────────────────── @@ -2958,7 +2958,7 @@ const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js'); })().pass); record('ownership: the rule is the one the page actually uses', (() => { - const source = readFileSync(join(ROOT, 'src/components/skills/SkillSurface.jsx'), 'utf8'); + const source = readFileSync(join(ROOT, resolveSourcePath('src/components/skills/SkillSurface.jsx')), 'utf8'); return { pass: /agentPermitsSkill\(skill\.id, scope\)/.test(source) && /from '@\/lib\/agents\/runtime'/.test(source), @@ -2969,7 +2969,7 @@ const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js'); record('ownership: the panel reads agents from the definitions store', (() => { /* The live failure: this file read `preferences.customAgents`, which authored agents had already moved out of, so no attachment was ever visible here. */ - const source = readFileSync(join(ROOT, 'src/components/ai-assistant/AgentContext.jsx'), 'utf8'); + const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentContext.jsx')), 'utf8'); return { pass: /useAgentDefinitions\(\)/.test(source) && /sourcesFrom\(/.test(source) @@ -3788,7 +3788,7 @@ record('agent starters carry no capability, so they cannot address an unoffered * Read out of `App.jsx` rather than asserted against a written list, so a route * that is renamed or removed fails here rather than in someone's browser. */ -const appSource = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8'); +const appSource = readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8'); /** * The switcher, if there still is one. @@ -3800,7 +3800,7 @@ const appSource = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8'); * ENOENT and stopped the whole run here, taking every section after it with it. * Absent is a state to report, not to crash on. */ -const SWITCHER_PATH = join(ROOT, 'src/components/ai-assistant/AgentSwitcher.jsx'); +const SWITCHER_PATH = join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentSwitcher.jsx')); const switcherSource = existsSync(SWITCHER_PATH) ? readFileSync(SWITCHER_PATH, 'utf8') : null; /* Only live navigations count: a disabled control goes nowhere by design. */ @@ -3836,7 +3836,7 @@ record('every route the agent switcher navigates to exists', * keeping — offered somewhere, and the route resolves — so it is asserted * against where the control actually is. */ -const agentsListSource = readFileSync(join(ROOT, 'src/pages/admin/WorkspaceAgents.jsx'), 'utf8'); +const agentsListSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/WorkspaceAgents.jsx')), 'utf8'); record('creating an agent is reachable, and its screen exists', /navigate\('\/admin\/workspace\/agents\/new'\)/.test(agentsListSource) && routed.has('/admin/workspace/agents/new'), @@ -4497,7 +4497,7 @@ record('every category offered by the picker matches at least one skill', const managementRoutes = ['/admin/workspace/agents', '/admin/workspace/agents/new']; const appRoutes = new Set( - [...readFileSync(join(ROOT, 'src/App.jsx'), 'utf8').matchAll(/ m[1]) .map((path) => (path.startsWith('/') ? path : `/admin/${path}`)) ); @@ -4523,7 +4523,7 @@ record('every surface route is registered in the router', record('the dynamic agent route is registered after the static one', (() => { - const source = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8'); + const source = readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8'); return source.indexOf('workspace/agents/new') < source.indexOf('workspace/agents/:id'); })(), 'so `agents/new` cannot be read as an agent whose id is "new"'); @@ -4710,8 +4710,8 @@ record('...and never answered from the configure page itself', * Asserted structurally: the configure screen must not import or define a chat. * The panel it gets is the one the Admin shell already mounts. */ -const detailSource = readFileSync(join(ROOT, 'src/pages/admin/AgentDetail.jsx'), 'utf8'); -const configureSource = readFileSync(join(ROOT, 'src/components/agents/AgentConfigure.jsx'), 'utf8'); +const detailSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/AgentDetail.jsx')), 'utf8'); +const configureSource = readFileSync(join(ROOT, resolveSourcePath('src/components/agents/AgentConfigure.jsx')), 'utf8'); /* The property is right and the old pattern was too blunt. It matched any occurrence of the substring, so `useAssistantPanel` — the hook for talking to @@ -4735,18 +4735,18 @@ record('the configure screen defines no chat of its own', record('the panel is mounted once, by the shell', /* ` ['src/pages/admin', f]), ...readdirSync(join(ROOT, 'src/components/agents')).map((f) => ['src/components/agents', f]), ] - .filter(([, f]) => /\.jsx?$/.test(f)) + .filter(([, f]) => /\.[jt]sx?$/.test(f)) .filter(([, f]) => !/^(AgentConfigure|AgentDetail|AgentCanvas)\./.test(f)) .filter(([dir, f]) => /agents\/AgentCanvas|from '\.\/AgentCanvas'/ - .test(readFileSync(join(ROOT, dir, f), 'utf8'))) + .test(readFileSync(join(ROOT, resolveSourcePath(`${dir}/${f}`)), 'utf8'))) .map(([dir, f]) => `${dir}/${f}`); record('the workspace treatment is used by the configure screen alone', canvasImporters.length === 0, @@ -6045,8 +6045,8 @@ record('a candidate who scored zero still reaches the queue', /* Comments are stripped first: these assert what the page DOES, and a comment explaining the bug that was fixed must not read as the bug. */ const decomment = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); - const candidatesSrc = decomment(readFileSync(join(ROOT, 'src/pages/admin/Candidates.jsx'), 'utf8')); - const nodesSrc = decomment(readFileSync(join(ROOT, 'src/pages/admin/candidates/nodes.jsx'), 'utf8')); + const candidatesSrc = decomment(readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/Candidates.jsx')), 'utf8')); + const nodesSrc = decomment(readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/candidates/nodes.jsx')), 'utf8')); /* Read from source rather than imported: the page pulls in the interview modal, which touches `window` at module scope, and standing up a DOM to read one string constant would be a worse test than reading the string. */ @@ -6177,7 +6177,7 @@ const human = await server.ssrLoadModule('/src/lib/humanInterviews.js'); * a real screening score with one this interview never took. */ { - const hooksSrc = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8'); + const hooksSrc = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8'); const complete = hooksSrc.slice( hooksSrc.indexOf('export function useCompleteHumanInterview'), hooksSrc.indexOf('export function useRecordInterviewNotHeld') @@ -6223,7 +6223,7 @@ const human = await server.ssrLoadModule('/src/lib/humanInterviews.js'); /* The AI flow is untouched: it still writes its own record at completion, and still does it through the same client this now shares. */ { - const aiSrc = readFileSync(join(ROOT, 'src/components/krow/AIInterviewModal.jsx'), 'utf8'); + const aiSrc = readFileSync(join(ROOT, resolveSourcePath('src/components/krow/AIInterviewModal.jsx')), 'utf8'); record('the AI interview still records itself on completion', /const finishInterview = useCallback/.test(aiSrc) && /createInterview\.mutateAsync\(\{/.test(aiSrc) @@ -6233,7 +6233,7 @@ const human = await server.ssrLoadModule('/src/lib/humanInterviews.js'); /* The modal is no longer theatre. */ { - const modalSrc = readFileSync(join(ROOT, 'src/components/krow/ScheduleInterviewModal.jsx'), 'utf8'); + const modalSrc = readFileSync(join(ROOT, resolveSourcePath('src/components/krow/ScheduleInterviewModal.jsx')), 'utf8'); record('scheduling an interview persists it', /useScheduleHumanInterview/.test(modalSrc) && /schedule\.mutateAsync/.test(modalSrc)); record('...the props its six call sites pass are unchanged', @@ -6250,8 +6250,8 @@ const human = await server.ssrLoadModule('/src/lib/humanInterviews.js'); instead of to a person, and the hire it becomes cannot be traced back to the profile it came from. Both call sites are asserted because they were written at different times and only one of them is on the position page. */ -const positionDetailSource = readFileSync(join(ROOT, 'src/pages/PositionDetail.jsx'), 'utf8'); -const hooksSource = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8'); +const positionDetailSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/PositionDetail.jsx')), 'utf8'); +const hooksSource = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8'); record('admitting talent to a position links the application to the profile', /worker_profile_id: profile\.id/.test(positionDetailSource), 'PositionDetail.admitTalent'); @@ -6295,7 +6295,7 @@ record('...and asks for nobody when there is no profile', records.applicationsForProfile(whoApps, null).length === 0, 'empty, not everything'); /* The reported symptom: a talent pool you could read and not use. */ -const talentPoolSource = readFileSync(join(ROOT, 'src/pages/admin/talent-pool/nodes.jsx'), 'utf8'); +const talentPoolSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/talent-pool/nodes.jsx')), 'utf8'); record('a talent pool row opens that person', /onRowClick=\{\(p\) => navigate\(`\/admin\/talent\/\$\{p\.id\}`\)\}/.test(talentPoolSource), 'DataTable wires the handler only when it is given one'); @@ -7695,7 +7695,7 @@ console.log('\n── Candidates vs Talent Pool ──'); ...registryMod.nodeRegistry.list(), ]; const engine = ['node.js', 'registry.js', 'operations.js', 'validate.js', 'patch.js', 'inspect.js'] - .map((f) => readFileSync(join(ROOT, 'src/lib/ui', f), 'utf8')); + .map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/lib/ui/${f}`)), 'utf8')); const offences = []; engine.forEach((source, i) => { @@ -7922,7 +7922,7 @@ console.log('\n── Candidates vs Talent Pool ──'); entry is only created by a `register` call that was handed a reference. A node naming a type nobody registered draws nothing — asserted above — so there is no string that can become code. */ - const source = readFileSync(join(ROOT, 'src/components/ui-tree/UiTreeRenderer.jsx'), 'utf8') + const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiTreeRenderer.jsx')), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/^\s*\/\/.*$/gm, ''); return !/\bimport\s*\(/.test(source) @@ -8107,7 +8107,7 @@ console.log('\n── Candidates vs Talent Pool ──'); record('applying writes only the uiLayouts key, so no other preference moves', (() => { /* `useUiLayouts.save` sends `{ uiLayouts }` and nothing else; the endpoint shallow-merges, so `customSkills` and the rest survive untouched. */ - const source = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8'); + const source = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8'); const fn = source.slice(source.indexOf('export function useUiLayouts'), source.indexOf('export function useJobPostings')); const writes = [...fn.matchAll(/mutateAsync\(\{([^}]*)\}/g)].map((m) => m[1].trim()); return writes.length === 1 && writes[0].startsWith('uiLayouts:'); @@ -8207,7 +8207,7 @@ console.log('\n── Candidates vs Talent Pool ──'); record('an operation that would be refused is never stored', (() => { /* `propose` validates before it previews, so a refused change never becomes a preview and therefore never reaches an apply. */ - const provider = readFileSync(join(ROOT, 'src/components/ui-tree/UiEditingProvider.jsx'), 'utf8'); + const provider = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiEditingProvider.jsx')), 'utf8'); const proposeBody = provider.slice(provider.indexOf('const propose'), provider.indexOf('/** Throw the experiment away')); return /applyOperation\(/.test(proposeBody) && /if \(!result\.ok\)/.test(proposeBody) @@ -8225,7 +8225,7 @@ console.log('\n── Candidates vs Talent Pool ──'); ]; const offences = []; for (const f of files) { - const code = readFileSync(join(ROOT, f), 'utf8') + const code = readFileSync(join(ROOT, resolveSourcePath(f)), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); for (const bad of ['writeFile', 'customSkills', 'skillDefinition', 'skill-definitions', '.md']) { if (code.includes(bad)) offences.push(`${f}: ${bad}`); @@ -8479,7 +8479,7 @@ console.log('\n── Candidates vs Talent Pool ──'); })().pass); record('the language layer names no page and no component', (() => { - const code = readFileSync(join(ROOT, 'src/lib/ui/intent.js'), 'utf8') + const code = readFileSync(join(ROOT, resolveSourcePath('src/lib/ui/intent.js')), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...reg4.list()]; const hits = banned.filter((w) => new RegExp(`['"\`]${w}['"\`]`).test(code)); @@ -9518,7 +9518,7 @@ console.log('\n── Candidates vs Talent Pool ──'); record('the editor never evaluates a string as code', (() => { const files = ['UiEditor.jsx', 'TreePanel.jsx', 'NodeInspector.jsx', 'NodePicker.jsx', 'ops.js'] - .map((f) => readFileSync(join(ROOT, 'src/components/ui-editor', f), 'utf8')) + .map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/components/ui-editor/${f}`)), 'utf8')) .join('\n') .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); return !/\beval\s*\(/.test(files) && !/new\s+Function/.test(files) @@ -9527,7 +9527,7 @@ console.log('\n── Candidates vs Talent Pool ──'); record('the editor contains no page name and no component branch', (() => { const files = ['UiEditor.jsx', 'TreePanel.jsx', 'NodeInspector.jsx', 'NodePicker.jsx', 'ops.js'] - .map((f) => readFileSync(join(ROOT, 'src/components/ui-editor', f), 'utf8')) + .map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/components/ui-editor/${f}`)), 'utf8')) .join('\n') .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...nreg.list()]; @@ -9740,7 +9740,7 @@ console.log('\n── Candidates vs Talent Pool ──'); * `scripts/browser-flows.js`, and only held to its contract here. */ record('every node is rendered inside a boundary', (() => { - const source = readFileSync(join(ROOT, 'src/components/ui-tree/UiTreeRenderer.jsx'), 'utf8'); + const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiTreeRenderer.jsx')), 'utf8'); return { pass: //.test(source) && /import \{ UiNodeBoundary \}/.test(source), diff --git a/scripts/ssr-resolve.mjs b/scripts/ssr-resolve.mjs index a065373..787458e 100644 --- a/scripts/ssr-resolve.mjs +++ b/scripts/ssr-resolve.mjs @@ -78,3 +78,31 @@ export function withSourceResolution(server, root = process.cwd()) { 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; +}