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
This commit is contained in:
2026-09-17 22:56:45 +05:30
parent dca184289e
commit 02a2ab05ef
2 changed files with 65 additions and 37 deletions

View File

@@ -19,7 +19,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs'; import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
import { buildFixture, FIXTURE_PATH } from './seed-fixture.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 ROOT = process.cwd();
const results = []; 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 now does — and the failure mode of it returning is not an error but a second
opinion silently outranking the server's. */ opinion silently outranking the server's. */
record('the panel imports no local suggestion ranker', 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'); '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', record('...and does not rank, score or sort suggestions itself',
!/rankPrompts|\.sort\(|score\(/.test(panelSource), !/rankPrompts|\.sort\(|score\(/.test(panelSource),
'no ranking in the panel'); 'no ranking in the panel');
@@ -1630,10 +1630,10 @@ record('...it asks the API instead',
/* The production data client must not carry the fixture. */ /* The production data client must not carry the fixture. */
record('the app does not import the seed 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'); 'base44Client reads demoUser.js, not seed.js');
record('the local entity store is gone', 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'); 'store.js removed');
/* ── 11. A fresh Add Owliver Skill screen ───────────────────────────────── /* ── 11. A fresh Add Owliver Skill screen ─────────────────────────────────
@@ -2958,7 +2958,7 @@ const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js');
})().pass); })().pass);
record('ownership: the rule is the one the page actually uses', (() => { 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 { return {
pass: /agentPermitsSkill\(skill\.id, scope\)/.test(source) pass: /agentPermitsSkill\(skill\.id, scope\)/.test(source)
&& /from '@\/lib\/agents\/runtime'/.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', (() => { record('ownership: the panel reads agents from the definitions store', (() => {
/* The live failure: this file read `preferences.customAgents`, which authored /* The live failure: this file read `preferences.customAgents`, which authored
agents had already moved out of, so no attachment was ever visible here. */ 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 { return {
pass: /useAgentDefinitions\(\)/.test(source) pass: /useAgentDefinitions\(\)/.test(source)
&& /sourcesFrom\(/.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 * 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. * 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. * 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. * ENOENT and stopped the whole run here, taking every section after it with it.
* Absent is a state to report, not to crash on. * 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; const switcherSource = existsSync(SWITCHER_PATH) ? readFileSync(SWITCHER_PATH, 'utf8') : null;
/* Only live navigations count: a disabled control goes nowhere by design. */ /* 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 * keeping — offered somewhere, and the route resolves — so it is asserted
* against where the control actually is. * 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', record('creating an agent is reachable, and its screen exists',
/navigate\('\/admin\/workspace\/agents\/new'\)/.test(agentsListSource) /navigate\('\/admin\/workspace\/agents\/new'\)/.test(agentsListSource)
&& routed.has('/admin/workspace/agents/new'), && 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 managementRoutes = ['/admin/workspace/agents', '/admin/workspace/agents/new'];
const appRoutes = new Set( const appRoutes = new Set(
[...readFileSync(join(ROOT, 'src/App.jsx'), 'utf8').matchAll(/<Route\s+path="([^"]+)"/g)] [...readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8').matchAll(/<Route\s+path="([^"]+)"/g)]
.map((m) => m[1]) .map((m) => m[1])
.map((path) => (path.startsWith('/') ? path : `/admin/${path}`)) .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', 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'); return source.indexOf('workspace/agents/new') < source.indexOf('workspace/agents/:id');
})(), })(),
'so `agents/new` cannot be read as an agent whose id is "new"'); '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. * Asserted structurally: the configure screen must not import or define a chat.
* The panel it gets is the one the Admin shell already mounts. * The panel it gets is the one the Admin shell already mounts.
*/ */
const detailSource = readFileSync(join(ROOT, 'src/pages/admin/AgentDetail.jsx'), 'utf8'); const detailSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/AgentDetail.jsx')), 'utf8');
const configureSource = readFileSync(join(ROOT, 'src/components/agents/AgentConfigure.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 /* 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 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', record('the panel is mounted once, by the shell',
/* `<AssistantPanel` alone also matches `<AssistantPanelProvider`, which is a /* `<AssistantPanel` alone also matches `<AssistantPanelProvider`, which is a
different component and legitimately present. */ different component and legitimately present. */
(readFileSync(join(ROOT, 'src/layouts/AdminLayout.jsx'), 'utf8').match(/<AssistantPanel\b(?!Provider)/g) || []).length === 1); (readFileSync(join(ROOT, resolveSourcePath('src/layouts/AdminLayout.jsx')), 'utf8').match(/<AssistantPanel\b(?!Provider)/g) || []).length === 1);
/* The switcher is gone — see the note above. What replaced it is a label, and /* The switcher is gone — see the note above. What replaced it is a label, and
the property worth keeping is that it stayed one: a header that can be the property worth keeping is that it stayed one: a header that can be
clicked is a second place to choose an agent. */ clicked is a second place to choose an agent. */
const badgeSource = readFileSync(join(ROOT, 'src/components/ai-assistant/AgentBadge.jsx'), 'utf8'); const badgeSource = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentBadge.jsx')), 'utf8');
record('the panel header names the agent and does not switch it', record('the panel header names the agent and does not switch it',
!/<button|onClick=|navigate\(/.test(badgeSource), !/<button|onClick=|navigate\(/.test(badgeSource),
'a label, not a control'); 'a label, not a control');
record('PageContext is untouched', record('PageContext is untouched',
!/agentConfigure/.test(readFileSync(join(ROOT, 'src/components/ai-assistant/PageContext.jsx'), 'utf8'))); !/agentConfigure/.test(readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/PageContext.jsx')), 'utf8')));
/* ── The configure surface declares no placements ───────────────────────── */ /* ── The configure surface declares no placements ───────────────────────── */
@@ -4781,7 +4781,7 @@ record('no skill declares the configure page',
* **It stays Krow.** The palette is the tokens, and nothing else. * **It stays Krow.** The palette is the tokens, and nothing else.
*/ */
const canvasSource = readFileSync(join(ROOT, 'src/components/agents/AgentCanvas.jsx'), 'utf8'); const canvasSource = readFileSync(join(ROOT, resolveSourcePath('src/components/agents/AgentCanvas.jsx')), 'utf8');
const agentUi = detailSource + configureSource + canvasSource; const agentUi = detailSource + configureSource + canvasSource;
/* One surface with sections inside it, not four cards side by side. */ /* One surface with sections inside it, not four cards side by side. */
@@ -4922,10 +4922,10 @@ const canvasImporters = [
...readdirSync(join(ROOT, 'src/pages/admin')).map((f) => ['src/pages/admin', f]), ...readdirSync(join(ROOT, 'src/pages/admin')).map((f) => ['src/pages/admin', f]),
...readdirSync(join(ROOT, 'src/components/agents')).map((f) => ['src/components/agents', 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(([, f]) => !/^(AgentConfigure|AgentDetail|AgentCanvas)\./.test(f))
.filter(([dir, f]) => /agents\/AgentCanvas|from '\.\/AgentCanvas'/ .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}`); .map(([dir, f]) => `${dir}/${f}`);
record('the workspace treatment is used by the configure screen alone', record('the workspace treatment is used by the configure screen alone',
canvasImporters.length === 0, 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 /* 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. */ 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 decomment = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const candidatesSrc = decomment(readFileSync(join(ROOT, 'src/pages/admin/Candidates.jsx'), 'utf8')); const candidatesSrc = decomment(readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/Candidates.jsx')), 'utf8'));
const nodesSrc = decomment(readFileSync(join(ROOT, 'src/pages/admin/candidates/nodes.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 /* 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 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. */ 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. * 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( const complete = hooksSrc.slice(
hooksSrc.indexOf('export function useCompleteHumanInterview'), hooksSrc.indexOf('export function useCompleteHumanInterview'),
hooksSrc.indexOf('export function useRecordInterviewNotHeld') 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 /* The AI flow is untouched: it still writes its own record at completion, and
still does it through the same client this now shares. */ 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', record('the AI interview still records itself on completion',
/const finishInterview = useCallback/.test(aiSrc) /const finishInterview = useCallback/.test(aiSrc)
&& /createInterview\.mutateAsync\(\{/.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. */ /* 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', record('scheduling an interview persists it',
/useScheduleHumanInterview/.test(modalSrc) && /schedule\.mutateAsync/.test(modalSrc)); /useScheduleHumanInterview/.test(modalSrc) && /schedule\.mutateAsync/.test(modalSrc));
record('...the props its six call sites pass are unchanged', 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 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 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. */ at different times and only one of them is on the position page. */
const positionDetailSource = readFileSync(join(ROOT, 'src/pages/PositionDetail.jsx'), 'utf8'); const positionDetailSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/PositionDetail.jsx')), 'utf8');
const hooksSource = readFileSync(join(ROOT, 'src/lib/krowHooks.js'), 'utf8'); const hooksSource = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8');
record('admitting talent to a position links the application to the profile', record('admitting talent to a position links the application to the profile',
/worker_profile_id: profile\.id/.test(positionDetailSource), /worker_profile_id: profile\.id/.test(positionDetailSource),
'PositionDetail.admitTalent'); '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'); records.applicationsForProfile(whoApps, null).length === 0, 'empty, not everything');
/* The reported symptom: a talent pool you could read and not use. */ /* 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', record('a talent pool row opens that person',
/onRowClick=\{\(p\) => navigate\(`\/admin\/talent\/\$\{p\.id\}`\)\}/.test(talentPoolSource), /onRowClick=\{\(p\) => navigate\(`\/admin\/talent\/\$\{p\.id\}`\)\}/.test(talentPoolSource),
'DataTable wires the handler only when it is given one'); 'DataTable wires the handler only when it is given one');
@@ -7695,7 +7695,7 @@ console.log('\n── Candidates vs Talent Pool ──');
...registryMod.nodeRegistry.list(), ...registryMod.nodeRegistry.list(),
]; ];
const engine = ['node.js', 'registry.js', 'operations.js', 'validate.js', 'patch.js', 'inspect.js'] 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 = []; const offences = [];
engine.forEach((source, i) => { 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. entry is only created by a `register` call that was handed a reference.
A node naming a type nobody registered draws nothing — asserted above — A node naming a type nobody registered draws nothing — asserted above —
so there is no string that can become code. */ 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\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, ''); .replace(/^\s*\/\/.*$/gm, '');
return !/\bimport\s*\(/.test(source) 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', (() => { record('applying writes only the uiLayouts key, so no other preference moves', (() => {
/* `useUiLayouts.save` sends `{ uiLayouts }` and nothing else; the endpoint /* `useUiLayouts.save` sends `{ uiLayouts }` and nothing else; the endpoint
shallow-merges, so `customSkills` and the rest survive untouched. */ 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 fn = source.slice(source.indexOf('export function useUiLayouts'), source.indexOf('export function useJobPostings'));
const writes = [...fn.matchAll(/mutateAsync\(\{([^}]*)\}/g)].map((m) => m[1].trim()); const writes = [...fn.matchAll(/mutateAsync\(\{([^}]*)\}/g)].map((m) => m[1].trim());
return writes.length === 1 && writes[0].startsWith('uiLayouts:'); 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', (() => { record('an operation that would be refused is never stored', (() => {
/* `propose` validates before it previews, so a refused change never becomes /* `propose` validates before it previews, so a refused change never becomes
a preview and therefore never reaches an apply. */ 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')); const proposeBody = provider.slice(provider.indexOf('const propose'), provider.indexOf('/** Throw the experiment away'));
return /applyOperation\(/.test(proposeBody) return /applyOperation\(/.test(proposeBody)
&& /if \(!result\.ok\)/.test(proposeBody) && /if \(!result\.ok\)/.test(proposeBody)
@@ -8225,7 +8225,7 @@ console.log('\n── Candidates vs Talent Pool ──');
]; ];
const offences = []; const offences = [];
for (const f of files) { 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, ''); .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const bad of ['writeFile', 'customSkills', 'skillDefinition', 'skill-definitions', '.md']) { for (const bad of ['writeFile', 'customSkills', 'skillDefinition', 'skill-definitions', '.md']) {
if (code.includes(bad)) offences.push(`${f}: ${bad}`); if (code.includes(bad)) offences.push(`${f}: ${bad}`);
@@ -8479,7 +8479,7 @@ console.log('\n── Candidates vs Talent Pool ──');
})().pass); })().pass);
record('the language layer names no page and no component', (() => { 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, ''); .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...reg4.list()]; const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...reg4.list()];
const hits = banned.filter((w) => new RegExp(`['"\`]${w}['"\`]`).test(code)); 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', (() => { record('the editor never evaluates a string as code', (() => {
const files = ['UiEditor.jsx', 'TreePanel.jsx', 'NodeInspector.jsx', 'NodePicker.jsx', 'ops.js'] 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') .join('\n')
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
return !/\beval\s*\(/.test(files) && !/new\s+Function/.test(files) 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', (() => { record('the editor contains no page name and no component branch', (() => {
const files = ['UiEditor.jsx', 'TreePanel.jsx', 'NodeInspector.jsx', 'NodePicker.jsx', 'ops.js'] 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') .join('\n')
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...nreg.list()]; 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. * `scripts/browser-flows.js`, and only held to its contract here.
*/ */
record('every node is rendered inside a boundary', (() => { 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 { return {
pass: /<UiNodeBoundary node=\{node\}>/.test(source) pass: /<UiNodeBoundary node=\{node\}>/.test(source)
&& /import \{ UiNodeBoundary \}/.test(source), && /import \{ UiNodeBoundary \}/.test(source),

View File

@@ -78,3 +78,31 @@ export function withSourceResolution(server, root = process.cwd()) {
return server; 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;
}