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