Files
codegraph/src/context/formatter.ts
T
Colby McHenryandClaude Opus 5 16e17495f4 feat(extraction): content-based generated-file detection (CG-5, #1500)
`isGeneratedFile` was path-only, but Go's own convention is a CONTENT
marker (`// Code generated by <tool>. DO NOT EDIT.`), not a filename one.
A Go monorepo with generated CRUD in ordinarily-named files sitting beside
hand-written use-cases was therefore invisible to every generated-file
down-rank in the codebase — that is #1500.

Measured on kubernetes/client-go (2,453 Go files): the canonical banner
appears in 2,001 of them, the path check flags 0, the new content check
flags exactly those 2,001 — no false positives, no misses.

Design: decide at INDEX time (content is already in memory for parsing),
persist on `files.generated`, read from the DB. Explore never reads file
headers per request.

- `hasGeneratedHeader(content)` recognizes the standard banners — Go's,
  protoc's, `@generated`, `<auto-generated>`, Thrift, OpenAPI Generator,
  FlatBuffers, bindgen, ANTLR. Precision-first and fenced three ways: an
  8KB/60-line header window, a comment-line requirement (leader or open
  block comment), and markers tight enough that prose can't trip them. A
  generator's own source, holding the banner as a string constant in its
  body, is not flagged; neither is this module itself (pinned by test).
- `isGeneratedFile(path)` is unchanged — cheap, sync, still the fallback.
- Schema v9 adds `files.generated` + a PARTIAL index. DDL only, no
  backfill: the flag derives from content the migration cannot see, so
  rows stay 0 until a re-index and every reader unions the flag with the
  path check — an un-migrated index keeps pre-#1500 behavior rather than
  regressing. Re-index required; noted in the CHANGELOG.
- `generatedPredicateFor(paths)` gives ranking a bounded probe + O(1)
  lookups. Bounded, not cached: no invalidation, so a ranking call can
  never serve a verdict the last sync already replaced. Wired into explore
  ranking, findSymbolMatches, findAllSymbols, search (MCP + CLI), the
  context formatter, and the dominant-file/route-file hygiene filters.

Cost (acceptance bar was no measurable index-time regression): a single
unanchored `/generat/i` test over the header rejects ~every hand-written
file before any line splitting. 4.6 µs/file on client-go (worst case —
82% generated). End-to-end `codegraph init` on client-go, n=3 alternating
arms: 5.73s median with detection vs 5.76s path-only baseline; the arms
cross over between runs, so the difference is inside run-to-run noise.

Scope note: generated status remains a stable TIEBREAK at equal score,
exactly where it was. Making it a strong negative signal is CG-10, which
this unblocks by making the signal correct and available.

Two pre-existing tests hard-coded schema version 8; both now track
CURRENT_SCHEMA_VERSION (or the migration table) so future migrations
don't require editing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:13:59 -05:00

299 lines
9.0 KiB
TypeScript

/**
* Context Formatter
*
* Formats TaskContext as markdown or JSON for consumption by Claude.
*/
import { Node, Edge, TaskContext, Subgraph } from '../types';
import { isGeneratedFile } from '../extraction/generated-detection';
/**
* Format context as markdown
*
* Creates a compact markdown document optimized for Claude with minimal context usage:
* - Brief summary
* - Entry points with locations
* - Code blocks only for key symbols
*/
export function formatContextAsMarkdown(
context: TaskContext,
/**
* Generated-file test. Defaults to the filename convention alone; the
* ContextBuilder passes a DB-backed predicate so files flagged by their
* HEADER at index time (#1500) demote here too.
*/
isGenerated: (filePath: string) => boolean = isGeneratedFile
): string {
const lines: string[] = [];
// Header with query
lines.push('## Code Context\n');
lines.push(`**Query:** ${context.query}\n`);
// Entry points - compact format. Re-sort so generated files (.pb.go,
// .pulsar.go, mocks, …) rank LAST — a flow query should lead with the
// hand-written implementation, not protobuf scaffolding.
const orderedEntries = [...context.entryPoints].sort((a, b) => {
const aGen = isGenerated(a.filePath) ? 1 : 0;
const bGen = isGenerated(b.filePath) ? 1 : 0;
return aGen - bGen;
});
if (orderedEntries.length > 0) {
lines.push('### Entry Points\n');
for (const node of orderedEntries) {
const location = node.startLine ? `:${node.startLine}` : '';
lines.push(`- **${node.name}** (${node.kind}) - ${node.filePath}${location}`);
if (node.signature) {
lines.push(` \`${node.signature}\``);
}
}
lines.push('');
}
// Related symbols - compact list (skip verbose structure tree). Drop nodes
// in generated source files (`.pb.go` / `.pulsar.go` / mocks / …) — agents
// chasing a flow never want to land on protobuf scaffolding (cosmos-Q3 used
// to list `gov.pulsar.go::GetExpeditedThreshold` and `1.pulsar.go::Get` in
// Related Symbols, pure noise that displaced real-flow entries).
const otherSymbols = Array.from(context.subgraph.nodes.values())
.filter(n => !context.entryPoints.some(e => e.id === n.id))
.filter(n => !isGenerated(n.filePath))
.slice(0, 10); // Limit to 10 related symbols
if (otherSymbols.length > 0) {
lines.push('### Related Symbols\n');
const byFile = new Map<string, Node[]>();
for (const node of otherSymbols) {
const existing = byFile.get(node.filePath) || [];
existing.push(node);
byFile.set(node.filePath, existing);
}
for (const [file, nodes] of byFile) {
const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
lines.push(`- ${file}: ${nodeList}`);
}
lines.push('');
}
// Code blocks - only for key entry points. Re-sort so non-generated blocks
// show first (consistent with Entry Points reordering above).
if (context.codeBlocks.length > 0) {
const orderedBlocks = [...context.codeBlocks].sort((a, b) => {
const aGen = isGenerated(a.filePath) ? 1 : 0;
const bGen = isGenerated(b.filePath) ? 1 : 0;
return aGen - bGen;
});
lines.push('### Code\n');
for (const block of orderedBlocks) {
const nodeName = block.node?.name ?? 'Unknown';
lines.push(`#### ${nodeName} (${block.filePath}:${block.startLine})\n`);
lines.push('```' + block.language);
lines.push(block.content);
lines.push('```\n');
}
}
return lines.join('\n');
}
/**
* Format context as JSON
*
* Returns a structured JSON representation suitable for programmatic use.
*/
export function formatContextAsJson(context: TaskContext): string {
// Convert Map to array for JSON serialization
const serializable = {
query: context.query,
summary: context.summary,
entryPoints: context.entryPoints.map(serializeNode),
nodes: Array.from(context.subgraph.nodes.values()).map(serializeNode),
edges: context.subgraph.edges.map(serializeEdge),
codeBlocks: context.codeBlocks.map((block) => ({
filePath: block.filePath,
startLine: block.startLine,
endLine: block.endLine,
language: block.language,
content: block.content,
nodeName: block.node?.name,
nodeKind: block.node?.kind,
})),
relatedFiles: context.relatedFiles,
stats: context.stats,
};
return JSON.stringify(serializable, null, 2);
}
/**
* Format a subgraph as an ASCII tree structure
*/
export function formatSubgraphTree(subgraph: Subgraph, entryPoints: Node[]): string {
const lines: string[] = [];
const printed = new Set<string>();
// Build adjacency list for outgoing edges
const outgoing = new Map<string, Edge[]>();
for (const edge of subgraph.edges) {
const existing = outgoing.get(edge.source) ?? [];
existing.push(edge);
outgoing.set(edge.source, existing);
}
// Print each entry point as a tree root
for (const entry of entryPoints) {
formatNodeTree(entry, subgraph, outgoing, printed, lines, 0, '');
lines.push(''); // Blank line between trees
}
// Print any remaining nodes not reached from entry points
const remaining: Node[] = [];
for (const node of subgraph.nodes.values()) {
if (!printed.has(node.id)) {
remaining.push(node);
}
}
if (remaining.length > 0 && remaining.length <= 10) {
lines.push('Other relevant symbols:');
for (const node of remaining) {
const location = node.startLine ? `:${node.startLine}` : '';
lines.push(` ${node.kind}: ${node.name} (${node.filePath}${location})`);
}
} else if (remaining.length > 10) {
lines.push(`... and ${remaining.length} more related symbols`);
}
return lines.join('\n').trim();
}
/**
* Format a single node and its relationships
*/
function formatNodeTree(
node: Node,
subgraph: Subgraph,
outgoing: Map<string, Edge[]>,
printed: Set<string>,
lines: string[],
depth: number,
prefix: string
): void {
if (printed.has(node.id)) {
return;
}
printed.add(node.id);
// Node header
const location = node.startLine ? `:${node.startLine}` : '';
const signature = node.signature ? ` - ${truncate(node.signature, 50)}` : '';
lines.push(`${prefix}${node.kind}: ${node.name} (${node.filePath}${location})${signature}`);
// Outgoing edges
const edges = outgoing.get(node.id) ?? [];
const significantEdges = edges.filter((e) =>
['calls', 'extends', 'implements', 'imports', 'references'].includes(e.kind)
);
// Group by kind
const edgesByKind = new Map<string, Edge[]>();
for (const edge of significantEdges) {
const existing = edgesByKind.get(edge.kind) ?? [];
existing.push(edge);
edgesByKind.set(edge.kind, existing);
}
// Print edges grouped by kind
const newPrefix = prefix + ' ';
for (const [kind, kindEdges] of edgesByKind) {
if (kindEdges.length > 3) {
// Summarize if too many
const names = kindEdges
.slice(0, 3)
.map((e) => {
const target = subgraph.nodes.get(e.target);
return target?.name ?? 'unknown';
})
.join(', ');
lines.push(`${newPrefix}├── ${kind}: ${names} and ${kindEdges.length - 3} more`);
} else {
for (let i = 0; i < kindEdges.length; i++) {
const edge = kindEdges[i]!;
const target = subgraph.nodes.get(edge.target);
const targetName = target?.name ?? 'unknown';
const connector = i === kindEdges.length - 1 ? '└──' : '├──';
lines.push(`${newPrefix}${connector} ${kind}${targetName}`);
}
}
}
// Recurse for directly connected nodes (limited depth)
if (depth < 1) {
for (const edge of significantEdges.slice(0, 3)) {
const target = subgraph.nodes.get(edge.target);
if (target && !printed.has(target.id)) {
formatNodeTree(target, subgraph, outgoing, printed, lines, depth + 1, newPrefix);
}
}
}
}
/**
* Serialize a node for JSON output
*/
function serializeNode(node: Node): Record<string, unknown> {
return {
id: node.id,
kind: node.kind,
name: node.name,
qualifiedName: node.qualifiedName,
filePath: node.filePath,
language: node.language,
startLine: node.startLine,
endLine: node.endLine,
signature: node.signature,
docstring: node.docstring,
visibility: node.visibility,
isExported: node.isExported,
isAsync: node.isAsync,
isStatic: node.isStatic,
};
}
/**
* Serialize an edge for JSON output
*/
function serializeEdge(edge: Edge): Record<string, unknown> {
return {
source: edge.source,
target: edge.target,
kind: edge.kind,
line: edge.line,
column: edge.column,
};
}
/**
* Truncate a string with ellipsis
*/
function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) {
return str;
}
return str.slice(0, maxLength - 3) + '...';
}
/**
* Format bytes as human-readable string
*/
export function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} bytes`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
} else {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}