codenavCli.ts
Rama main · réplica de lectura del repositorio original.
/**
* `codenav`: a language server's answers without a language server. each call is one short process in
* the agent's own sandbox, TypeScript through the compiler API and Python through jedi, over only the
* files the query needs: the target's imports for `def`, and the files `git grep` finds the name in for
* the rest. nothing stays resident between calls. bundled to `dist/codenav.mjs` and put on the shell's
* PATH by `utils/codenav.ts`; see wiki/codenav.md.
*/
import { execFileSync, spawnSync } from "node:child_process";
import {
appendFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, extname, join, relative, resolve, sep } from "node:path";
import type * as TS from "typescript";
const OPS = ["def", "impl", "refs", "callers"] as const;
type Op = (typeof OPS)[number];
const USAGE = `usage: codenav <def|impl|refs|callers> <file>:<line>[:<col>] [<symbol>]
def where the symbol is defined
impl implementations of an interface or abstract member (TypeScript)
refs references to the symbol
callers functions that call it
covers TypeScript/JavaScript and Python; <file> is relative to the current directory.`;
const TS_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
const PY_EXTENSIONS = [".py", ".pyi"];
const MAX_HITS = 200;
const NEAR_LINES = 10;
const CACHE = process.env.CODENAV_CACHE || join(tmpdir(), "codenav");
type Query = {
op: Op;
file: string;
/** zero-based places on the given line the symbol may be, tried in order */
positions: Position[];
/** the same on the lines nearest it, tried when nothing answered and the given line has no code match */
nearby: Position[];
/** lines from the given line to the nearest one with the symbol's name, 0 when it has it */
shift: number;
symbol: string;
root: string;
};
type Position = { line: number; column: number };
/** zero-based; `caller` is set for `callers`, empty at module scope */
type Hit = { file: string; line: number; column: number; caller?: string | undefined };
/** call-hierarchy kinds for a file's top level, whose callers print no name */
const MODULE_KINDS: string[] = ["module", "script"];
function parse(argv: string[]): Query {
const op = OPS.find((candidate) => candidate === argv[0]);
if (!op)
throw new Error(`${argv[0] ? `unknown operation "${argv[0]}"` : "no operation"}\n${USAGE}`);
const target = /^(.+?):(\d+)(?::(\d+))?$/.exec(argv[1] ?? "");
if (!target?.[1] || !target[2]) throw new Error(`expected <file>:<line>\n${USAGE}`);
if (!target[3] && !argv[2]) throw new Error(`give a <symbol> or a :<col>\n${USAGE}`);
const file = resolve(target[1]);
const ext = extname(file);
if (!TS_EXTENSIONS.includes(ext) && !PY_EXTENSIONS.includes(ext)) {
throw new Error(`no navigation for ${ext || "extensionless"} files`);
}
if (op === "impl" && PY_EXTENSIONS.includes(ext)) throw new Error("impl is TypeScript-only");
const lines = readFileSync(file, "utf8").split("\n");
const given = Number(target[2]) - 1;
if (lines[given] === undefined) throw new Error(`${target[1]} has no line ${target[2]}`);
const found = target[3]
? { positions: [{ line: given, column: Number(target[3]) - 1 }], nearby: [] }
: symbolPositions(lines, given, argv[2] ?? "");
const first = found.positions[0] ?? found.nearby[0];
if (!first) {
throw new Error(
`"${argv[2]}" is not within ${NEAR_LINES} lines of ${target[1]}:${target[2]}: ${lines[given].trim()}`
);
}
const lineText = lines[first.line];
// a column inside an identifier names all of it, which is what `git grep -w` has to find
const start = lineText.slice(0, first.column).search(/[\w$]*$/);
const symbol = /^[\w$]+/.exec(lineText.slice(start))?.[0];
if (!symbol) throw new Error(`no identifier at ${argv[1]}`);
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: dirname(file),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return { op, file, ...found, shift: first.line - given, symbol, root };
}
/**
* the symbol's matches on the line, and on the lines nearest it, above first: agents pass a line a
* few off the symbol's (by up to 8 in the second window).
*/
function symbolPositions(lines: string[], line: number, symbol: string) {
const on = (candidate: number) =>
symbolColumns(lines[candidate] ?? "", symbol).map((column) => ({ line: candidate, column }));
const nearby = Array.from({ length: NEAR_LINES }, (_, i) => [
...on(line - i - 1),
...on(line + i + 1),
]);
return { positions: on(line), nearby: nearby.flat() };
}
/** every whole-identifier match of the symbol's last segment, so `client.request` finds `request` */
function symbolColumns(lineText: string, symbol: string): number[] {
const name = symbol.split(".").pop() ?? "";
const columns: number[] = [];
// `indexOf("")` never advances past the end of the line
if (!name) return columns;
const identifier = /[\w$]/;
for (let i = lineText.indexOf(name); i !== -1; i = lineText.indexOf(name, i + 1)) {
const before = lineText[i - 1] ?? "";
const after = lineText[i + name.length] ?? "";
if (!identifier.test(before) && !identifier.test(after)) columns.push(i);
}
return columns;
}
/**
* one program per tsconfig, as tsserver keeps one per project: a caller in another package of a
* monorepo reaches the target through its own package's paths and export conditions, which the
* target's options would resolve to a `.d.ts` instead. the target's own project goes first.
*/
function typescript(query: Query): Hit[] {
const ts = loadTypescript(query.file);
const projects = new Map([[findConfig(ts, query.file), new Set([query.file])]]);
for (const file of query.op === "def" ? [] : grepFiles(query, TS_EXTENSIONS)) {
const configPath = findConfig(ts, file);
projects.set(configPath, (projects.get(configPath) ?? new Set([query.file])).add(file));
}
const hits: Hit[] = [];
for (const [configPath, roots] of projects) {
hits.push(...askProject(ts, { query, configPath, roots: [...roots] }));
if (hits.length > MAX_HITS) break;
}
return hits;
}
function findConfig(ts: typeof TS, file: string): string | undefined {
const dir = dirname(file);
return (
ts.findConfigFile(dir, ts.sys.fileExists) ??
ts.findConfigFile(dir, ts.sys.fileExists, "jsconfig.json")
);
}
function askProject(
ts: typeof TS,
params: { query: Query; configPath: string | undefined; roots: string[] }
): Hit[] {
const query = params.query;
const config = params.configPath
? ts.getParsedCommandLineOfConfigFile(
params.configPath,
{},
{
...ts.sys,
onUnRecoverableConfigFileDiagnostic: () => {},
}
)
: undefined;
const service = ts.createLanguageService(
{
// a JavaScript target or candidate loads even where the project leaves allowJs off
getCompilationSettings: () => ({ ...config?.options, allowJs: true }),
getScriptFileNames: () => params.roots,
getScriptVersion: () => "0",
getScriptSnapshot: (name) => {
const text = ts.sys.readFile(name);
return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
},
getCurrentDirectory: () => dirname(params.configPath ?? query.file),
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: ts.sys.fileExists,
// a symlink out of node_modules is a workspace package, which would otherwise load as a second
// copy; one within it is a dependency, whose own (megabytes of `@octokit/types`) is not needed
realpath: (path) => {
const real = ts.sys.realpath?.(path) ?? path;
return real.includes(`${sep}node_modules${sep}`) ? path : real;
},
readFile: ts.sys.readFile,
readDirectory: ts.sys.readDirectory,
directoryExists: ts.sys.directoryExists,
getDirectories: ts.sys.getDirectories,
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
},
ts.createDocumentRegistry()
);
const source = service.getProgram()?.getSourceFile(query.file);
if (!source) throw new Error(`TypeScript could not load ${query.file}`);
const hit = (fileName: string, start: number, caller?: string): Hit => {
const at = service.getProgram()?.getSourceFile(fileName)?.getLineAndCharacterOfPosition(start);
return { file: fileName, line: at?.line ?? 0, column: at?.character ?? 0, caller };
};
const position = (at: Position) => source.getPositionOfLineAndCharacter(at.line, at.column);
// a comment's or a string's text says nothing about the symbol
const inText = (at: Position) => {
const offset = position(at);
const deepest = (node: TS.Node): TS.Node | undefined =>
node.getStart(source) <= offset && offset < node.getEnd()
? (ts.forEachChild(node, deepest) ?? node)
: undefined;
const node = deepest(source) ?? source;
return (
service.getSpanOfEnclosingComment(query.file, offset, false) !== undefined ||
ts.isStringLiteral(node) ||
ts.isTemplateLiteralToken(node) ||
ts.isJsxText(node)
);
};
const firstHits = (positions: Position[]) => {
for (const at of positions) {
const hits = askTypescript(service, {
op: query.op,
file: query.file,
position: position(at),
hit,
});
if (hits.length) return hits;
}
return [];
};
// code first, so a string's copies, which TypeScript lists as its references, do not hide a real match
const hits = firstHits(
[...query.positions].sort((a, b) => Number(inText(a)) - Number(inText(b)))
);
// code on the given line answers even with no results, or unresolved, as a call on an uninstalled
// package's type is: a namesake nearby must not answer for it
if (hits.length || !query.positions.every(inText)) return hits;
return firstHits(query.nearby.filter((at) => !inText(at)));
}
function askTypescript(
service: TS.LanguageService,
params: {
op: Op;
file: string;
position: number;
hit: (fileName: string, start: number, caller?: string) => Hit;
}
): Hit[] {
const at = (span: { fileName: string; textSpan: TS.TextSpan }) =>
params.hit(span.fileName, span.textSpan.start);
switch (params.op) {
case "def":
return (service.getDefinitionAtPosition(params.file, params.position) ?? []).map(at);
case "impl":
return (service.getImplementationAtPosition(params.file, params.position) ?? []).map(at);
case "refs":
return (service.findReferences(params.file, params.position) ?? []).flatMap((symbol) =>
symbol.references.filter((ref) => !ref.isDefinition).map(at)
);
case "callers":
return [
// an import specifier has no call hierarchy of its own, so it answers for what it imports
service.prepareCallHierarchy(params.file, params.position) ??
(service.getDefinitionAtPosition(params.file, params.position) ?? [])
// a namespace import's definition is its module, whose start is whatever the file opens with
.filter((target) => !MODULE_KINDS.includes(target.kind))
.flatMap((target) =>
[service.prepareCallHierarchy(target.fileName, target.textSpan.start) ?? []].flat()
),
]
.flat()
.flatMap((item) =>
service.provideCallHierarchyIncomingCalls(item.file, item.selectionSpan.start)
)
.flatMap((call) =>
call.fromSpans.map((span) =>
params.hit(
call.from.file,
span.start,
MODULE_KINDS.includes(call.from.kind) ? "" : call.from.name
)
)
);
default:
return params.op satisfies never;
}
}
/** the repository's own TypeScript when it has 4–6, else a pinned 5.9: TS7 has no JS API and drops `baseUrl` */
function loadTypescript(file: string): typeof TS {
try {
const ts: typeof TS = createRequire(file)("typescript");
const major = Number(ts.version.split(".")[0]);
if (major >= 4 && major <= 6) return ts;
} catch {
// not installed: a review checkout rarely has its dependencies
}
const dir = installOnce("typescript", (staging) =>
run("npm", [
"install",
"--prefix",
staging,
"--no-save",
"--no-audit",
"--no-fund",
"--ignore-scripts",
"--loglevel=error",
"typescript@5.9.3",
])
);
return createRequire(join(dir, "package.json"))("typescript");
}
/**
* jedi, rather than pyright: pyright answers only as a server, and its cold start is the cost this
* avoids. jedi's own reference search stops after 30 parsed files, so references are found the way
* TypeScript's are: `git grep` for the name, then jedi resolves each occurrence.
*/
function python(query: Query): Hit[] {
const dir = installOnce("jedi", (staging) => {
run("python3", ["-m", "venv", staging]);
run(join(staging, "bin", "python"), [
"-m",
"pip",
"install",
"--quiet",
"--disable-pip-version-check",
"jedi==0.19.2",
]);
});
const lines = readFileSync(query.file, "utf8").split("\n");
// jedi counts code points where JavaScript counts UTF-16 units, which an emoji before a match tells apart
const codePoints = (at: Position) => ({
line: at.line,
column: [...lines[at.line].slice(0, at.column)].length,
});
const output = run(join(dir, "bin", "python"), [
"-c",
JEDI,
query.op,
query.file,
JSON.stringify(query.positions.map(codePoints)),
JSON.stringify(query.nearby.map(codePoints)),
query.symbol,
query.root,
String(MAX_HITS + 1),
]);
return output
.split("\n")
.filter(Boolean)
.map((line) => {
const hit: { file: string; line: number; column: number; caller: string | null } =
JSON.parse(line);
return {
file: hit.file,
line: hit.line - 1,
column: hit.column,
caller: hit.caller ?? undefined,
};
});
}
const JEDI = `
import json, re, subprocess, sys
import jedi, parso
op, path, given, nearby, symbol, root, limit = sys.argv[1:]
given, nearby = json.loads(given), json.loads(nearby)
project = jedi.Project(root)
scripts = {}
def script(file):
if file not in scripts:
scripts[file] = jedi.Script(path=file, project=project)
return scripts[file]
def emit(file, line, column, caller=None):
print(json.dumps({"file": file, "line": line, "column": column, "caller": caller}))
def resolve(positions):
for at in positions:
targets = [d for d in script(path).goto(at["line"] + 1, at["column"], follow_imports=True) if d.module_path]
if targets:
return targets
return []
def is_name(tree, at):
# the leaf at a name's first column is the token before it, so look one in
leaf = tree.get_leaf_for_position((at["line"] + 1, at["column"] + 1))
return leaf is not None and leaf.type == "name"
targets = resolve(given)
if not targets:
# a comment's or a string's text says nothing about the symbol, but a name that resolves to nothing must not answer for a namesake nearby
tree = parso.parse(open(path, encoding="utf-8", errors="replace").read())
if any(is_name(tree, at) for at in given):
sys.exit(0)
targets = resolve(nearby)
if not targets:
sys.exit(0)
if op == "def":
for d in targets:
emit(str(d.module_path), d.line, d.column)
sys.exit(0)
keys = {(str(d.module_path), d.line, d.column) for d in targets}
grep = subprocess.run(["git", "grep", "-n", "-z", "-w", "-F", "--untracked", "-e", symbol, "--", "*.py", "*.pyi"], cwd=root, capture_output=True, text=True, errors="replace").stdout
found = 0
for entry in grep.splitlines():
rel, at, text = entry.split("\\0", 2)
file, at = f"{root}/{rel}", int(at)
for match in re.finditer(rf"\\b{re.escape(symbol)}\\b", text):
if (file, at, match.start()) in keys:
continue
# a call or a decorator, as TypeScript's call hierarchy counts them; an import or an assignment is neither
if op == "callers" and not text[match.end():].lstrip().startswith("(") and not re.search(r"@[\\w.]*$", text[:match.start()]):
continue
try:
if not any((str(d.module_path), d.line, d.column) in keys for d in script(file).goto(at, match.start(), follow_imports=True)):
continue
context = script(file).get_context(at, match.start()) if op == "callers" else None
except Exception:
continue
emit(file, at, match.start(), None if context is None else "" if context.type == "module" else context.name)
found += 1
if found >= int(limit):
sys.exit(0)
`;
function grepFiles(query: Query, extensions: string[]): string[] {
const result = spawnSync(
"git",
[
"grep",
"-l",
"-z",
"-w",
"-F",
"--untracked",
"-e",
query.symbol,
"--",
...extensions.map((e) => `*${e}`),
],
{ cwd: query.root, encoding: "utf8" }
);
return result.stdout
.split("\0")
.filter(Boolean)
.map((path) => join(query.root, path));
}
/**
* installs into the run's cache once, staging and renaming so a call killed mid-install (the shell
* tool's timeout) leaves nothing half-made, and two calls racing both end up using one copy
*/
function installOnce(name: string, build: (staging: string) => void): string {
const dir = join(CACHE, name);
if (existsSync(dir)) return dir;
// not the pid: each sandboxed shell command is its own PID namespace, so concurrent calls can share one
mkdirSync(CACHE, { recursive: true });
const staging = mkdtempSync(`${dir}.`);
build(staging);
try {
renameSync(staging, dir);
} catch {
// another call finished first
rmSync(staging, { recursive: true, force: true });
}
return dir;
}
function run(command: string, argv: string[]): string {
const result = spawnSync(command, argv, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (result.status === 0) return result.stdout;
const detail = (result.stderr || result.error?.message || "")
.trim()
.split("\n")
.slice(-10)
.join("\n");
throw new Error(`${command} ${argv[0]} failed: ${detail}`);
}
function dedupe(hits: Hit[]): Hit[] {
const seen = new Set<string>();
return hits.filter((hit) => {
const key = `${hit.file}:${hit.line}:${hit.column}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** grep-like `path:line:col: [caller: ]source`; paths outside the repository stay absolute */
function format(query: Query, hits: Hit[]): string {
const files = new Map<string, string[]>();
const lines = hits.slice(0, MAX_HITS).map((hit) => {
let text = files.get(hit.file);
if (!text) {
text = existsSync(hit.file) ? readFileSync(hit.file, "utf8").split("\n") : [];
files.set(hit.file, text);
}
const path = hit.file.startsWith(query.root + sep)
? relative(process.cwd(), hit.file)
: hit.file;
const caller = hit.caller ? `${hit.caller}: ` : "";
return `${path}:${hit.line + 1}:${hit.column + 1}: ${caller}${(text[hit.line] ?? "").trim()}\n`;
});
const more = hits.length > MAX_HITS ? `codenav: first ${MAX_HITS} shown\n` : "";
return lines.join("") + more;
}
/** a runner short of memory kills this process (and jedi under it) before the agent */
function deprioritize(): void {
if (process.platform !== "linux") return;
try {
writeFileSync("/proc/self/oom_score_adj", "1000");
} catch {
// raising it needs no privilege, but a sandbox may mount /proc read-only
}
}
/** one line per call, which the action logs at the end of the run */
function record(): void {
try {
mkdirSync(CACHE, { recursive: true });
appendFileSync(
join(CACHE, "calls"),
`${args[0] ?? ""} exited ${exitCode} in ${Date.now() - started}ms${shift ? ` (line ${shift > 0 ? "+" : ""}${shift})` : ""}\n`
);
} catch {
// telemetry never fails the command
}
}
// last, so every declaration above is initialized when it runs
const started = Date.now();
const args = process.argv.slice(2);
let exitCode = 2;
let shift = 0;
try {
deprioritize();
const query = parse(args);
shift = query.shift;
if (shift) {
process.stderr.write(
`codenav: "${query.symbol}" is not on ${args[1]}; using the nearest lines that have it\n`
);
}
const hits = dedupe(
PY_EXTENSIONS.includes(extname(query.file)) ? python(query) : typescript(query)
);
exitCode = hits.length ? 0 : 1;
if (hits.length) process.stdout.write(format(query, hits));
else process.stderr.write("codenav: no results\n");
} catch (error) {
process.stderr.write(`codenav: ${error instanceof Error ? error.message : String(error)}\n`);
}
record();
process.exitCode = exitCode;