`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>
299 lines
9.0 KiB
TypeScript
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`;
|
|
}
|
|
}
|