diff --git a/scripts/lib/cli-invocation.ts b/scripts/lib/cli-invocation.ts new file mode 100644 index 00000000..834d2999 --- /dev/null +++ b/scripts/lib/cli-invocation.ts @@ -0,0 +1,26 @@ +/** + * Resolve `node` argv for spawning the codegraph CLI as a subprocess. + * + * Benchmark scripts (`token-benchmark.ts`) need to run the actual + * `codegraph` CLI (`build`, `mcp`) against a target directory, not just + * import its programmatic API. This repo ships TypeScript source with no + * compiled `dist/` required for local development, so `node /src/cli.js` + * fails outright — only `cli.ts` exists (#1907). Even after fixing the + * extension, a spawned child process doesn't inherit the parent's + * `--experimental-strip-types` / `--import ` flags the way + * statically- or dynamically-imported modules in the parent process do, so + * those flags must be passed explicitly. Mirrors the pattern already used by + * `tests/integration/cli.test.ts`'s `NODE_TS_FLAGS`. + */ +import path from 'node:path'; + +/** + * @param root Absolute path to the codegraph repo root (the directory + * containing `src/`, not `src/` itself). + * @returns argv (excluding `node` itself) to spawn `src/cli.ts` with the + * flags required to run TypeScript source directly. + */ +export function resolveCliNodeArgs(root: string): string[] { + const loaderUrl = new URL('../ts-resolve-loader.ts', import.meta.url).href; + return ['--experimental-strip-types', '--import', loaderUrl, path.join(root, 'src', 'cli.ts')]; +} diff --git a/scripts/token-benchmark.ts b/scripts/token-benchmark.ts index 8d2f4c16..5aede43e 100644 --- a/scripts/token-benchmark.ts +++ b/scripts/token-benchmark.ts @@ -27,7 +27,9 @@ import { parseArgs } from 'node:util'; import { ISSUES, extractAgentOutput, validateResult } from './token-benchmark-issues.js'; import { getBenchmarkVersion } from './bench-version.js'; +import { srcImport } from './lib/bench-config.js'; import { median, round1, timeMedian } from './lib/bench-timing.js'; +import { resolveCliNodeArgs } from './lib/cli-invocation.js'; import { extractUsageMetrics, tallyToolCalls } from './lib/session-metrics.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -35,6 +37,13 @@ const root = path.resolve(__dirname, '..'); const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); const benchVersion = getBenchmarkVersion(pkg.version, root); +// Codegraph's own module tree lives nested under src/ (mirrors dist/'s +// layout), not flat — see scripts/lib/bench-config.ts's srcImport() for the +// same .ts-preferring resolution used by the other benchmark scripts. +const SRC_DIR = path.join(root, 'src'); + +const CLI_NODE_ARGS = resolveCliNodeArgs(root); + // Redirect console.log to stderr so only JSON goes to stdout const origLog = console.log; console.log = (...args) => console.error(...args); @@ -153,10 +162,9 @@ function checkoutCommit(nextjsDir, sha) { // ── Graph building ──────────────────────────────────────────────────────── async function buildCodegraph(nextjsDir) { - const cliPath = path.join(root, 'src', 'cli.js'); console.error('Building codegraph graph for Next.js...'); const start = performance.now(); - execFileSync('node', [cliPath, 'build', nextjsDir], { + execFileSync('node', [...CLI_NODE_ARGS, 'build', nextjsDir], { cwd: nextjsDir, stdio: 'pipe', timeout: 600_000, // 10 min @@ -189,12 +197,11 @@ function buildSessionOptions(mode, nextjsDir) { if (mode === 'codegraph') { const dbPath = path.join(nextjsDir, '.codegraph', 'graph.db'); - const cliPath = path.join(root, 'src', 'cli.js'); options.mcpServers = { codegraph: { type: 'stdio', command: 'node', - args: [cliPath, 'mcp', '-d', dbPath], + args: [...CLI_NODE_ARGS, 'mcp', '-d', dbPath], }, }; } @@ -310,19 +317,12 @@ async function runQueryBenchmarks(hubName, dbPath, fnDepsData, fnImpactData) { * Reuses the same codegraph APIs as the existing benchmark scripts. */ async function runPerfBenchmarks(nextjsDir) { - const { pathToFileURL } = await import('node:url'); - const { buildGraph } = await import( - pathToFileURL(path.join(root, 'src', 'builder.js')).href - ); + const { buildGraph } = await import(srcImport(SRC_DIR, 'domain/graph/builder.js')); const { fnDepsData, fnImpactData, statsData } = await import( - pathToFileURL(path.join(root, 'src', 'queries.js')).href - ); - const { isNativeAvailable } = await import( - pathToFileURL(path.join(root, 'src', 'native.js')).href - ); - const { isWasmAvailable } = await import( - pathToFileURL(path.join(root, 'src', 'parser.js')).href + srcImport(SRC_DIR, 'domain/queries.js') ); + const { isNativeAvailable } = await import(srcImport(SRC_DIR, 'infrastructure/native.js')); + const { isWasmAvailable } = await import(srcImport(SRC_DIR, 'domain/parser.js')); const dbPath = path.join(nextjsDir, '.codegraph', 'graph.db'); diff --git a/tests/unit/bench-config-src-import.test.ts b/tests/unit/bench-config-src-import.test.ts new file mode 100644 index 00000000..85215826 --- /dev/null +++ b/tests/unit/bench-config-src-import.test.ts @@ -0,0 +1,44 @@ +/** + * Unit tests for scripts/lib/bench-config.ts's srcImport(). + * + * Regression coverage for #1907: scripts/token-benchmark.ts's `--perf` + * dynamic imports used flat paths (e.g. `src/builder.js`, `src/queries.js`, + * `src/native.js`, `src/parser.js`) that never existed — the real modules + * live nested (`src/domain/graph/builder.ts`, `src/domain/queries.ts`, + * `src/infrastructure/native.ts`, `src/domain/parser.ts`), mirroring dist/'s + * layout. This locks in that the exact nested subpaths token-benchmark.ts + * now imports via srcImport() resolve to real, importable modules exporting + * the symbols runPerfBenchmarks() needs. + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { srcImport } from '../../scripts/lib/bench-config.js'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const srcDir = path.join(repoRoot, 'src'); + +describe('srcImport() against the module paths used by token-benchmark.ts --perf', () => { + it('resolves domain/graph/builder.js to a module exporting buildGraph', async () => { + const mod = await import(srcImport(srcDir, 'domain/graph/builder.js')); + expect(typeof mod.buildGraph).toBe('function'); + }); + + it('resolves domain/queries.js to a module exporting fnDepsData, fnImpactData, statsData', async () => { + const mod = await import(srcImport(srcDir, 'domain/queries.js')); + expect(typeof mod.fnDepsData).toBe('function'); + expect(typeof mod.fnImpactData).toBe('function'); + expect(typeof mod.statsData).toBe('function'); + }); + + it('resolves infrastructure/native.js to a module exporting isNativeAvailable', async () => { + const mod = await import(srcImport(srcDir, 'infrastructure/native.js')); + expect(typeof mod.isNativeAvailable).toBe('function'); + }); + + it('resolves domain/parser.js to a module exporting isWasmAvailable', async () => { + const mod = await import(srcImport(srcDir, 'domain/parser.js')); + expect(typeof mod.isWasmAvailable).toBe('function'); + }); +}); diff --git a/tests/unit/cli-invocation.test.ts b/tests/unit/cli-invocation.test.ts new file mode 100644 index 00000000..a8808315 --- /dev/null +++ b/tests/unit/cli-invocation.test.ts @@ -0,0 +1,46 @@ +/** + * Unit tests for scripts/lib/cli-invocation.ts + * + * Regression coverage for #1907: scripts/token-benchmark.ts spawned the + * codegraph CLI via `path.join(root, 'src', 'cli.js')` — a file that never + * existed (src/ has no compiled .js output; only src/cli.ts) — and, even + * after fixing the extension, a spawned child `node` process doesn't + * inherit the --experimental-strip-types / --import flags the + * parent script itself needs to run TypeScript source. resolveCliNodeArgs() + * centralizes the correct invocation so it can be exercised directly. + */ + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { resolveCliNodeArgs } from '../../scripts/lib/cli-invocation.js'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +describe('resolveCliNodeArgs', () => { + it('resolves the strip-types flag, the loader, and an existing cli.ts entry point', () => { + const args = resolveCliNodeArgs(repoRoot); + + expect(args[0]).toBe('--experimental-strip-types'); + expect(args[1]).toBe('--import'); + expect(args[2]).toMatch(/^file:.*ts-resolve-loader\.ts$/); + expect(args[3]).toBe(path.join(repoRoot, 'src', 'cli.ts')); + + // Both the loader and the CLI entry point must exist on disk — this is + // exactly the class of bug #1907 fixed (paths pointing at files that + // were never there). + expect(fs.existsSync(new URL(args[2]))).toBe(true); + expect(fs.existsSync(args[3])).toBe(true); + }); + + it('spawns the real CLI successfully via the resolved argv (mirrors tests/integration/cli.test.ts)', () => { + const args = resolveCliNodeArgs(repoRoot); + const out = execFileSync('node', [...args, '--version'], { + encoding: 'utf-8', + timeout: 30_000, + }); + expect(out.trim()).toMatch(/^\d+\.\d+\.\d+/); + }); +});