mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-16 23:43:12 +00:00
* 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>
1099 lines
42 KiB
TypeScript
1099 lines
42 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
}
|