#!/usr/bin/env node // Gauntlet tooling (P1a): independent ground truth for TypeScript/JavaScript CALLS // edges. Uses the real `typescript` LanguageService (not the heuristic indexer) to // resolve each call site's definition, and compares it against the edge the // indexer produced. This is the objective oracle for TS/JS; non-TS/JS languages // (and any TS/JS call the LanguageService can't resolve) fall through to the LLM // judge packet (judge-packet.mjs). // // Usage: node bench/ts-oracle.mjs [--out verdicts.jsonl] import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; import ts from 'typescript'; import { walkRepo, findNearestTsconfig } from '../dist/index.js'; const require = createRequire(import.meta.url); const TS_LIB_DIR = path.dirname(require.resolve('typescript')); const TS_LANGS = new Set(['typescript', 'tsx', 'javascript']); function parseArgs(argv) { const args = { repoPath: null, edgesPath: null, out: 'verdicts.jsonl' }; const positional = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--out') args.out = argv[++i]; else if (a === '--help' || a === '-h') args.help = true; else positional.push(a); } [args.repoPath, args.edgesPath] = positional; return args; } function usage() { console.error('usage: node bench/ts-oracle.mjs [--out verdicts.jsonl]'); } function readJsonl(p) { return fs.readFileSync(p, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l)); } function posixRel(from, to) { return path.relative(from, to).split(path.sep).join('/'); } // Minimal ts.LanguageServiceHost backed directly by the filesystem: no incremental // versions (each run is a fresh one-shot). `pathsConfig` (a TsPathsConfig from the // indexer's own tsPaths.js, or null) supplies this project's baseUrl/paths so // `getDefinitionAtPosition`/alias-following can resolve a "@/hooks/useApi"-style // specifier the same way the indexer's own tsconfig-aware resolveImport now does. // `fileNames` is always the FULL repo-wide TS/JS file set (not just this project's // own files) so cross-file navigation still works regardless of which project's // host answered the query - only baseUrl/paths differ per host. function makeHost(repoPath, fileNames, pathsConfig) { const files = new Set(fileNames); const snapshotCache = new Map(); const compilerOptions = { allowJs: true, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler ?? ts.ModuleResolutionKind.NodeNext, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.Preserve, ...(pathsConfig ? { baseUrl: pathsConfig.baseUrl ?? pathsConfig.configDir, paths: Object.fromEntries(pathsConfig.paths.map((p) => [ p.star ? `${p.prefix}*${p.suffix}` : p.prefix, p.targets, ])), } : {}), }; return { getScriptFileNames: () => [...files], getScriptVersion: () => '0', getScriptSnapshot: (fileName) => { if (snapshotCache.has(fileName)) return snapshotCache.get(fileName); if (!fs.existsSync(fileName)) return undefined; const snap = ts.ScriptSnapshot.fromString(fs.readFileSync(fileName, 'utf8')); snapshotCache.set(fileName, snap); return snap; }, getCurrentDirectory: () => repoPath, getCompilationSettings: () => compilerOptions, getDefaultLibFileName: (options) => path.join(TS_LIB_DIR, ts.getDefaultLibFileName(options)), fileExists: ts.sys.fileExists, readFile: ts.sys.readFile, readDirectory: ts.sys.readDirectory, directoryExists: ts.sys.directoryExists, getDirectories: ts.sys.getDirectories, useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, }; } // A single global ts.LanguageService can only have one compilerOptions, but this repo // has many sub-project tsconfigs (services/portal, services/web, cli, ...) each // declaring their own "@/*" -> "./src/*" relative to their OWN directory - a merged // global paths map would let e.g. services/web's `cn()` resolve to services/portal's // identically-aliased, identically-named `cn()` (a real collision seen in this repo's // shadcn-style utils/hooks). So: one LanguageService per distinct tsconfig project // (found via the indexer's own findNearestTsconfig, keyed by its configDir; files // under no applicable tsconfig share a single no-paths default project), sharing one // DocumentRegistry. Returns { serviceForFile(absFile) -> { ls, program } }. function makeProjectServices(repoPath, fileNames) { const registry = ts.createDocumentRegistry(); const byConfigDir = new Map(); // configDir key ('' = default) -> { ls, program } const fileToKey = new Map(); for (const abs of fileNames) { const cfg = findNearestTsconfig(path.dirname(abs), repoPath); const key = cfg ? cfg.configDir : ''; fileToKey.set(abs, key); if (!byConfigDir.has(key)) { const host = makeHost(repoPath, fileNames, cfg); const ls = ts.createLanguageService(host, registry); byConfigDir.set(key, { ls, program: ls.getProgram() }); } } const defaultEntry = byConfigDir.get('') ?? (() => { const host = makeHost(repoPath, fileNames, null); const ls = ts.createLanguageService(host, registry); const entry = { ls, program: ls.getProgram() }; byConfigDir.set('', entry); return entry; })(); return { serviceForFile: (absFile) => byConfigDir.get(fileToKey.get(absFile) ?? '') ?? defaultEntry, }; } function lineColToPos(source, line0, col0) { const lines = source.split('\n'); let pos = 0; for (let i = 0; i < line0; i++) pos += lines[i].length + 1; return pos + col0; } // True when `pos` falls inside an ImportDeclaration node of `sourceFile` - i.e. the // definition TS handed back is really the *local import specifier* (`import { add } // from '@/lib/util'`), not the thing it re-exports. Walks the already-parsed AST from // the LanguageService's Program (no extra parse). function isPositionInImportDeclaration(sourceFile, pos) { let found = false; const visit = (node) => { if (found || pos < node.getStart(sourceFile) || pos >= node.getEnd()) return; if (ts.isImportDeclaration(node)) { found = true; return; } node.forEachChild(visit); }; sourceFile.forEachChild(visit); return found; } // A definition whose `kind` is 'alias' (an import specifier) - or whose span sits // inside an import declaration in its own file - is one hop short of the real // target. Follow it once (getDefinitionAtPosition, falling back to // getTypeDefinitionAtPosition) and substitute the followed definition(s) in its // place. Only one hop: re-export chains longer than that are rare enough not to be // worth chasing here, and stop the oracle from ever infinite-looping on a cycle. function followAliasDefs(ls, program, defs) { const out = []; for (const d of defs) { const sf = program?.getSourceFile(d.fileName); const looksLikeImportBinding = d.kind === 'alias' || (sf && isPositionInImportDeclaration(sf, d.textSpan.start)); if (looksLikeImportBinding) { let followed; try { followed = ls.getDefinitionAtPosition(d.fileName, d.textSpan.start); } catch { followed = undefined; } if (!followed || !followed.length) { try { followed = ls.getTypeDefinitionAtPosition(d.fileName, d.textSpan.start); } catch { followed = undefined; } } if (followed && followed.length) { out.push(...followed); continue; } } out.push(d); } return out; } const isNodeModulesPath = (fileName) => /(^|[\\/])node_modules([\\/]|$)/.test(fileName); const tierOf = (confidence) => (confidence === 1 ? '1.0' : confidence === 0.6 ? '0.6' : confidence === 0.4 ? '0.4' : String(confidence)); // Find the callee identifier (last dotted segment of the tail qualname, e.g. // "greet" for "User.greet") inside the head symbol's line range, as a call // expression: the identifier followed by optional whitespace and `(` or `<` // (the `<` covers a generic call like `foo(...)`). function findCallSite(source, lineStart1, lineEnd1, identifier) { const lines = source.split('\n'); const re = new RegExp(`\\b${identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*[(<]`); const from = Math.max(0, lineStart1 - 1); const to = Math.min(lines.length - 1, lineEnd1 - 1); for (let li = from; li <= to; li++) { const m = re.exec(lines[li]); if (m) return { line0: li, col0: m.index }; } return null; } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help || !args.repoPath || !args.edgesPath) { usage(); process.exit(args.help ? 0 : 1); } const repoPath = path.resolve(args.repoPath); if (!fs.existsSync(repoPath)) { console.error(`repoPath does not exist: ${repoPath}`); process.exit(1); } if (!fs.existsSync(args.edgesPath)) { console.error(`edges file does not exist: ${args.edgesPath}`); process.exit(1); } const edges = readJsonl(args.edgesPath).filter((e) => (e.type ?? 'CALLS') === 'CALLS' && TS_LANGS.has(e.lang)); if (!edges.length) { console.error('ts-oracle: no TS/JS CALLS edges found in input, nothing to verify'); fs.writeFileSync(path.resolve(args.out), ''); return; } const walked = await walkRepo(repoPath); const fileNames = walked.filter((w) => TS_LANGS.has(w.lang)).map((w) => w.abs); const projects = makeProjectServices(repoPath, fileNames); const sourceCache = new Map(); const readSource = (relPath) => { if (sourceCache.has(relPath)) return sourceCache.get(relPath); const abs = path.join(repoPath, relPath); const src = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null; sourceCache.set(relPath, src); return src; }; const out = fs.createWriteStream(path.resolve(args.out)); let correct = 0, incorrect = 0, unknown = 0, external = 0; const tierStats = new Map(); // tier -> { correct, incorrect, unknown, external } const bumpTier = (confidence, verdict) => { const tier = tierOf(confidence); const s = tierStats.get(tier) ?? { correct: 0, incorrect: 0, unknown: 0, external: 0 }; s[verdict]++; tierStats.set(tier, s); }; for (const e of edges) { const identifier = e.tail.split('::').pop().split('.').pop(); const headSource = readSource(e.headFile); let verdict = 'unknown'; let oracleFile = null; let oracleName = null; if (headSource != null && e.headLineStart != null && e.headLineEnd != null) { const site = findCallSite(headSource, e.headLineStart, e.headLineEnd, identifier); if (site) { const absHead = path.join(repoPath, e.headFile); const pos = lineColToPos(headSource, site.line0, site.col0); const { ls, program } = projects.serviceForFile(absHead); let defs; try { defs = ls.getDefinitionAtPosition(absHead, pos); } catch { defs = undefined; } if (defs && defs.length) { defs = followAliasDefs(ls, program, defs); } if (defs && defs.length) { const match = defs.find((d) => posixRel(repoPath, d.fileName) === e.tailFile && (d.name ?? '').includes(identifier)); if (match) { verdict = 'correct'; oracleFile = posixRel(repoPath, match.fileName); oracleName = match.name ?? null; } else { const d0 = defs[0]; verdict = isNodeModulesPath(d0.fileName) ? 'external' : 'incorrect'; oracleFile = posixRel(repoPath, d0.fileName); oracleName = d0.name ?? null; } } } } if (verdict === 'correct') correct++; else if (verdict === 'incorrect') incorrect++; else if (verdict === 'external') external++; else unknown++; bumpTier(e.confidence, verdict); out.write(JSON.stringify({ head: e.head, tail: e.tail, verdict, oracleFile, oracleName }) + '\n'); } out.end(); await new Promise((resolve, reject) => { out.on('finish', resolve); out.on('error', reject); }); const scored = correct + incorrect; const precision = scored ? correct / scored : null; console.error(`ts-oracle: ${edges.length} TS/JS CALLS edges checked`); console.error(` correct=${correct} incorrect=${incorrect} unknown=${unknown} external=${external}`); console.error(` precision (correct/(correct+incorrect)) = ${precision === null ? 'n/a (no scored edges)' : precision.toFixed(3)}`); console.error(' per-tier breakdown:'); for (const tier of [...tierStats.keys()].sort((a, b) => Number(b) - Number(a))) { const s = tierStats.get(tier); const tScored = s.correct + s.incorrect; const tPrecision = tScored ? s.correct / tScored : null; console.error(` tier ${tier}: correct=${s.correct} incorrect=${s.incorrect} unknown=${s.unknown} external=${s.external} precision=${tPrecision === null ? 'n/a' : tPrecision.toFixed(3)}`); } } main().catch((e) => { console.error(e.stack || e.message || e); process.exit(1); });