GitNexus/gitnexus/src/cli/eval-server.ts
Gergő Magyar 54f97c86c7
fix(impact): make File risk comparable via shared axes (#3075) (#3082)
* docs(plans): add impact file risk plan

Capture the evidence, constraints, and verification path for fixing incomparable File and symbol impact risk.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(impact): centralize risk scoring

Keep the existing thresholds in one shared scorer and expose a common-axis comparison for targets with unavailable enrichment axes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(impact): expose incomparable file risk scale

Mark File impact results when process and module axes are unavailable, and provide a common-axis score for honest cross-kind comparisons.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(impact): explain cross-kind risk comparisons

Surface the common-axis score in CLI and agent guidance while reusing the shared threshold ladder in the web impact tool.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(impact): fail closed when enrichment is incomplete

Preserve proved HIGH/CRITICAL process counts, treat failed queries as UNKNOWN, and surface riskScale metadata on MCP, group, CLI, and Graph-RAG File walks.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 11:58:15 +00:00

1099 lines
42 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Eval Server — Lightweight HTTP server for SWE-bench evaluation
*
* Keeps LadybugDB warm in memory so tool calls from the agent are near-instant.
* Designed to run inside Docker containers during SWE-bench evaluation.
*
* KEY DESIGN: Returns LLM-friendly text, not raw JSON.
* Raw JSON wastes tokens and is hard for models to parse. The text formatter
* converts structured results into compact, readable output that models
* can immediately act on. Next-step hints guide the agent through a
* productive tool-chaining workflow (query → context → impact → fix).
*
* Architecture:
* Agent bash cmd → curl localhost:PORT/tool/query → eval-server → LocalBackend → format → text
*
* Usage:
* gitnexus eval-server # default port 4848, binds 127.0.0.1
* gitnexus eval-server --port 4848 # explicit port
* GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host 0.0.0.0
* GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host devbox.local
* gitnexus eval-server --idle-timeout 300 # auto-shutdown after 300s idle
*
* READY signal format: GITNEXUS_EVAL_SERVER_READY:<host>:<port>
* IPv4: GITNEXUS_EVAL_SERVER_READY:127.0.0.1:4848
* IPv6: GITNEXUS_EVAL_SERVER_READY:[::1]:4848
*
* API:
* POST /tool/:name — Call a tool. Body is JSON arguments. Returns formatted text.
* GET /health — Health check. Returns {"status":"ok","repos":[...]}
* POST /shutdown — Graceful shutdown.
*/
import http from 'http';
import crypto from 'node:crypto';
import { lookup } from 'node:dns/promises';
import { isIPv4, isIPv6 } from 'node:net';
import { readFileSync, writeSync } from 'node:fs';
import path from 'node:path';
import { parseEnv } from 'node:util';
import {
LocalBackend,
type RepoListing,
type ListReposPagination,
} from '../mcp/local/local-backend.js';
import { logger } from '../core/logger.js';
import { cliInfo, cliWarn, cliError } from './cli-message.js';
import { formatDetectChangesResult } from './detect-changes-format.js';
import { formatSymbolLine } from './format-symbol.js';
export { formatDetectChangesResult } from './detect-changes-format.js';
export interface EvalServerOptions {
port?: string;
host?: string;
idleTimeout?: string;
}
/**
* Validate the --host value. Accepts IPv4, IPv6, or "localhost".
* Returns the host string unchanged, or null if invalid.
* "localhost" is passed through so the OS resolves it to the correct loopback
* address (127.0.0.1 or ::1) at bind time rather than forcing IPv4.
*/
export function validateHost(raw: string): string | null {
if (raw === 'localhost') return raw;
if (isIPv4(raw) || isIPv6(raw)) return raw;
return null;
}
type EvalServerHostLookup = (hostname: string) => Promise<string>;
function isHostname(raw: string): boolean {
if (!raw || raw.length > 253 || /^[\d.]+$/.test(raw)) return false;
return raw
.split('.')
.every(
(label) =>
label.length > 0 &&
label.length <= 63 &&
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label),
);
}
/** Resolve a DNS bind name once so validation and listen() use the same concrete address. */
export async function resolveEvalServerBindHost(
raw: string,
resolveHostname: EvalServerHostLookup = async (hostname) =>
(await lookup(hostname, { family: 4 })).address,
): Promise<string | null> {
const directHost = validateHost(raw);
if (directHost && directHost !== 'localhost') return directHost;
if (directHost !== 'localhost' && !isHostname(raw)) return null;
try {
const address = await resolveHostname(raw);
return isIPv4(address) ? address : null;
} catch {
return null;
}
}
function readAuthTokenFile(filePath: string): string | undefined {
try {
return parseEnv(readFileSync(filePath, 'utf8')).GITNEXUS_AUTH_TOKEN?.trim() || undefined;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw new Error(`Unable to read eval-server authentication from ${filePath}`, { cause: error });
}
}
/** Resolve the bearer token from the shell, then .env.local, then .env. */
export function resolveEvalServerAuthToken(
env: NodeJS.ProcessEnv,
cwd: string = process.cwd(),
): string | undefined {
if (Object.hasOwn(env, 'GITNEXUS_AUTH_TOKEN')) {
return env.GITNEXUS_AUTH_TOKEN?.trim() || undefined;
}
return (
readAuthTokenFile(path.join(cwd, '.env.local')) ?? readAuthTokenFile(path.join(cwd, '.env'))
);
}
/** True only for literal loopback addresses; DNS names are resolved before this check. */
export function isEvalServerLoopbackHost(host: string): boolean {
return host === 'localhost' || host === '::1' || (isIPv4(host) && host.startsWith('127.'));
}
/**
* Resolve the bearer token for a concrete bind host. An unreadable `.env` /
* `.env.local` only matters when the binding actually requires a token, so
* loopback binds degrade to a warning instead of refusing to start; any
* non-loopback bind keeps the fail-closed error.
*/
export function resolveEvalServerAuthTokenForHost(
host: string,
env: NodeJS.ProcessEnv,
cwd: string = process.cwd(),
): { token?: string; warning?: string } {
try {
return { token: resolveEvalServerAuthToken(env, cwd) };
} catch (error) {
if (isEvalServerLoopbackHost(host)) {
const reason = error instanceof Error ? error.message : String(error);
return { warning: `${reason} Continuing without authentication on loopback host ${host}.` };
}
throw error;
}
}
/** Refuse exposure of the eval-server query surface without authentication. */
export function assertSecureEvalServerBinding(host: string, authToken: string | undefined): void {
if (!authToken && !isEvalServerLoopbackHost(host)) {
throw new Error(
`Refusing to start eval-server on non-loopback host ${host} without authentication. ` +
'Set GITNEXUS_AUTH_TOKEN or bind to 127.0.0.1, localhost, or ::1.',
);
}
}
/** Validate the exact Bearer header while keeping token comparison constant-time. */
export function isEvalServerBearerAuthorized(
authorization: string | string[] | undefined,
authToken: string | undefined,
): boolean {
if (!authToken) return true;
const expected = Buffer.from(`Bearer ${authToken}`, 'utf8');
const supplied = typeof authorization === 'string' ? Buffer.from(authorization, 'utf8') : null;
const sameLength = supplied?.length === expected.length;
const candidate = sameLength && supplied ? supplied : Buffer.alloc(expected.length);
return crypto.timingSafeEqual(candidate, expected) && sameLength;
}
// ─── Text Formatters ──────────────────────────────────────────────────
// Convert structured JSON results into compact, LLM-friendly text.
// Design: minimize tokens, maximize actionability.
export function formatQueryResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const lines: string[] = [];
const processes = result.processes || [];
const symbols = result.process_symbols || [];
const defs = result.definitions || [];
if (processes.length === 0 && defs.length === 0) {
return 'No matching execution flows found. Try a different search term or use grep.';
}
lines.push(`Found ${processes.length} execution flow(s):\n`);
for (let i = 0; i < processes.length; i++) {
const p = processes[i];
lines.push(`${i + 1}. ${p.summary} (${p.step_count} steps, ${p.symbol_count} symbols)`);
// Show symbols belonging to this process
const procSymbols = symbols.filter((s: any) => s.process_id === p.id);
for (const s of procSymbols.slice(0, 6)) {
const loc = s.startLine ? `:${s.startLine}` : '';
lines.push(` ${s.type} ${s.name}${s.filePath}${loc}`);
}
if (procSymbols.length > 6) {
lines.push(` ... and ${procSymbols.length - 6} more`);
}
lines.push('');
}
if (defs.length > 0) {
lines.push(`Standalone definitions:`);
for (const d of defs.slice(0, 8)) {
lines.push(formatSymbolLine(d.type, d.name, d.filePath));
}
if (defs.length > 8) lines.push(` ... and ${defs.length - 8} more`);
}
return lines.join('\n').trim();
}
export function formatContextResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
if (result.status === 'ambiguous') {
const lines = [
`Multiple symbols named '${result.candidates?.[0]?.name || '?'}'. Disambiguate with file path:\n`,
];
for (const c of result.candidates || []) {
lines.push(` ${c.kind} ${c.name}${c.filePath}:${c.line || '?'} (uid: ${c.uid})`);
}
lines.push(`\nRe-run: gitnexus-context "${result.candidates?.[0]?.name}" "<file_path>"`);
return lines.join('\n');
}
const sym = result.symbol;
if (!sym) return 'Symbol not found.';
const lines: string[] = [];
const loc = sym.startLine ? `:${sym.startLine}-${sym.endLine}` : '';
lines.push(`${sym.kind} ${sym.name}${sym.filePath}${loc}`);
lines.push('');
// Incoming refs (who calls/imports/extends this)
const incoming = result.incoming || {};
const incomingCount = Object.values(incoming).reduce(
(sum: number, arr: any) => sum + arr.length,
0,
) as number;
if (incomingCount > 0) {
lines.push(`Called/imported by (${incomingCount}):`);
for (const [relType, refs] of Object.entries(incoming)) {
for (const ref of (refs as any[]).slice(0, 10)) {
lines.push(` ← [${relType}] ${ref.kind} ${ref.name}${ref.filePath}`);
}
}
lines.push('');
}
// Outgoing refs (what this calls/imports)
const outgoing = result.outgoing || {};
const outgoingCount = Object.values(outgoing).reduce(
(sum: number, arr: any) => sum + arr.length,
0,
) as number;
if (outgoingCount > 0) {
lines.push(`Calls/imports (${outgoingCount}):`);
for (const [relType, refs] of Object.entries(outgoing)) {
for (const ref of (refs as any[]).slice(0, 10)) {
lines.push(` → [${relType}] ${ref.kind} ${ref.name}${ref.filePath}`);
}
}
lines.push('');
}
// Processes
const procs = result.processes || [];
if (procs.length > 0) {
lines.push(`Participates in ${procs.length} execution flow(s):`);
for (const p of procs) {
lines.push(`${p.name} (step ${p.step_index}/${p.step_count})`);
}
}
if (sym.content) {
lines.push('');
lines.push(`Source:`);
lines.push(sym.content);
}
return lines.join('\n').trim();
}
function formatTruncationSuffix(result: {
truncatedBy?: unknown;
truncatedByReasons?: unknown;
}): string {
const label = Array.isArray(result.truncatedByReasons)
? result.truncatedByReasons.join(', ')
: typeof result.truncatedBy === 'string'
? result.truncatedBy
: '';
return label ? ` (by ${label})` : '';
}
function pushCallgraphRiskLines(lines: string[], result: any): void {
if (result.risk) {
lines.push(`Risk: ${result.risk}`);
}
if (result.riskNote) {
lines.push(String(result.riskNote));
}
if (result.riskScale?.comparableAcrossKinds === false && result.riskSharedAxes) {
lines.push(
`Shared-axes risk: ${result.riskSharedAxes} (process/module axes are unavailable — compare File vs symbol only; do not use this to waive a HIGH/CRITICAL risk warning)`,
);
}
}
export function formatImpactResult(result: any): string {
if (result.error) {
const suggestion = result.suggestion ? `\nSuggestion: ${result.suggestion}` : '';
return `Error: ${result.error}${suggestion}`;
}
const target = result.target;
const direction = result.direction;
const byDepth = result.byDepth || {};
const total = result.impactedCount || 0;
// #2129 — an ambiguous bare name must not print the "isolated / safe to
// refactor" headline. Surface the per-candidate blast radius + the maximum,
// mirroring formatContextResult, so the real impact under whichever symbol the
// caller meant is visible on the text surface, not just in the JSON.
if (result.status === 'ambiguous') {
if (result.mode === 'pdg') {
const shown = result.candidates?.length ?? 0;
const totalCandidates = result.totalCandidates ?? shown;
const countPhrase =
totalCandidates > shown
? `${totalCandidates} symbols (showing ${shown})`
: `${totalCandidates} symbols`;
const lines = [
`${target?.name || '?'}: AMBIGUOUS — ${countPhrase} share this name. ` +
`PDG impact was not computed until the target is disambiguated. ` +
`Use --uid, file_path, or kind for one authoritative PDG result.`,
];
if (result.message) lines.push(String(result.message));
for (const c of result.candidates || []) {
const score = typeof c.score === 'number' ? ` score ${c.score}` : '';
lines.push(
` ${c.kind} ${c.name}${c.filePath}:${c.line || '?'}${score} (uid: ${c.uid})`,
);
}
return lines.join('\n');
}
// #2129 review F11 — report the FULL match count (`totalCandidates`), not the
// truncated `candidates[]` length; note when the candidate list is capped.
const shown = result.candidates?.length ?? 0;
const total = result.totalCandidates ?? shown;
const countPhrase = total > shown ? `${total} symbols (showing ${shown})` : `${total} symbols`;
const lines = [
`${target?.name || '?'}: AMBIGUOUS — ${countPhrase} share this name. ` +
`Max blast radius ${result.maxImpactedCount ?? 0} (${result.maxRisk ?? 'UNKNOWN'} risk). ` +
`Disambiguate with --uid for one authoritative result:`,
];
for (const c of result.candidates || []) {
lines.push(
` ${c.kind} ${c.name}${c.filePath}:${c.line || '?'} ` +
`[${c.impactedCount ?? 0} ${direction}, risk ${c.risk ?? 'UNKNOWN'}] (uid: ${c.uid})`,
);
}
// #2129 review F1 — a failed per-candidate probe makes the max a lower bound.
if (result.partialProbe) {
lines.push(
' ⚠️ One or more candidate probes failed — max blast radius / risk are lower bounds.',
);
}
return lines.join('\n');
}
// ─── PDG mode (mode:'pdg') ────────────────────────────────────────────
// KTD8 presentation half. PDG results are intra-procedural Program
// Dependence Graph blast radii: the single collapsed `byDepth[1]` bucket
// has NO call-hop depth meaning (block-hops ≠ call-hops), so we must NOT
// reuse the callgraph "depth N / WILL BREAK (direct)" framing, the
// callgraph DI/dynamic-dispatch lower-bound copy, or the confident
// "isolated" zero. A degraded / no-body PDG result is INCONCLUSIVE, not
// safe-to-refactor — it gets the explicit caveat + remediation, never an
// empty blast radius. Detect on `mode:'pdg'` (every PDG return path —
// findings, degradation, no-body, no-dependence — carries it). Ambiguous
// PDG results carry `status:'ambiguous'` and are handled above; they never
// reach here.
if (result.mode === 'pdg') {
const name = target?.name || '?';
const appendPdgInterproceduralSymbols = (lines: string[]): boolean => {
const byDepth =
result.interproceduralByDepth || result.pdgInterprocedural?.byDepth || result.byDepth || {};
const byDepthCounts =
result.interproceduralByDepthCounts ||
result.pdgInterprocedural?.byDepthCounts ||
result.byDepthCounts ||
{};
const depthKeys = Array.from(
new Set([...Object.keys(byDepthCounts), ...Object.keys(byDepth)]),
)
.map((d) => Number(d))
.filter((d) => Number.isFinite(d))
.sort((a, b) => a - b);
const hasReach = depthKeys.some((depth) => {
const items = byDepth[depth] || byDepth[String(depth)] || [];
const count = byDepthCounts[depth] ?? byDepthCounts[String(depth)] ?? items.length;
return count > 0;
});
if (!hasReach) return false;
const totalSymbols =
result.pdgInterprocedural?.impactedCount ??
(typeof result.impactedCount === 'number' ? result.impactedCount : 0);
lines.push('');
lines.push(`Inter-procedural symbol reach (${totalSymbols}):`);
for (const depth of depthKeys) {
const items = byDepth[depth] || byDepth[String(depth)] || [];
const count = byDepthCounts[depth] ?? byDepthCounts[String(depth)] ?? items.length;
if (count <= 0) continue;
lines.push(` d=${depth} (${count})`);
const shown = Math.min(items.length, 12);
for (const item of items.slice(0, shown)) {
const flags: string[] = [];
if (item.unresolved) flags.push('unresolved');
if (item.ambiguous) flags.push('ambiguous');
const flagStr = flags.length ? ` [${flags.join(', ')}]` : '';
lines.push(` ${item.type || ''} ${item.name}${item.filePath}${flagStr}`);
}
if (count > shown) lines.push(` ... and ${count - shown} more`);
}
return true;
};
// (1) Degradation — the PDG layer (or a sub-layer) is absent/unreadable.
// `pdgLayer` is the non-'ready' state from `pdgLayerStatus`. Print the
// honest remediation, NOT a zero/empty blast radius.
if (result.pdgLayer) {
const subLayer = result.missingSubLayer
? ` (missing sub-layer: ${result.missingSubLayer})`
: '';
return (
`${name}: PDG impact unavailable — the index has no usable PDG layer ` +
`[${result.pdgLayer}]${subLayer}. This is NOT "no impact". ` +
`Re-index with \`gitnexus analyze --pdg\` to build the control/data ` +
`dependence layer, or use \`--mode callgraph\` for the call-graph blast radius.` +
(result.note ? `\n${result.note}` : '')
);
}
// (2) No-body symbol (KTD6) — interface / type alias / abstract / ambient
// member / one-line declaration with no CFG. Show the caveat, never
// "isolated / no dependencies".
if (result.epistemic === 'no-pdg-body') {
const noBodyLines = [
`${name}: local PDG slice not applicable to this symbol — it has no PDG body ` +
`(no control/data dependence edges; e.g. an interface, type alias, ` +
`abstract/ambient member, or a one-line declaration). This is NOT a ` +
`confident "no impact".`,
];
appendPdgInterproceduralSymbols(noBodyLines);
if (result.note) noBodyLines.push(result.note);
return noBodyLines.join('\n');
}
// (2b) STATEMENT-ANCHORED SLICE (mode:'pdg' + line). When `criterionLine` is
// present the result is a statement slice: the seeded line plus the list of
// dependent statements (`affectedStatements: {line,filePath,text}[]`). Render
// those statements directly — this IS the useful output of statement mode —
// rather than the symbol-projection bucket below. Empty cases:
// - `pdg-no-block-at-line`: the line is blank / a comment / outside the
// body (no statement block) — print the steering note.
// - empty `affectedStatements` with `pdg-intra-procedural`: the line has no
// dependents in this direction — print the steering note.
// Each non-empty case also surfaces truncation honestly.
if (typeof result.criterionLine === 'number') {
const slice: any[] = Array.isArray(result.affectedStatements)
? result.affectedStatements
: [];
const count =
typeof result.affectedStatementCount === 'number'
? result.affectedStatementCount
: slice.length;
// File anchor for the heading — the seeded statement's file (every slice
// statement shares the function's file). Fall back to the target's file.
const anchorFile = slice[0]?.filePath || target?.filePath || name;
if (count === 0 || slice.length === 0) {
// No statement block at the line, or no dependents in this direction.
// Print the honest note (pdg-no-block-at-line or the no-dependence note)
// verbatim — never an empty "isolated" headline.
const emptySliceLines = [
`No statements ${direction}-dependent on ${anchorFile}:${result.criterionLine}.`,
];
if (result.truncated) {
const by = formatTruncationSuffix(result);
emptySliceLines.push(
`⚠️ Truncated${by} — the dependence slice was bounded; deeper PDG-dependent statements may exist.`,
);
}
appendPdgInterproceduralSymbols(emptySliceLines);
if (result.note) emptySliceLines.push(result.note);
return emptySliceLines.join('\n');
}
const slLines: string[] = [];
slLines.push(
`Statements ${direction}-dependent on ${anchorFile}:${result.criterionLine} (${count}):`,
);
for (const s of slice) {
const text = typeof s.text === 'string' ? s.text : '';
slLines.push(` L${s.line}: ${text}`);
}
// Truncation honesty — the slice may be a lower bound (depth or per-step
// LIMIT bound). Surface it the same way the symbol render does.
if (result.truncated) {
const by = formatTruncationSuffix(result);
slLines.push(
`⚠️ Truncated${by} — the dependence slice was bounded; deeper PDG-dependent statements may exist.`,
);
}
appendPdgInterproceduralSymbols(slLines);
if (result.note) {
slLines.push('');
slLines.push(` ${result.note}`);
}
return slLines.join('\n').trim();
}
const pdgLines: string[] = [];
if (!appendPdgInterproceduralSymbols(pdgLines)) {
pdgLines.push(
`${name} (${direction}): no inter-procedural symbols reached. ` +
`The local PDG statement slice may still report affectedStatements when seeded with line:<N>.`,
);
}
// The assembled note carries the local-PDG framing plus the unified
// inter-procedural symbol-reach contract; surface it verbatim so the CLI
// reader sees the same honesty the JSON consumer does.
if (result.note) {
pdgLines.push('');
pdgLines.push(` ${result.note}`);
} else {
pdgLines.push('');
pdgLines.push(
' Program Dependence Graph result — statement reach is reported in affectedStatements and inter-procedural symbol reach in interproceduralByDepth/byDepth.',
);
}
// Honest incompleteness signals (block-attribution + truncation).
if (result.ambiguousProjectionCount > 0) {
pdgLines.push(
`⚠️ ${result.ambiguousProjectionCount} block(s) could not be attributed to a ` +
`unique owning symbol (same-line functions) — all colliding symbols are shown.`,
);
}
if (result.unresolvedBlockCount > 0) {
pdgLines.push(
`⚠️ ${result.unresolvedBlockCount} dependence block(s) map to no owning ` +
`Function/Method/Constructor (top-level statement / closure) — surfaced under their file.`,
);
}
if (result.truncated) {
const by = formatTruncationSuffix(result);
pdgLines.push(
`⚠️ Truncated${by} — the dependence traversal was bounded; deeper PDG impacts may exist.`,
);
}
return pdgLines.join('\n').trim();
}
if (total === 0) {
// #1858 — "isolated" is a confident claim. If an interface / indirection
// boundary is on the path, the true count is a lower bound, not zero;
// callers binding via DI / dynamic dispatch were not traced. Say so instead.
const lines: string[] = [];
if (result.epistemic === 'lower-bound') {
lines.push(
`${target?.name || '?'}: no direct ${direction} dependencies traced, but this is a LOWER BOUND — unresolved indirection on the path (actual impact may be higher):`,
);
for (const b of result.boundaries || []) lines.push(`${b}`);
} else if (direction === 'upstream') {
lines.push(
`${target?.name || '?'}: No ${direction} callers resolved. This is not evidence the symbol is unused or isolated.`,
);
} else {
lines.push(`${target?.name || '?'}: No ${direction} dependencies found.`);
}
pushCallgraphRiskLines(lines, result);
return lines.join('\n');
}
const lines: string[] = [];
const dirLabel =
direction === 'upstream' ? 'depends on this (will break if changed)' : 'this depends on';
lines.push(
`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}`,
);
if (result.partial) {
lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
}
// #1858 — an interface / indirection boundary on the path makes this a lower
// bound; surface it so the count is not read as exhaustive.
if (result.epistemic === 'lower-bound') {
lines.push(
'⚠️ Lower bound — unresolved indirection on the path (callers binding via DI / dynamic dispatch are not traced; actual impact may be higher):',
);
for (const b of result.boundaries || []) lines.push(`${b}`);
}
pushCallgraphRiskLines(lines, result);
lines.push('');
const depthLabels: Record<number, string> = {
1: 'WILL BREAK (direct)',
2: 'LIKELY AFFECTED (indirect)',
3: 'MAY NEED TESTING (transitive)',
};
if (!result.byDepth && result.byDepthCounts) {
lines.push('(summary only — use summaryOnly: false to see symbol lists)');
const depthCounts = result.byDepthCounts;
for (const depth of [1, 2, 3]) {
const count = depthCounts[depth] ?? 0;
if (count === 0) continue;
lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${count})`);
}
lines.push('');
} else {
const depthCounts = result.byDepthCounts || {};
for (const depth of [1, 2, 3]) {
const items = byDepth[depth] || [];
const trueCount = depthCounts[depth] ?? items.length;
if (trueCount === 0) continue;
lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${trueCount})`);
if (items.length === 0) {
lines.push(` (0 items on this page — adjust offset)`);
} else {
const shown = Math.min(items.length, 12);
for (const item of items.slice(0, shown)) {
const conf = item.confidence < 1 ? ` (conf: ${item.confidence})` : '';
lines.push(
` ${item.type} ${item.name}${item.filePath} [${item.relationType}]${conf}`,
);
}
if (trueCount > shown) {
lines.push(` ... and ${trueCount - shown} more`);
}
}
lines.push('');
}
}
return lines.join('\n').trim();
}
export function formatCypherResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
if (Array.isArray(result)) {
if (result.length === 0) return 'Query returned 0 rows.';
// Format as simple table
const keys = Object.keys(result[0]);
const lines: string[] = [`${result.length} row(s):\n`];
for (const row of result.slice(0, 30)) {
const parts = keys.map((k) => `${k}: ${row[k]}`);
lines.push(` ${parts.join(' | ')}`);
}
if (result.length > 30) {
lines.push(` ... ${result.length - 30} more rows`);
}
return lines.join('\n');
}
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
export function formatListReposResult(result: {
repositories: RepoListing[];
pagination?: ListReposPagination;
}): string {
// `list_repos` always returns the paginated { repositories, pagination } object (#2119).
const repos = result.repositories;
const pg = result.pagination;
if (repos.length === 0) {
return pg && pg.total > 0
? `No repositories on this page (offset ${pg.offset} of ${pg.total} total).`
: 'No indexed repositories.';
}
const lines = ['Indexed repositories:\n'];
for (const r of repos) {
const stats = r.stats || {};
lines.push(
` ${r.name}${stats.nodes || '?'} symbols, ${stats.edges || '?'} relationships, ${stats.processes || '?'} flows`,
);
lines.push(` Path: ${r.path}`);
lines.push(` Indexed: ${r.indexedAt}`);
}
if (pg) {
lines.push('');
lines.push(
` Showing ${repos.length} of ${pg.total} (offset ${pg.offset}).` +
(pg.hasMore ? ` More available — re-run with offset ${pg.nextOffset}.` : ''),
);
}
return lines.join('\n');
}
/**
* Format a tool result as compact, LLM-friendly text.
*/
function formatToolResult(toolName: string, result: any): string {
switch (toolName) {
case 'query':
return formatQueryResult(result);
case 'context':
return formatContextResult(result);
case 'impact':
return formatImpactResult(result);
case 'cypher':
return formatCypherResult(result);
case 'detect_changes':
return formatDetectChangesResult(result);
case 'list_repos':
return formatListReposResult(result);
default:
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
}
// ─── Next-Step Hints ──────────────────────────────────────────────────
// Guide the agent to the logical next tool call.
// Critical for tool chaining: query → context → impact → fix.
export function getNextStepHint(toolName: string, result?: any): string {
switch (toolName) {
case 'query':
return '\n---\nNext: Pick a symbol above and run gitnexus-context "<name>" to see all its callers, callees, and execution flows.';
case 'context':
return '\n---\nNext: To check what breaks if you change this, run gitnexus-impact "<name>" upstream';
case 'impact':
if (
result?.error ||
result?.status === 'ambiguous' ||
result?.mode === 'pdg' ||
result?.pdgLayer ||
typeof result?.criterionLine === 'number'
) {
return '';
}
return '\n---\nNext: Review d=1 items first (WILL BREAK). Read the source with cat to understand the code, then make your fix.';
case 'cypher':
return '\n---\nNext: To explore a result symbol in depth, run gitnexus-context "<name>"';
case 'detect_changes':
return '\n---\nNext: Run gitnexus-context "<symbol>" on high-risk changed symbols to check their callers.';
case 'list_repos':
return '\n---\nNext: READ gitnexus://repo/{name}/context for a repo above. If pagination.hasMore is true, re-run list_repos with offset set to pagination.nextOffset to page through the rest.';
default:
return '';
}
}
// ─── Server ───────────────────────────────────────────────────────────
export async function evalServerCommand(options?: EvalServerOptions): Promise<void> {
const port = parseInt(options?.port || '4848');
const idleTimeoutSec = parseInt(options?.idleTimeout || '0');
const rawHost = options?.host ?? '127.0.0.1';
const host = await resolveEvalServerBindHost(rawHost);
if (!host) {
cliError(
`Invalid --host value "${rawHost}":\n` +
` Must be an IP address or a hostname that resolves to a local IPv4 address.\n\n` +
` Examples:\n` +
` gitnexus eval-server --host 127.0.0.1 (loopback only, default)\n` +
` GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host 0.0.0.0\n` +
` GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host 192.168.1.5\n` +
` gitnexus eval-server --host localhost (resolved IPv4 loopback)\n` +
` GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host devbox.local\n`,
{ flag: '--host', value: rawHost },
);
process.exit(1);
}
let authToken: string | undefined;
try {
const resolved = resolveEvalServerAuthTokenForHost(host, process.env);
authToken = resolved.token;
if (resolved.warning) cliWarn(resolved.warning);
} catch (error) {
cliError(
error instanceof Error
? error.message
: 'Unable to read eval-server authentication configuration.',
);
process.exit(1);
}
try {
assertSecureEvalServerBinding(host, authToken);
} catch (error) {
cliError(error instanceof Error ? error.message : 'Refusing insecure eval-server binding.', {
host,
});
process.exit(1);
}
const backend = new LocalBackend();
const ok = await backend.init();
if (!ok) {
// Operator-actionable but the server cannot start; warn-level so log
// aggregators don't trip error alerts on a configuration miss. Use
// cliWarn so the diagnostic reaches stderr synchronously before
// process.exit() — direct logger.warn would be lost to the buffered
// pino destination on hard exit (skips beforeExit flush).
cliWarn('GitNexus eval-server: No indexed repositories found. Run: gitnexus analyze');
process.exit(1);
}
const repos = await backend.listRepos();
logger.info(
{ repoCount: repos.length, repos: repos.map((r) => r.name) },
'GitNexus eval-server: repos loaded',
);
let idleTimer: ReturnType<typeof setTimeout> | null = null;
function resetIdleTimer() {
if (idleTimeoutSec <= 0) return;
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(async () => {
logger.info({ idleTimeoutSec }, 'GitNexus eval-server: idle timeout reached, shutting down');
await backend.disconnect();
process.exit(0);
}, idleTimeoutSec * 1000);
}
// Startup-generated shutdown token: a `POST /shutdown` must present it in the
// X-Shutdown-Token header. The local agent that launches the server reads it
// from the GITNEXUS_EVAL_SERVER_SHUTDOWN_TOKEN line on fd 1 (next to the READY
// signal); a client on another VM under `--host 0.0.0.0` cannot guess it, so it
// can no longer kill the server. (SIGINT/SIGTERM and the idle timeout still
// shut down locally without a token.)
const shutdownToken = crypto.randomBytes(24).toString('hex');
const server = http.createServer(async (req, res) => {
if (!isEvalServerBearerAuthorized(req.headers.authorization, authToken)) {
res.setHeader('Content-Type', 'application/json');
res.setHeader('WWW-Authenticate', 'Bearer');
res.writeHead(401);
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
resetIdleTimer();
try {
// Health check
if (req.method === 'GET' && req.url === '/health') {
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify({ status: 'ok', repos: repos.map((r) => r.name) }));
return;
}
// Shutdown
if (req.method === 'POST' && req.url === '/shutdown') {
if (req.headers['x-shutdown-token'] !== shutdownToken) {
res.setHeader('Content-Type', 'application/json');
res.writeHead(403);
res.end(JSON.stringify({ error: 'forbidden: missing or invalid X-Shutdown-Token' }));
return;
}
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify({ status: 'shutting_down' }));
setTimeout(async () => {
await backend.disconnect();
server.close();
process.exit(0);
}, 100);
return;
}
// Tool calls: POST /tool/:name
const toolMatch = req.url?.match(/^\/tool\/(\w+)$/);
if (req.method === 'POST' && toolMatch) {
const toolName = toolMatch[1];
if (!EVAL_SERVER_TOOLS.has(toolName)) {
res.setHeader('Content-Type', 'text/plain');
res.writeHead(400);
res.end(
`Error: unsupported tool '${toolName}'. Supported: ${[...EVAL_SERVER_TOOLS].sort().join(', ')}`,
);
return;
}
const body = await readBody(req);
let args: Record<string, any> = {};
if (body.trim()) {
try {
args = JSON.parse(body);
} catch {
res.setHeader('Content-Type', 'text/plain');
res.writeHead(400);
res.end('Error: Invalid JSON body');
return;
}
}
// Call tool, format result as text, append next-step hint
const result = await backend.callTool(toolName, args);
const formatted = formatToolResult(toolName, result);
const hint = getNextStepHint(toolName, result);
res.setHeader('Content-Type', 'text/plain');
res.writeHead(200);
res.end(formatted + hint);
return;
}
// 404
res.setHeader('Content-Type', 'text/plain');
res.writeHead(404);
res.end('Not found. Use POST /tool/:name or GET /health');
} catch (err: any) {
res.setHeader('Content-Type', 'text/plain');
res.writeHead(500);
res.end(`Error: ${err.message || 'Internal error'}`);
}
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
cliError(
`\nGitNexus eval-server failed to start:\n` +
` Port ${port} is already in use.\n\n` +
` Either:\n` +
` 1. Stop the process already using port ${port}\n` +
` 2. Use a different port: gitnexus eval-server --port 4849\n`,
{ code: err.code, port, host },
);
} else if (err.code === 'EADDRNOTAVAIL') {
// "localhost" may resolve to ::1 on IPv6-only systems; treat it as
// potentially IPv6 so the user gets the right diagnostic hint.
const isIPv6Host = isIPv6(host) || host === 'localhost';
cliError(
`\nGitNexus eval-server failed to start:\n` +
` Address ${host} is not available on this machine.\n\n` +
(isIPv6Host
? ` Address ${host} resolved but is not reachable — IPv6 may be disabled, or the loopback interface may be unavailable.\n` +
` Docker containers and many CI environments disable IPv6 by default.\n\n`
: ` The --host value must be an IP assigned to a local network interface.\n` +
` Run \`ip addr\` (Linux) or \`ipconfig\` (Windows) to list available addresses.\n\n`) +
` Common fixes:\n` +
` gitnexus eval-server --host 127.0.0.1 (loopback, this machine only)\n` +
` GITNEXUS_AUTH_TOKEN=... gitnexus eval-server --host 0.0.0.0\n`,
{ code: err.code, port, host },
);
} else if (err.code === 'EACCES') {
cliError(
`\nGitNexus eval-server failed to start:\n` +
` Permission denied binding to port ${port}.\n\n` +
` Ports below 1024 require elevated privileges.\n` +
` Use a port above 1024: gitnexus eval-server --port 4848\n`,
{ code: err.code, port, host },
);
} else {
cliError(`\nGitNexus eval-server failed to start:\n ${err.message}\n`, {
code: err.code,
port,
host,
});
}
process.exit(1);
});
server.listen(port, host, () => {
// Plain-text banner for the human watching stderr; structured record
// for log aggregation (split into two so the user sees a real banner
// not `{"level":30,"msg":"...","port":4747,"endpoints":[...]}`).
// Use server.address() so the banner and READY signal reflect what the OS
// actually bound to, not the input host string. This matters when "localhost"
// is passed: the OS may resolve it to ::1 on some systems.
const addr = server.address();
// server.listen callback only fires after a successful TCP bind, so
// server.address() is guaranteed to return an AddressInfo object here.
if (typeof addr !== 'object' || addr === null) {
cliError(
`\nGitNexus eval-server: unexpected server.address() value after bind: ${JSON.stringify(addr)}\n`,
);
process.exit(1);
}
const boundPort = addr.port;
const boundAddress = addr.address;
const displayHost = boundAddress.includes(':') ? `[${boundAddress}]` : boundAddress;
const bannerLines = [
`GitNexus eval-server: listening on http://${displayHost}:${boundPort}`,
` POST /tool/query — search execution flows`,
` POST /tool/context — 360-degree symbol view`,
` POST /tool/impact — blast radius analysis`,
` POST /tool/cypher — raw Cypher query`,
` GET /health — health check`,
` POST /shutdown — graceful shutdown`,
];
if (authToken) {
bannerLines.push(' Bearer authentication enabled');
}
if (idleTimeoutSec > 0) {
bannerLines.push(` Auto-shutdown after ${idleTimeoutSec}s idle`);
}
cliInfo(bannerLines.join('\n'), {
port: boundPort,
host,
idleTimeoutSec: idleTimeoutSec > 0 ? idleTimeoutSec : undefined,
authEnabled: Boolean(authToken),
endpoints: [
'POST /tool/query',
'POST /tool/context',
'POST /tool/impact',
'POST /tool/cypher',
'GET /health',
'POST /shutdown',
],
});
try {
// Use fd 1 directly — LadybugDB captures process.stdout (#324)
writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${displayHost}:${boundPort}\n`);
// The launching agent reads this to authorize POST /shutdown.
writeSync(1, `GITNEXUS_EVAL_SERVER_SHUTDOWN_TOKEN:${shutdownToken}\n`);
} catch {
// stdout may not be available (e.g., broken pipe)
}
});
resetIdleTimer();
const shutdown = async () => {
logger.info('GitNexus eval-server: shutting down...');
await backend.disconnect();
server.close();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
/**
* Tools the eval-server exposes over HTTP — the read-only query surface the
* banner advertises. `LocalBackend.callTool` ALSO dispatches write-side / heavier
* tools (rename, shape_check, tool_map, …); the allowlist keeps a stray
* `POST /tool/<name>` from reaching those through this Docker/eval-harness server.
*/
export const EVAL_SERVER_TOOLS: ReadonlySet<string> = new Set([
'query',
'context',
'impact',
'cypher',
'detect_changes',
'list_repos',
]);
export const MAX_BODY_SIZE = 1024 * 1024; // 1MB
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalSize = 0;
req.on('data', (chunk: Buffer) => {
totalSize += chunk.length;
if (totalSize > MAX_BODY_SIZE) {
req.destroy(new Error('Request body too large (max 1MB)'));
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}