packages/code-indexer/bench/ts-oracle.mjs
Rama main · réplica de lectura del repositorio original.
#!/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 <repoPath> <edges.jsonl> [--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 <repoPath> <edges.jsonl> [--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<T>(...)`).
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); });