From c5198cf49ce70b0821d6408cc08d70cee75fd76b Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 14:48:20 +0200 Subject: [PATCH 01/11] feat: add source-backed architecture context tool --- docs/tools.md | 9 +-- src/adapters/mcp/register-tools.ts | 17 +++++ src/adapters/opencode/tools.ts | 15 +++++ src/adapters/pi/extension.ts | 18 +++++ src/tools/architecture-context.ts | 105 +++++++++++++++++++++++++++++ src/tools/contracts.ts | 8 +++ src/tools/execute-common.ts | 11 +++ src/tools/operations.ts | 28 +++++++- src/tools/tool-names.ts | 4 ++ tests/architecture-context.test.ts | 32 +++++++++ 10 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 src/tools/architecture-context.ts create mode 100644 tests/architecture-context.test.ts diff --git a/docs/tools.md b/docs/tools.md index 04f1e204..ee1fa263 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -6,11 +6,11 @@ Tool availability depends on the host mode. | Host / integration | Tool surface | Additional capabilities | |---|---|---| -| `opencode` (plugin) | 19 tools in total (15 portable tools + 3 knowledge-base tools + 1 OpenCode-native tool) | Slash commands and `index_visualize` | -| MCP clients, including `codex`, `claude`, and `jcode` | 18 tools + 5 prompts (15 portable tools + 3 knowledge-base tools) | Knowledge-base management for every MCP client; no OpenCode slash commands | -| `pi` (Pi extension) | 18 tools total (15 portable tools + 3 Pi knowledge-base tools) | Bundled `codebase-search` skill with host-specific knowledge-base names | +| `opencode` (plugin) | 20 tools in total (16 portable tools + 3 knowledge-base tools + 1 OpenCode-native tool) | Slash commands and `index_visualize` | +| MCP clients, including `codex`, `claude`, and `jcode` | 19 tools + 5 prompts (16 portable tools + 3 knowledge-base tools) | Knowledge-base management for every MCP client; no OpenCode slash commands | +| `pi` (Pi extension) | 19 tools total (16 portable tools + 3 Pi knowledge-base tools) | Bundled `codebase-search` skill with host-specific knowledge-base names | -### Portable MCP core (15 tools) +### Portable MCP core (16 tools) These tools are available through the MCP server and the OpenCode plugin. @@ -28,6 +28,7 @@ These tools are available through the MCP server and the OpenCode plugin. - `call_graph` - `call_graph_path` - `pr_impact` +- `architecture_context` - `code_communities` The MCP server also exposes five prompts: diff --git a/src/adapters/mcp/register-tools.ts b/src/adapters/mcp/register-tools.ts index 00e3ade2..c55a48ed 100644 --- a/src/adapters/mcp/register-tools.ts +++ b/src/adapters/mcp/register-tools.ts @@ -37,6 +37,7 @@ import { executeCodebaseContext, executeCodebaseEditContext, executeCodeCommunities, + executeArchitectureContext, executeIndexCodebase, executeIndexHealthCheck, executeIndexLogs, @@ -373,6 +374,22 @@ export function registerMcpTools(server: McpServer, runtime: McpServerRuntime): }, ); + server.tool( + TOOL_NAME.ARCHITECTURE_CONTEXT, + "Repository-scale architecture map backed by cited graph symbols and relationships. Use before focused retrieval when module boundaries or entry points are needed.", + { + query: allowNullAsUndefined(z.string().optional()).describe("Optional subsystem or planning focus"), + directory: allowNullAsUndefined(z.string().optional()).describe("Constrain the map to this directory"), + depth: allowNullAsUndefined(z.number().int().min(1).max(3).optional().default(2)).describe("Summary detail level (1-3)"), + includeRecentActivity: allowNullAsUndefined(z.boolean().optional().default(false)).describe("Include recent activity when available"), + tokenBudget: allowNullAsUndefined(z.number().int().min(128).max(4000).optional().default(1200)).describe("Maximum response token budget"), + }, + async (args) => { + const result = await executeArchitectureContext(runtime.projectRoot, runtime.host, args); + return { content: [{ type: "text", text: result.text }] }; + }, + ); + server.tool( TOOL_NAME.CODE_COMMUNITIES, "Discover natural module boundaries and hub symbols using graph community detection. " + diff --git a/src/adapters/opencode/tools.ts b/src/adapters/opencode/tools.ts index 0c3b158a..d47a73ff 100644 --- a/src/adapters/opencode/tools.ts +++ b/src/adapters/opencode/tools.ts @@ -44,6 +44,7 @@ import { executeCodebaseContext, executeCodebaseEditContext, executeCodeCommunities, + executeArchitectureContext, executeIndexCodebase, executeIndexHealthCheck, executeIndexLogs, @@ -394,6 +395,20 @@ export const remove_knowledge_base: ToolDefinition = tool({ export { pr_impact }; +export const architecture_context: ToolDefinition = tool({ + description: "Repository-scale architecture map backed by cited graph symbols and relationships. Use before focused retrieval when you need module boundaries, entry points, and safe next steps.", + args: { + query: z.string().nullable().optional().describe("Optional subsystem or planning focus"), + directory: z.string().nullable().optional().describe("Constrain the map to this directory"), + depth: z.number().int().min(1).max(3).optional().default(2).describe("Summary detail level (1-3)"), + includeRecentActivity: z.boolean().optional().default(false).describe("Reserved for recent activity context"), + tokenBudget: z.number().int().min(128).max(4000).optional().default(1200).describe("Maximum response token budget"), + }, + async execute(args, context) { + return (await executeArchitectureContext(context?.worktree, DEFAULT_HOST, args)).text; + }, +}); + export const code_communities: ToolDefinition = tool({ description: "Discover natural module boundaries and hub symbols in the codebase using graph community detection. " + diff --git a/src/adapters/pi/extension.ts b/src/adapters/pi/extension.ts index 4358f5eb..2a1498e7 100644 --- a/src/adapters/pi/extension.ts +++ b/src/adapters/pi/extension.ts @@ -15,6 +15,7 @@ import { getPrImpact, getIndexerForProject, getCodeCommunities, + getArchitectureContext, implementationLookup, isExactSymbolQuery, listKnowledgeBases, @@ -403,6 +404,23 @@ export default function codebaseIndexPiExtension(pi: ExtensionAPI): void { }, }); + pi.registerTool({ + name: TOOL_NAME.ARCHITECTURE_CONTEXT, + label: "Architecture Context", + description: "Repository-scale architecture map with source-backed module, boundary, and hub evidence.", + parameters: Type.Object({ + query: Type.Optional(Type.String()), + directory: Type.Optional(Type.String()), + depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 3, default: 2 })), + includeRecentActivity: Type.Optional(Type.Boolean({ default: false })), + tokenBudget: Type.Optional(Type.Integer({ minimum: 128, maximum: 4000, default: 1200 })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const result = await getArchitectureContext(projectRoot(ctx), HOST, params); + return text(result.text, result); + }, + }); + pi.registerTool({ name: TOOL_NAME.CODE_COMMUNITIES, label: "Code Communities", diff --git a/src/tools/architecture-context.ts b/src/tools/architecture-context.ts new file mode 100644 index 00000000..fb28b93d --- /dev/null +++ b/src/tools/architecture-context.ts @@ -0,0 +1,105 @@ +import type { CentralityData, CommunityCouplingData, CommunityData, SymbolData } from "../native/index.js"; + +export const ARCHITECTURE_CONTEXT_DEFAULT_DEPTH = 2; +export const ARCHITECTURE_CONTEXT_MAX_DEPTH = 3; +export const ARCHITECTURE_CONTEXT_DEFAULT_TOKEN_BUDGET = 1200; +export const ARCHITECTURE_CONTEXT_MIN_TOKEN_BUDGET = 128; +export const ARCHITECTURE_CONTEXT_MAX_TOKEN_BUDGET = 4000; + +export interface ArchitectureContextInput { + query?: string | null; + directory?: string | null; + depth?: number; + includeRecentActivity?: boolean; + tokenBudget?: number; +} + +export interface ArchitectureContextResult { + modules: Array<{ id: number; label: string; symbolCount: number; evidence: Array<{ symbol: string; filePath: string }> }>; + boundaries: Array<{ fromModule: string; toModule: string; connections: number; evidence: Array<{ fromSymbol: string; fromFilePath: string; toSymbol: string; toFilePath: string }> }>; + hubs: Array<{ symbol: string; filePath: string; connections: number }>; + coverage: { symbols: number; communities: number; scoped: boolean; graphSparse: boolean; note: string }; + recommendations: string[]; + text: string; +} + +function compare(left: string, right: string): number { return left.localeCompare(right); } +function inDirectory(filePath: string, directory?: string): boolean { + if (!directory) return true; + const normalized = directory.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, ""); + const candidate = filePath.replace(/\\/g, "/"); + return candidate === normalized || candidate.startsWith(`${normalized}/`); +} + +export function buildArchitectureContext( + input: ArchitectureContextInput, + communities: CommunityData[], + centrality: CentralityData[], + couplings: CommunityCouplingData[], + focusedSymbols: SymbolData[] = [], +): ArchitectureContextResult { + const depth = Math.max(1, Math.min(ARCHITECTURE_CONTEXT_MAX_DEPTH, Math.floor(input.depth ?? ARCHITECTURE_CONTEXT_DEFAULT_DEPTH))); + const focusIds = new Set(focusedSymbols.map((symbol) => symbol.id)); + const hasFocus = focusIds.size > 0; + const scopedMembers = communities.filter((member) => inDirectory(member.filePath, input.directory ?? undefined) && (!hasFocus || focusIds.has(member.symbolId))); + const labels = new Map(communities.map((member) => [member.communityId, member.communityLabel])); + const byCommunity = new Map(); + for (const member of scopedMembers) { + const list = byCommunity.get(member.communityId) ?? []; + list.push(member); + byCommunity.set(member.communityId, list); + } + const moduleLimit = Math.max(1, Math.min(12, Math.floor((input.tokenBudget ?? ARCHITECTURE_CONTEXT_DEFAULT_TOKEN_BUDGET) / 180))); + const modules = [...byCommunity.entries()] + .map(([id, members]) => ({ + id, + label: labels.get(id) ?? `Community ${id}`, + symbolCount: members.length, + evidence: members.slice().sort((a, b) => compare(a.symbolName, b.symbolName) || compare(a.symbolId, b.symbolId)).slice(0, depth + 1).map((member) => ({ symbol: member.symbolName, filePath: member.filePath })), + })) + .sort((a, b) => b.symbolCount - a.symbolCount || compare(a.label, b.label) || a.id - b.id) + .slice(0, moduleLimit); + const selectedIds = new Set(modules.map((module) => module.id)); + const boundaries = couplings + .filter((coupling) => selectedIds.has(coupling.communityA) && selectedIds.has(coupling.communityB)) + .map((coupling) => ({ + fromModule: labels.get(coupling.communityA) ?? `Community ${coupling.communityA}`, + toModule: labels.get(coupling.communityB) ?? `Community ${coupling.communityB}`, + connections: coupling.count, + evidence: (coupling.relationships ?? coupling.representativeRelationships ?? []).slice().sort((a, b) => compare(a.fromSymbolId, b.fromSymbolId) || compare(a.toSymbolId, b.toSymbolId)).slice(0, depth).map((edge) => ({ fromSymbol: edge.fromSymbolName, fromFilePath: edge.fromFilePath, toSymbol: edge.toSymbolName, toFilePath: edge.toFilePath })), + })) + .sort((a, b) => b.connections - a.connections || compare(a.fromModule, b.fromModule) || compare(a.toModule, b.toModule)) + .slice(0, depth * 2); + const hubs = centrality + .filter((item) => inDirectory(item.filePath, input.directory ?? undefined) && (!hasFocus || focusIds.has(item.symbolId))) + .slice().sort((a, b) => b.totalConnections - a.totalConnections || compare(a.symbolId, b.symbolId)) + .slice(0, depth * 2) + .map((item) => ({ symbol: item.symbolName, filePath: item.filePath, connections: item.totalConnections })); + const graphSparse = scopedMembers.length === 0 || boundaries.length === 0; + const note = scopedMembers.length === 0 + ? "No graph symbols matched the requested scope. No architectural relationship is inferred." + : graphSparse + ? "No resolved cross-module coupling was available in this scope. Treat module boundaries as incomplete." + : "Module relationships are grounded in resolved representative call relationships."; + const recommendations = modules[0]?.evidence[0] + ? [`implementation_lookup for ${modules[0].evidence[0].symbol} in ${modules[0].evidence[0].filePath}`, `call_graph for ${modules[0].evidence[0].symbol}`] + : ["codebase_context with a narrower query or directory"]; + const lines = ["→ Architecture context", `Coverage: ${scopedMembers.length} graph symbols across ${byCommunity.size} communities${input.directory ? ` in ${input.directory}` : ""}.`, `Uncertainty: ${note}`]; + for (const module of modules) { + lines.push(`\nModule: ${module.label} (${module.symbolCount} scoped symbols)`); + for (const evidence of module.evidence) lines.push(`- ${evidence.symbol} (${evidence.filePath})`); + } + if (boundaries.length > 0) { + lines.push("\nBoundaries:"); + for (const boundary of boundaries) { + lines.push(`- ${boundary.fromModule} ↔ ${boundary.toModule}: ${boundary.connections} connections`); + for (const edge of boundary.evidence) lines.push(` - ${edge.fromSymbol} (${edge.fromFilePath}) -> ${edge.toSymbol} (${edge.toFilePath})`); + } + } + if (hubs.length > 0) { + lines.push("\nEntry points and hubs:"); + for (const hub of hubs) lines.push(`- ${hub.symbol} (${hub.filePath}), ${hub.connections} graph connections`); + } + lines.push("\nRecommended next steps:", ...recommendations.map((item) => `- ${item}`)); + return { modules, boundaries, hubs, coverage: { symbols: scopedMembers.length, communities: byCommunity.size, scoped: Boolean(input.directory || input.query), graphSparse, note }, recommendations, text: lines.join("\n") }; +} diff --git a/src/tools/contracts.ts b/src/tools/contracts.ts index a0169b0a..4b65d933 100644 --- a/src/tools/contracts.ts +++ b/src/tools/contracts.ts @@ -100,6 +100,14 @@ export interface SharedCallGraphPathArgs { maxDepth?: number; } +export interface SharedArchitectureContextArgs { + query?: string | null; + directory?: string | null; + depth?: number; + includeRecentActivity?: boolean; + tokenBudget?: number; +} + export interface SharedCodeCommunitiesArgs { branch?: string; minSize?: number; diff --git a/src/tools/execute-common.ts b/src/tools/execute-common.ts index 46188ca1..6471089f 100644 --- a/src/tools/execute-common.ts +++ b/src/tools/execute-common.ts @@ -4,6 +4,7 @@ import type { SharedCallGraphPathArgs, SharedCodebaseContextArgs, SharedCodebaseEditContextArgs, + SharedArchitectureContextArgs, SharedCodeCommunitiesArgs, SharedIndexCodebaseArgs, SharedIndexLogsArgs, @@ -14,6 +15,7 @@ import { getCallGraphData, getCallGraphPath, getCodeCommunities, + getArchitectureContext, getIndexLogs, getIndexMetrics, getIndexStatus, @@ -170,3 +172,12 @@ export async function executeCodeCommunities( const result = await getCodeCommunities(projectRoot, host, args); return { text: formatCodeCommunities(result) }; } + +export async function executeArchitectureContext( + projectRoot: string | undefined, + host: HostMode, + args: SharedArchitectureContextArgs, +): Promise { + const result = await getArchitectureContext(projectRoot, host, args); + return { text: result.text, details: result as unknown as Record }; +} diff --git a/src/tools/operations.ts b/src/tools/operations.ts index 84c51587..7cae28a5 100644 --- a/src/tools/operations.ts +++ b/src/tools/operations.ts @@ -7,6 +7,7 @@ import type { CallEdgeData, PathHopData, SymbolData } from "../native/index.js"; import { Indexer } from "../indexer/index.js"; import { findKnowledgeBasePathIndex, hasMatchingKnowledgeBasePath, resolveKnowledgeBasePath } from "./knowledge-base-paths.js"; import { buildCodeCommunitiesResult } from "./format-communities.js"; +import { buildArchitectureContext } from "./architecture-context.js"; import { CODE_COMMUNITIES_DEFAULT_COUPLING_LIMIT, CODE_COMMUNITIES_DEFAULT_HUB_THRESHOLD, @@ -16,7 +17,7 @@ import { CODE_COMMUNITIES_MIN_COUPLING, CODE_COMMUNITIES_MIN_SIZE, } from "./contracts.js"; -import type { SharedCodeCommunitiesArgs } from "./contracts.js"; +import type { SharedArchitectureContextArgs, SharedCodeCommunitiesArgs } from "./contracts.js"; import { calculatePercentage, formatProgressTitle, formatStatus } from "./utils.js"; import type { LogLevel } from "../config/schema.js"; import type { LogEntry } from "../utils/logger.js"; @@ -903,3 +904,28 @@ export { getSharedIndexer, formatStatus, }; + +export async function getArchitectureContext( + projectRoot: string | undefined, + host: HostMode, + params: SharedArchitectureContextArgs, +): Promise { + await ensureAutoIndexReadyForRetrieval(projectRoot, host); + const indexer = getIndexerForProject(projectRoot, host); + const [communities, centrality, couplings] = await Promise.all([ + indexer.detectCommunities(), + indexer.computeCentrality(), + indexer.detectCommunityCouplings(), + ]); + let focusedSymbols: SymbolData[] = []; + if (params.query?.trim()) { + const results = await indexer.search(params.query.trim(), 24, { + metadataOnly: true, + directory: params.directory ?? undefined, + prioritizeSourcePaths: true, + }); + const paths = [...new Set(results.map((result) => result.filePath))]; + focusedSymbols = paths.length > 0 ? await indexer.getSymbolsForFiles(paths) : []; + } + return buildArchitectureContext(params, communities, centrality, couplings, focusedSymbols); +} diff --git a/src/tools/tool-names.ts b/src/tools/tool-names.ts index 900f11fc..442431f7 100644 --- a/src/tools/tool-names.ts +++ b/src/tools/tool-names.ts @@ -14,6 +14,7 @@ export const TOOL_NAME = { CALL_GRAPH_PATH: "call_graph_path", PR_IMPACT: "pr_impact", CODE_COMMUNITIES: "code_communities", + ARCHITECTURE_CONTEXT: "architecture_context", ADD_KNOWLEDGE_BASE: "add_knowledge_base", LIST_KNOWLEDGE_BASES: "list_knowledge_bases", REMOVE_KNOWLEDGE_BASE: "remove_knowledge_base", @@ -40,6 +41,7 @@ export const PORTABLE_TOOL_NAMES = [ TOOL_NAME.CALL_GRAPH, TOOL_NAME.CALL_GRAPH_PATH, TOOL_NAME.PR_IMPACT, + TOOL_NAME.ARCHITECTURE_CONTEXT, TOOL_NAME.CODE_COMMUNITIES, ] as const; @@ -61,6 +63,7 @@ export const OPENCODE_TOOL_NAMES = [ TOOL_NAME.LIST_KNOWLEDGE_BASES, TOOL_NAME.REMOVE_KNOWLEDGE_BASE, TOOL_NAME.PR_IMPACT, + TOOL_NAME.ARCHITECTURE_CONTEXT, TOOL_NAME.CODE_COMMUNITIES, TOOL_NAME.INDEX_VISUALIZE, ] as const; @@ -80,6 +83,7 @@ export const PI_TOOL_NAMES = [ TOOL_NAME.CALL_GRAPH, TOOL_NAME.CALL_GRAPH_PATH, TOOL_NAME.PR_IMPACT, + TOOL_NAME.ARCHITECTURE_CONTEXT, TOOL_NAME.CODE_COMMUNITIES, TOOL_NAME.PI_KNOWLEDGE_BASE_LIST, TOOL_NAME.PI_KNOWLEDGE_BASE_ADD, diff --git a/tests/architecture-context.test.ts b/tests/architecture-context.test.ts new file mode 100644 index 00000000..f5c30c9e --- /dev/null +++ b/tests/architecture-context.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { buildArchitectureContext } from "../src/tools/architecture-context.js"; + +describe("architecture_context", () => { + const communities = [ + { symbolId: "a", symbolName: "Api", filePath: "src/api.ts", communityId: 1, communityLabel: "API", crossCommunityConnections: 1 }, + { symbolId: "b", symbolName: "Store", filePath: "src/store.ts", communityId: 2, communityLabel: "Storage", crossCommunityConnections: 1 }, + { symbolId: "c", symbolName: "Fixture", filePath: "tests/fixture.ts", communityId: 3, communityLabel: "Tests", crossCommunityConnections: 0 }, + ]; + const centrality = [ + { symbolId: "a", symbolName: "Api", filePath: "src/api.ts", callerCount: 2, calleeCount: 1, totalConnections: 3 }, + { symbolId: "b", symbolName: "Store", filePath: "src/store.ts", callerCount: 1, calleeCount: 2, totalConnections: 3 }, + ]; + const couplings = [{ communityA: 1, communityB: 2, count: 2, relationships: [{ fromSymbolId: "a", fromSymbolName: "Api", fromFilePath: "src/api.ts", toSymbolId: "b", toSymbolName: "Store", toFilePath: "src/store.ts" }] }]; + + it("is deterministic and cites every architectural claim", () => { + const first = buildArchitectureContext({}, communities, centrality, couplings); + const second = buildArchitectureContext({}, communities, centrality, couplings); + expect(first).toEqual(second); + expect(first.text).toContain("Api (src/api.ts)"); + expect(first.text).toContain("Api (src/api.ts) -> Store (src/store.ts)"); + expect(first.text).toContain("Recommended next steps"); + }); + + it("keeps a directory scope strict and reports sparse graph uncertainty", () => { + const result = buildArchitectureContext({ directory: "src/missing" }, communities, centrality, couplings); + expect(result.modules).toEqual([]); + expect(result.text).toContain("No graph symbols matched the requested scope"); + expect(result.text).not.toContain("Fixture"); + }); +}); From 426c68783f301804c7d225fb4719ccaa63e23932 Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 14:50:18 +0200 Subject: [PATCH 02/11] fix: register architecture context in OpenCode runtime --- src/adapters/opencode.ts | 2 ++ tests/plugin-hooks.test.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index 04829beb..c56d468f 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -18,6 +18,7 @@ import { find_similar, call_graph, call_graph_path, + architecture_context, code_communities, implementation_lookup, add_knowledge_base, @@ -159,6 +160,7 @@ const plugin: Plugin = async ({ directory, worktree }) => { [TOOL_NAME.LIST_KNOWLEDGE_BASES]: list_knowledge_bases, [TOOL_NAME.REMOVE_KNOWLEDGE_BASE]: remove_knowledge_base, [TOOL_NAME.PR_IMPACT]: pr_impact, + [TOOL_NAME.ARCHITECTURE_CONTEXT]: architecture_context, [TOOL_NAME.CODE_COMMUNITIES]: code_communities, [TOOL_NAME.INDEX_VISUALIZE]: index_visualize, }, diff --git a/tests/plugin-hooks.test.ts b/tests/plugin-hooks.test.ts index 256bfe0c..c58e903f 100644 --- a/tests/plugin-hooks.test.ts +++ b/tests/plugin-hooks.test.ts @@ -98,6 +98,7 @@ vi.mock("../src/tools/index.js", () => { list_knowledge_bases: toolStub, remove_knowledge_base: toolStub, pr_impact: toolStub, + architecture_context: toolStub, code_communities: toolStub, index_visualize: toolStub, initializeTools: mockState.initializeTools, From c3745aa633ddc3541a405a577542a74c1ff796dc Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 15:05:59 +0200 Subject: [PATCH 03/11] fix: ground architecture focus and graph coverage --- src/tools/architecture-context.ts | 9 ++++++--- src/tools/operations.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/tools/architecture-context.ts b/src/tools/architecture-context.ts index fb28b93d..41889287 100644 --- a/src/tools/architecture-context.ts +++ b/src/tools/architecture-context.ts @@ -37,11 +37,13 @@ export function buildArchitectureContext( centrality: CentralityData[], couplings: CommunityCouplingData[], focusedSymbols: SymbolData[] = [], + graphCoverage?: { totalEdges: number; resolvedEdges: number }, ): ArchitectureContextResult { const depth = Math.max(1, Math.min(ARCHITECTURE_CONTEXT_MAX_DEPTH, Math.floor(input.depth ?? ARCHITECTURE_CONTEXT_DEFAULT_DEPTH))); const focusIds = new Set(focusedSymbols.map((symbol) => symbol.id)); const hasFocus = focusIds.size > 0; - const scopedMembers = communities.filter((member) => inDirectory(member.filePath, input.directory ?? undefined) && (!hasFocus || focusIds.has(member.symbolId))); + const focusedCommunityIds = new Set(communities.filter((member) => focusIds.has(member.symbolId)).map((member) => member.communityId)); + const scopedMembers = communities.filter((member) => inDirectory(member.filePath, input.directory ?? undefined) && (!hasFocus || focusedCommunityIds.has(member.communityId))); const labels = new Map(communities.map((member) => [member.communityId, member.communityLabel])); const byCommunity = new Map(); for (const member of scopedMembers) { @@ -76,11 +78,12 @@ export function buildArchitectureContext( .slice(0, depth * 2) .map((item) => ({ symbol: item.symbolName, filePath: item.filePath, connections: item.totalConnections })); const graphSparse = scopedMembers.length === 0 || boundaries.length === 0; + const resolutionNote = graphCoverage ? ` ${graphCoverage.resolvedEdges}/${graphCoverage.totalEdges} observed call edges are resolved.` : ""; const note = scopedMembers.length === 0 ? "No graph symbols matched the requested scope. No architectural relationship is inferred." : graphSparse - ? "No resolved cross-module coupling was available in this scope. Treat module boundaries as incomplete." - : "Module relationships are grounded in resolved representative call relationships."; + ? `No resolved cross-module coupling was available in this scope. Treat module boundaries as incomplete.${resolutionNote}` + : `Module relationships are grounded in resolved representative call relationships.${resolutionNote}`; const recommendations = modules[0]?.evidence[0] ? [`implementation_lookup for ${modules[0].evidence[0].symbol} in ${modules[0].evidence[0].filePath}`, `call_graph for ${modules[0].evidence[0].symbol}`] : ["codebase_context with a narrower query or directory"]; diff --git a/src/tools/operations.ts b/src/tools/operations.ts index 7cae28a5..b8431baf 100644 --- a/src/tools/operations.ts +++ b/src/tools/operations.ts @@ -912,10 +912,11 @@ export async function getArchitectureContext( ): Promise { await ensureAutoIndexReadyForRetrieval(projectRoot, host); const indexer = getIndexerForProject(projectRoot, host); - const [communities, centrality, couplings] = await Promise.all([ + const [communities, centrality, couplings, visualization] = await Promise.all([ indexer.detectCommunities(), indexer.computeCentrality(), indexer.detectCommunityCouplings(), + indexer.getVisualizationData({ directory: params.directory ?? undefined }), ]); let focusedSymbols: SymbolData[] = []; if (params.query?.trim()) { @@ -927,5 +928,8 @@ export async function getArchitectureContext( const paths = [...new Set(results.map((result) => result.filePath))]; focusedSymbols = paths.length > 0 ? await indexer.getSymbolsForFiles(paths) : []; } - return buildArchitectureContext(params, communities, centrality, couplings, focusedSymbols); + return buildArchitectureContext(params, communities, centrality, couplings, focusedSymbols, { + totalEdges: visualization.edges.length, + resolvedEdges: visualization.edges.filter((edge) => edge.isResolved).length, + }); } From 8fc632f85cf6f4e6ff1d1d39ac5e72dceea4170f Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 15:22:32 +0200 Subject: [PATCH 04/11] feat: include recent architecture activity --- src/tools/architecture-context.ts | 2 ++ src/tools/operations.ts | 8 +++++++- tests/architecture-context.test.ts | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tools/architecture-context.ts b/src/tools/architecture-context.ts index 41889287..9161357c 100644 --- a/src/tools/architecture-context.ts +++ b/src/tools/architecture-context.ts @@ -38,6 +38,7 @@ export function buildArchitectureContext( couplings: CommunityCouplingData[], focusedSymbols: SymbolData[] = [], graphCoverage?: { totalEdges: number; resolvedEdges: number }, + recentActivity: string[] = [], ): ArchitectureContextResult { const depth = Math.max(1, Math.min(ARCHITECTURE_CONTEXT_MAX_DEPTH, Math.floor(input.depth ?? ARCHITECTURE_CONTEXT_DEFAULT_DEPTH))); const focusIds = new Set(focusedSymbols.map((symbol) => symbol.id)); @@ -103,6 +104,7 @@ export function buildArchitectureContext( lines.push("\nEntry points and hubs:"); for (const hub of hubs) lines.push(`- ${hub.symbol} (${hub.filePath}), ${hub.connections} graph connections`); } + if (input.includeRecentActivity && recentActivity.length > 0) lines.push("\nRecent activity:", ...recentActivity.map((item) => `- ${item}`)); lines.push("\nRecommended next steps:", ...recommendations.map((item) => `- ${item}`)); return { modules, boundaries, hubs, coverage: { symbols: scopedMembers.length, communities: byCommunity.size, scoped: Boolean(input.directory || input.query), graphSparse, note }, recommendations, text: lines.join("\n") }; } diff --git a/src/tools/operations.ts b/src/tools/operations.ts index b8431baf..9e3648bd 100644 --- a/src/tools/operations.ts +++ b/src/tools/operations.ts @@ -8,6 +8,8 @@ import { Indexer } from "../indexer/index.js"; import { findKnowledgeBasePathIndex, hasMatchingKnowledgeBasePath, resolveKnowledgeBasePath } from "./knowledge-base-paths.js"; import { buildCodeCommunitiesResult } from "./format-communities.js"; import { buildArchitectureContext } from "./architecture-context.js"; +import { attachRecentActivity } from "./visualize/activity.js"; +import { transformForVisualization } from "./visualize/transform.js"; import { CODE_COMMUNITIES_DEFAULT_COUPLING_LIMIT, CODE_COMMUNITIES_DEFAULT_HUB_THRESHOLD, @@ -928,8 +930,12 @@ export async function getArchitectureContext( const paths = [...new Set(results.map((result) => result.filePath))]; focusedSymbols = paths.length > 0 ? await indexer.getSymbolsForFiles(paths) : []; } + const recentActivity = params.includeRecentActivity + ? attachRecentActivity(transformForVisualization(visualization.symbols, visualization.edges, { directory: params.directory ?? undefined }), getProjectRoot(projectRoot, host)).changes + ?.slice(0, 3).map((change) => `${change.title}: ${change.summary}`) ?? [] + : []; return buildArchitectureContext(params, communities, centrality, couplings, focusedSymbols, { totalEdges: visualization.edges.length, resolvedEdges: visualization.edges.filter((edge) => edge.isResolved).length, - }); + }, recentActivity); } diff --git a/tests/architecture-context.test.ts b/tests/architecture-context.test.ts index f5c30c9e..4ba123a0 100644 --- a/tests/architecture-context.test.ts +++ b/tests/architecture-context.test.ts @@ -21,6 +21,7 @@ describe("architecture_context", () => { expect(first.text).toContain("Api (src/api.ts)"); expect(first.text).toContain("Api (src/api.ts) -> Store (src/store.ts)"); expect(first.text).toContain("Recommended next steps"); + expect(buildArchitectureContext({ includeRecentActivity: true }, communities, centrality, couplings, [], undefined, ["API moved recently: source-backed activity"]).text).toContain("Recent activity:"); }); it("keeps a directory scope strict and reports sparse graph uncertainty", () => { From b6383014c59b3907b60da2b8a992fab57ffe1d7c Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 15:49:16 +0200 Subject: [PATCH 05/11] test: cover retrieval-mode distribution in eval outputs --- tests/eval-metrics.test.ts | 52 ++++++++++++++++++++++++++++++++++++++ tests/eval-reports.test.ts | 9 +++++++ 2 files changed, 61 insertions(+) diff --git a/tests/eval-metrics.test.ts b/tests/eval-metrics.test.ts index e2c6437a..d5f6a5be 100644 --- a/tests/eval-metrics.test.ts +++ b/tests/eval-metrics.test.ts @@ -485,6 +485,58 @@ describe("eval metrics", () => { expect(perQuery[0].rawTop3DistinctRatio).toBeCloseTo(2 / 3, 6); }); + it("tracks retrieval mode distribution in aggregate metrics", () => { + const perQuery = [ + buildPerQueryResult( + query({ id: "q-search" }), + [{ + filePath: "/repo/src/indexer/index.ts", + startLine: 1, + endLine: 2, + score: 1, + chunkType: "function", + name: "rankHybridResults", + }], + 10, + 10, + ), + buildPerQueryResult( + query({ id: "q-context", retrievalMode: "context" }), + [{ + filePath: "/repo/src/indexer/index.ts", + startLine: 1, + endLine: 2, + score: 1, + chunkType: "function", + name: "rankHybridResults", + }], + 10, + 10, + ), + buildPerQueryResult( + query({ id: "q-edit", retrievalMode: "edit-context" }), + [{ + filePath: "/repo/src/indexer/index.ts", + startLine: 1, + endLine: 2, + score: 1, + chunkType: "function", + name: "rankHybridResults", + }], + 10, + 10, + ), + ]; + + const metrics = computeEvalMetrics([query({ id: "q-search" }), query({ id: "q-context", retrievalMode: "context" }), query({ id: "q-edit", retrievalMode: "edit-context" })], perQuery, 0, 0, 0); + + expect(metrics.retrievalModeCounts).toEqual({ + search: 1, + context: 1, + "edit-context": 1, + }); + }); + it("measures context response tokens, candidate compression, and quality per token", () => { const queries: GoldenQuery[] = [ query({ id: "q1", retrievalMode: "context" }), diff --git a/tests/eval-reports.test.ts b/tests/eval-reports.test.ts index fa7fdfc8..6141243d 100644 --- a/tests/eval-reports.test.ts +++ b/tests/eval-reports.test.ts @@ -44,10 +44,19 @@ describe("eval reports", () => { readFileSync("benchmarks/baselines/eval-baseline-summary.json", "utf-8"), ) as { metrics: Record }; source.metrics.graphNeighborRecall = 0.5; + source.metrics.retrievalModeCounts = { + search: 30, + context: 7, + "edit-context": 3, + }; const summaryPath = path.join(tempDir, "summary.json"); writeFileSync(summaryPath, JSON.stringify(source), "utf-8"); const summary = loadSummary(summaryPath); expect(createSummaryMarkdown(summary)).toContain("| Graph-neighbor recall | 0.5000 |"); + expect(createSummaryMarkdown(summary)).toContain("## Retrieval Mode Distribution"); + expect(createSummaryMarkdown(summary)).toContain("| search | 30 |"); + expect(createSummaryMarkdown(summary)).toContain("| context | 7 |"); + expect(createSummaryMarkdown(summary)).toContain("| edit-context | 3 |"); }); }); From 99d151b2c1a4976aec00ad413a1476e9a0f5d64e Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 15:49:41 +0200 Subject: [PATCH 06/11] feat: ground architecture maps in source evidence --- src/adapters/opencode/tools.ts | 2 +- src/tools/architecture-context.ts | 729 ++++++++++++++++++++++++++--- src/tools/operations.ts | 70 ++- src/tools/visualize/activity.ts | 11 +- tests/architecture-context.test.ts | 237 +++++++++- 5 files changed, 942 insertions(+), 107 deletions(-) diff --git a/src/adapters/opencode/tools.ts b/src/adapters/opencode/tools.ts index d47a73ff..f769b9ec 100644 --- a/src/adapters/opencode/tools.ts +++ b/src/adapters/opencode/tools.ts @@ -401,7 +401,7 @@ export const architecture_context: ToolDefinition = tool({ query: z.string().nullable().optional().describe("Optional subsystem or planning focus"), directory: z.string().nullable().optional().describe("Constrain the map to this directory"), depth: z.number().int().min(1).max(3).optional().default(2).describe("Summary detail level (1-3)"), - includeRecentActivity: z.boolean().optional().default(false).describe("Reserved for recent activity context"), + includeRecentActivity: z.boolean().optional().default(false).describe("Include matching Git activity from the last 90 days when available"), tokenBudget: z.number().int().min(128).max(4000).optional().default(1200).describe("Maximum response token budget"), }, async execute(args, context) { diff --git a/src/tools/architecture-context.ts b/src/tools/architecture-context.ts index 9161357c..38734c96 100644 --- a/src/tools/architecture-context.ts +++ b/src/tools/architecture-context.ts @@ -1,4 +1,16 @@ -import type { CentralityData, CommunityCouplingData, CommunityData, SymbolData } from "../native/index.js"; +import type { + CentralityData, + CommunityCouplingData, + CommunityData, + SymbolData, +} from "../native/index.js"; +import type { VisualizationNode } from "./visualize/types.js"; + +import { readFileSync } from "node:fs"; +import * as path from "node:path"; + +import { estimateTokens } from "../utils/cost.js"; +import { deriveModules } from "./visualize/modules.js"; export const ARCHITECTURE_CONTEXT_DEFAULT_DEPTH = 2; export const ARCHITECTURE_CONTEXT_MAX_DEPTH = 3; @@ -14,97 +26,676 @@ export interface ArchitectureContextInput { tokenBudget?: number; } +export interface ArchitectureSearchEvidence { + filePath: string; + startLine: number; + endLine: number; + score: number; + name?: string; +} + +export interface ArchitectureSourceEvidence { + symbolId: string; + symbol: string; + filePath: string; + line: number; + excerpt?: string; +} + +export interface ArchitectureRecentActivity { + title: string; + date: string; + commit: string; + summary: string; + filePaths: string[]; +} + +export interface ArchitectureContextSources { + projectRoot?: string; + sourceSymbols?: SymbolData[]; + focusedSymbols?: SymbolData[]; + graphCoverage?: { totalEdges: number; resolvedEdges: number }; + recentActivity?: ArchitectureRecentActivity[]; +} + +export interface ArchitectureContextModule { + id: string; + label: string; + symbolCount: number; + source: "community" | "directory"; + evidence: ArchitectureSourceEvidence[]; +} + export interface ArchitectureContextResult { - modules: Array<{ id: number; label: string; symbolCount: number; evidence: Array<{ symbol: string; filePath: string }> }>; - boundaries: Array<{ fromModule: string; toModule: string; connections: number; evidence: Array<{ fromSymbol: string; fromFilePath: string; toSymbol: string; toFilePath: string }> }>; + modules: ArchitectureContextModule[]; + boundaries: Array<{ + fromModule: string; + toModule: string; + connections: number; + evidence: Array<{ + fromSymbol: string; + fromFilePath: string; + toSymbol: string; + toFilePath: string; + }>; + }>; hubs: Array<{ symbol: string; filePath: string; connections: number }>; - coverage: { symbols: number; communities: number; scoped: boolean; graphSparse: boolean; note: string }; + recentActivity: ArchitectureRecentActivity[]; + coverage: { + symbols: number; + communities: number; + scoped: boolean; + graphSparse: boolean; + sourceFallback: boolean; + note: string; + }; recommendations: string[]; + tokenBudget: number; + tokenEstimate: number; + omitted: { modules: number; boundaries: number; hubs: number; recentActivity: number }; text: string; } -function compare(left: string, right: string): number { return left.localeCompare(right); } -function inDirectory(filePath: string, directory?: string): boolean { - if (!directory) return true; - const normalized = directory.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, ""); - const candidate = filePath.replace(/\\/g, "/"); - return candidate === normalized || candidate.startsWith(`${normalized}/`); +interface ArchitectureModuleCandidate extends ArchitectureContextModule { + communityId?: number; } -export function buildArchitectureContext( - input: ArchitectureContextInput, +type ArchitectureBoundaryCandidate = ArchitectureContextResult["boundaries"][number] & { + fromId: string; + toId: string; +}; + +function compare(left: string, right: string): number { + return left.localeCompare(right); +} + +function normalizePath(value: string): string { + return value.trim().replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""); +} + +function displayPath(filePath: string, projectRoot?: string): string { + const normalizedFilePath = normalizePath(filePath); + if (!projectRoot) return normalizedFilePath; + const normalizedRoot = normalizePath(path.resolve(projectRoot)); + if (normalizedFilePath === normalizedRoot) return "."; + if (normalizedFilePath.startsWith(`${normalizedRoot}/`)) { + return normalizedFilePath.slice(normalizedRoot.length + 1); + } + return normalizedFilePath; +} + +export function isArchitecturePathInDirectory( + filePath: string, + directory?: string | null, + projectRoot?: string, +): boolean { + const requested = normalizePath(directory ?? ""); + if (!requested || requested === ".") return true; + + const candidate = normalizePath(filePath); + if (candidate === requested || candidate.startsWith(`${requested}/`)) return true; + + if (projectRoot) { + const absoluteDirectory = normalizePath(path.resolve(projectRoot, requested)); + if (candidate === absoluteDirectory || candidate.startsWith(`${absoluteDirectory}/`)) return true; + } + + return candidate.endsWith(`/${requested}`) || candidate.includes(`/${requested}/`); +} + +function sameFile(left: string, right: string): boolean { + const normalizedLeft = normalizePath(left); + const normalizedRight = normalizePath(right); + return normalizedLeft === normalizedRight + || normalizedLeft.endsWith(`/${normalizedRight}`) + || normalizedRight.endsWith(`/${normalizedLeft}`); +} + +function spansOverlap( + leftStart: number, + leftEnd: number, + rightStart: number, + rightEnd: number, +): boolean { + return leftStart <= rightEnd && rightStart <= leftEnd; +} + +export function selectArchitectureFocusedSymbols( + results: ArchitectureSearchEvidence[], + symbols: SymbolData[], + depth = ARCHITECTURE_CONTEXT_DEFAULT_DEPTH, +): SymbolData[] { + const normalizedDepth = Math.max(1, Math.min(ARCHITECTURE_CONTEXT_MAX_DEPTH, Math.floor(depth))); + const topScore = results[0]?.score ?? 0; + const selected: SymbolData[] = []; + const selectedIds = new Set(); + + for (const [index, result] of results.entries()) { + if (index >= normalizedDepth * 3) break; + if (index >= 2 && topScore > 0 && result.score < topScore * 0.55) continue; + + const fileSymbols = symbols.filter((symbol) => sameFile(symbol.filePath, result.filePath)); + const ranked = fileSymbols.slice().sort((left, right) => { + const leftNameMatch = result.name !== undefined && left.name === result.name ? 1 : 0; + const rightNameMatch = result.name !== undefined && right.name === result.name ? 1 : 0; + if (leftNameMatch !== rightNameMatch) return rightNameMatch - leftNameMatch; + + const leftOverlap = spansOverlap(left.startLine, left.endLine, result.startLine, result.endLine) ? 1 : 0; + const rightOverlap = spansOverlap(right.startLine, right.endLine, result.startLine, result.endLine) ? 1 : 0; + if (leftOverlap !== rightOverlap) return rightOverlap - leftOverlap; + + const leftDistance = Math.abs(left.startLine - result.startLine); + const rightDistance = Math.abs(right.startLine - result.startLine); + return leftDistance - rightDistance + || (left.endLine - left.startLine) - (right.endLine - right.startLine) + || compare(left.id, right.id); + }); + + const best = ranked[0]; + if (best && !selectedIds.has(best.id)) { + selected.push(best); + selectedIds.add(best.id); + } + } + + return selected; +} + +function cleanComment(lines: string[]): string | undefined { + const text = lines + .map((line) => line.trim() + .replace(/^\/\*\*?\s?/, "") + .replace(/^\/\/[/!]?[ ]?/, "") + .replace(/^#\s?/, "") + .replace(/^\*\s?/, "") + .replace(/\*\/$/, "")) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (!text || text.startsWith("@")) return undefined; + return text.slice(0, 220); +} + +function leadingComment(lines: string[], startIndex: number): string | undefined { + const previousIndex = startIndex - 1; + if (previousIndex < 0 || lines[previousIndex]?.trim() === "") return undefined; + const previous = lines[previousIndex]?.trim() ?? ""; + + if (/^(\/\/[/!]?|#)/.test(previous)) { + const comments: string[] = []; + for (let index = previousIndex; index >= Math.max(0, previousIndex - 5); index -= 1) { + const line = lines[index]?.trim() ?? ""; + if (!/^(\/\/[/!]?|#)/.test(line)) break; + comments.unshift(line); + } + return cleanComment(comments); + } + + if (previous.endsWith("*/")) { + const comments: string[] = []; + for (let index = previousIndex; index >= Math.max(0, previousIndex - 7); index -= 1) { + const line = lines[index]?.trim() ?? ""; + comments.unshift(line); + if (line.startsWith("/*")) break; + } + if (comments[0]?.startsWith("/*")) return cleanComment(comments); + } + + return undefined; +} + +function declarationExcerpt(lines: string[], symbol: SymbolData): string | undefined { + const startIndex = Math.max(0, symbol.startLine - 1); + const declaration = lines + .slice(startIndex, Math.min(lines.length, startIndex + 3, symbol.endLine)) + .map((line) => line.trim()) + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (!declaration) return undefined; + const openingBrace = declaration.indexOf("{"); + const concise = openingBrace >= 0 ? declaration.slice(0, openingBrace + 1) : declaration; + return concise.slice(0, 220); +} + +function sourceEvidence( + symbol: SymbolData, + projectRoot: string | undefined, + fileCache: Map, +): ArchitectureSourceEvidence { + let lines = fileCache.get(symbol.filePath); + if (lines === undefined) { + try { + lines = readFileSync(symbol.filePath, "utf8").split(/\r?\n/); + } catch { + lines = null; + } + fileCache.set(symbol.filePath, lines); + } + + const startIndex = Math.max(0, symbol.startLine - 1); + const excerpt = lines + ? leadingComment(lines, startIndex) ?? declarationExcerpt(lines, symbol) + : undefined; + return { + symbolId: symbol.id, + symbol: symbol.name, + filePath: displayPath(symbol.filePath, projectRoot), + line: symbol.startLine, + ...(excerpt ? { excerpt } : {}), + }; +} + +function evidenceForCommunityMember( + member: CommunityData, + symbolById: Map, + projectRoot: string | undefined, + fileCache: Map, +): ArchitectureSourceEvidence { + const symbol = symbolById.get(member.symbolId); + if (symbol) return sourceEvidence(symbol, projectRoot, fileCache); + return { + symbolId: member.symbolId, + symbol: member.symbolName, + filePath: displayPath(member.filePath, projectRoot), + line: 0, + }; +} + +function buildCommunityModules( communities: CommunityData[], - centrality: CentralityData[], - couplings: CommunityCouplingData[], - focusedSymbols: SymbolData[] = [], - graphCoverage?: { totalEdges: number; resolvedEdges: number }, - recentActivity: string[] = [], -): ArchitectureContextResult { - const depth = Math.max(1, Math.min(ARCHITECTURE_CONTEXT_MAX_DEPTH, Math.floor(input.depth ?? ARCHITECTURE_CONTEXT_DEFAULT_DEPTH))); + focusedSymbols: SymbolData[], + sourceSymbols: SymbolData[], + input: ArchitectureContextInput, + depth: number, + projectRoot: string | undefined, + fileCache: Map, +): { modules: ArchitectureModuleCandidate[]; scopedMembers: CommunityData[] } { + const queryRequested = Boolean(input.query?.trim()); const focusIds = new Set(focusedSymbols.map((symbol) => symbol.id)); - const hasFocus = focusIds.size > 0; - const focusedCommunityIds = new Set(communities.filter((member) => focusIds.has(member.symbolId)).map((member) => member.communityId)); - const scopedMembers = communities.filter((member) => inDirectory(member.filePath, input.directory ?? undefined) && (!hasFocus || focusedCommunityIds.has(member.communityId))); + const focusRanks = new Map(focusedSymbols.map((symbol, index) => [symbol.id, index])); + const focusedCommunityIds = new Set( + communities.filter((member) => focusIds.has(member.symbolId)).map((member) => member.communityId), + ); + const scopedMembers = communities.filter((member) => + isArchitecturePathInDirectory(member.filePath, input.directory, projectRoot) + && (!queryRequested || focusedCommunityIds.has(member.communityId)) + ); const labels = new Map(communities.map((member) => [member.communityId, member.communityLabel])); + const symbolById = new Map(sourceSymbols.map((symbol) => [symbol.id, symbol])); const byCommunity = new Map(); for (const member of scopedMembers) { - const list = byCommunity.get(member.communityId) ?? []; - list.push(member); - byCommunity.set(member.communityId, list); - } - const moduleLimit = Math.max(1, Math.min(12, Math.floor((input.tokenBudget ?? ARCHITECTURE_CONTEXT_DEFAULT_TOKEN_BUDGET) / 180))); - const modules = [...byCommunity.entries()] - .map(([id, members]) => ({ - id, - label: labels.get(id) ?? `Community ${id}`, + const members = byCommunity.get(member.communityId) ?? []; + members.push(member); + byCommunity.set(member.communityId, members); + } + + const modules = [...byCommunity.entries()].map(([communityId, members]) => { + const ordered = members.slice().sort((left, right) => { + const leftRank = focusRanks.get(left.symbolId) ?? Number.POSITIVE_INFINITY; + const rightRank = focusRanks.get(right.symbolId) ?? Number.POSITIVE_INFINITY; + return leftRank - rightRank + || right.crossCommunityConnections - left.crossCommunityConnections + || compare(left.symbolName, right.symbolName) + || compare(left.symbolId, right.symbolId); + }); + return { + id: `community-${communityId}`, + communityId, + label: labels.get(communityId) ?? `Community ${communityId}`, symbolCount: members.length, - evidence: members.slice().sort((a, b) => compare(a.symbolName, b.symbolName) || compare(a.symbolId, b.symbolId)).slice(0, depth + 1).map((member) => ({ symbol: member.symbolName, filePath: member.filePath })), - })) - .sort((a, b) => b.symbolCount - a.symbolCount || compare(a.label, b.label) || a.id - b.id) - .slice(0, moduleLimit); - const selectedIds = new Set(modules.map((module) => module.id)); - const boundaries = couplings - .filter((coupling) => selectedIds.has(coupling.communityA) && selectedIds.has(coupling.communityB)) - .map((coupling) => ({ - fromModule: labels.get(coupling.communityA) ?? `Community ${coupling.communityA}`, - toModule: labels.get(coupling.communityB) ?? `Community ${coupling.communityB}`, - connections: coupling.count, - evidence: (coupling.relationships ?? coupling.representativeRelationships ?? []).slice().sort((a, b) => compare(a.fromSymbolId, b.fromSymbolId) || compare(a.toSymbolId, b.toSymbolId)).slice(0, depth).map((edge) => ({ fromSymbol: edge.fromSymbolName, fromFilePath: edge.fromFilePath, toSymbol: edge.toSymbolName, toFilePath: edge.toFilePath })), - })) - .sort((a, b) => b.connections - a.connections || compare(a.fromModule, b.fromModule) || compare(a.toModule, b.toModule)) - .slice(0, depth * 2); - const hubs = centrality - .filter((item) => inDirectory(item.filePath, input.directory ?? undefined) && (!hasFocus || focusIds.has(item.symbolId))) - .slice().sort((a, b) => b.totalConnections - a.totalConnections || compare(a.symbolId, b.symbolId)) - .slice(0, depth * 2) - .map((item) => ({ symbol: item.symbolName, filePath: item.filePath, connections: item.totalConnections })); - const graphSparse = scopedMembers.length === 0 || boundaries.length === 0; - const resolutionNote = graphCoverage ? ` ${graphCoverage.resolvedEdges}/${graphCoverage.totalEdges} observed call edges are resolved.` : ""; - const note = scopedMembers.length === 0 - ? "No graph symbols matched the requested scope. No architectural relationship is inferred." - : graphSparse - ? `No resolved cross-module coupling was available in this scope. Treat module boundaries as incomplete.${resolutionNote}` - : `Module relationships are grounded in resolved representative call relationships.${resolutionNote}`; - const recommendations = modules[0]?.evidence[0] - ? [`implementation_lookup for ${modules[0].evidence[0].symbol} in ${modules[0].evidence[0].filePath}`, `call_graph for ${modules[0].evidence[0].symbol}`] - : ["codebase_context with a narrower query or directory"]; - const lines = ["→ Architecture context", `Coverage: ${scopedMembers.length} graph symbols across ${byCommunity.size} communities${input.directory ? ` in ${input.directory}` : ""}.`, `Uncertainty: ${note}`]; + source: "community" as const, + evidence: ordered.slice(0, depth + 1).map((member) => + evidenceForCommunityMember(member, symbolById, projectRoot, fileCache) + ), + focusRank: Math.min(...members.map((member) => focusRanks.get(member.symbolId) ?? Number.POSITIVE_INFINITY)), + }; + }).sort((left, right) => { + if (queryRequested && left.focusRank !== right.focusRank) return left.focusRank - right.focusRank; + return right.symbolCount - left.symbolCount || compare(left.label, right.label) || compare(left.id, right.id); + }).slice(0, 12).map(({ focusRank: _focusRank, ...module }) => module); + + return { modules, scopedMembers }; +} + +function buildDirectoryModules( + sourceSymbols: SymbolData[], + focusedSymbols: SymbolData[], + input: ArchitectureContextInput, + depth: number, + projectRoot: string | undefined, + fileCache: Map, +): ArchitectureModuleCandidate[] { + const queryRequested = Boolean(input.query?.trim()); + const candidates = (queryRequested ? focusedSymbols : sourceSymbols).filter((symbol) => + isArchitecturePathInDirectory(symbol.filePath, input.directory, projectRoot) + ); + const focusRanks = new Map(focusedSymbols.map((symbol, index) => [symbol.id, index])); + const nodes: VisualizationNode[] = candidates.map((symbol) => ({ + id: symbol.id, + name: symbol.name, + filePath: symbol.filePath, + kind: symbol.kind, + line: symbol.startLine, + directory: path.dirname(symbol.filePath), + moduleId: "", + moduleLabel: "", + })); + const symbolsById = new Map(candidates.map((symbol) => [symbol.id, symbol])); + + return deriveModules(nodes).map((module) => { + const members = module.symbols + .map((symbolId) => symbolsById.get(symbolId)) + .filter((symbol): symbol is SymbolData => symbol !== undefined) + .sort((left, right) => { + const leftRank = focusRanks.get(left.id) ?? Number.POSITIVE_INFINITY; + const rightRank = focusRanks.get(right.id) ?? Number.POSITIVE_INFINITY; + return leftRank - rightRank || compare(left.name, right.name) || compare(left.id, right.id); + }); + return { + id: module.id, + label: module.label, + symbolCount: members.length, + source: "directory" as const, + evidence: members.slice(0, depth + 1).map((symbol) => sourceEvidence(symbol, projectRoot, fileCache)), + focusRank: Math.min(...members.map((symbol) => focusRanks.get(symbol.id) ?? Number.POSITIVE_INFINITY)), + }; + }).sort((left, right) => { + if (queryRequested && left.focusRank !== right.focusRank) return left.focusRank - right.focusRank; + return right.symbolCount - left.symbolCount || compare(left.label, right.label) || compare(left.id, right.id); + }).slice(0, 12).map(({ focusRank: _focusRank, ...module }) => module); +} + +function sourceCitation(evidence: ArchitectureSourceEvidence): string { + return evidence.line > 0 ? `${evidence.filePath}:${evidence.line}` : evidence.filePath; +} + +function renderArchitectureText( + input: ArchitectureContextInput, + modules: ArchitectureContextModule[], + boundaries: ArchitectureContextResult["boundaries"], + hubs: ArchitectureContextResult["hubs"], + recentActivity: ArchitectureRecentActivity[], + coverage: ArchitectureContextResult["coverage"], + recommendations: string[], + recentActivityUnavailable: boolean, +): string { + const lines = ["→ Architecture context"]; + if (input.query?.trim()) lines.push(`Focus: ${input.query.trim()}`); + lines.push( + `Coverage: ${coverage.symbols} scoped symbols across ${coverage.communities} modules${input.directory ? ` in ${normalizePath(input.directory)}` : ""}.`, + `Uncertainty: ${coverage.note}`, + ); + for (const module of modules) { - lines.push(`\nModule: ${module.label} (${module.symbolCount} scoped symbols)`); - for (const evidence of module.evidence) lines.push(`- ${evidence.symbol} (${evidence.filePath})`); + const sourceLabel = module.source === "community" ? "graph community" : "source directory fallback"; + lines.push(`\nModule: ${module.label} (${module.symbolCount} scoped symbols, ${sourceLabel})`); + const responsibility = module.evidence.find((evidence) => evidence.excerpt); + if (responsibility?.excerpt) { + lines.push(`- Source-backed responsibility: ${responsibility.excerpt} [${responsibility.symbol} at ${sourceCitation(responsibility)}]`); + } else { + lines.push("- Source-backed responsibility unavailable; no responsibility is inferred."); + } + for (const evidence of module.evidence) { + lines.push(`- Evidence: ${evidence.symbol} (${sourceCitation(evidence)})`); + } } + if (boundaries.length > 0) { lines.push("\nBoundaries:"); for (const boundary of boundaries) { - lines.push(`- ${boundary.fromModule} ↔ ${boundary.toModule}: ${boundary.connections} connections`); - for (const edge of boundary.evidence) lines.push(` - ${edge.fromSymbol} (${edge.fromFilePath}) -> ${edge.toSymbol} (${edge.toFilePath})`); + lines.push(`- ${boundary.fromModule} ↔ ${boundary.toModule}: ${boundary.connections} resolved connections`); + for (const edge of boundary.evidence) { + lines.push(` - ${edge.fromSymbol} (${edge.fromFilePath}) -> ${edge.toSymbol} (${edge.toFilePath})`); + } } } + if (hubs.length > 0) { lines.push("\nEntry points and hubs:"); - for (const hub of hubs) lines.push(`- ${hub.symbol} (${hub.filePath}), ${hub.connections} graph connections`); + for (const hub of hubs) { + lines.push(`- ${hub.symbol} (${hub.filePath}), ${hub.connections} graph connections`); + } } - if (input.includeRecentActivity && recentActivity.length > 0) lines.push("\nRecent activity:", ...recentActivity.map((item) => `- ${item}`)); + + if (input.includeRecentActivity) { + lines.push("\nRecent activity:"); + if (recentActivityUnavailable) { + lines.push("- No matching Git activity was found in the last 90 days."); + } else { + for (const activity of recentActivity) { + const files = activity.filePaths.length > 0 ? ` Files: ${activity.filePaths.join(", ")}.` : ""; + lines.push(`- ${activity.title} [commit ${activity.commit}, ${activity.date}]: ${activity.summary}${files}`); + } + } + } + lines.push("\nRecommended next steps:", ...recommendations.map((item) => `- ${item}`)); - return { modules, boundaries, hubs, coverage: { symbols: scopedMembers.length, communities: byCommunity.size, scoped: Boolean(input.directory || input.query), graphSparse, note }, recommendations, text: lines.join("\n") }; + return lines.join("\n"); +} + +function recommendationsFor( + input: ArchitectureContextInput, + modules: ArchitectureModuleCandidate[], + graphSparse: boolean, +): string[] { + const evidence = modules[0]?.evidence[0]; + const directory = input.directory?.trim() || undefined; + const recommendations: string[] = []; + if (evidence) { + recommendations.push(`implementation_lookup ${JSON.stringify({ + query: evidence.symbol, + directory: path.posix.dirname(evidence.filePath), + })}`); + if (!graphSparse) { + recommendations.push(`call_graph ${JSON.stringify({ name: evidence.symbol, filePath: evidence.filePath, direction: "callees" })}`); + } + } + recommendations.push(`codebase_context ${JSON.stringify({ + query: input.query?.trim() || (evidence ? `Understand ${evidence.symbol} and its module` : "Locate the repository subsystem to inspect"), + ...(directory ? { directory } : {}), + tokenBudget: Math.min(1200, input.tokenBudget ?? ARCHITECTURE_CONTEXT_DEFAULT_TOKEN_BUDGET), + })}`); + return recommendations; +} + +export function buildArchitectureContext( + input: ArchitectureContextInput, + communities: CommunityData[], + centrality: CentralityData[], + couplings: CommunityCouplingData[], + sources: ArchitectureContextSources = {}, +): ArchitectureContextResult { + const depth = Math.max( + 1, + Math.min(ARCHITECTURE_CONTEXT_MAX_DEPTH, Math.floor(input.depth ?? ARCHITECTURE_CONTEXT_DEFAULT_DEPTH)), + ); + const tokenBudget = Math.max( + ARCHITECTURE_CONTEXT_MIN_TOKEN_BUDGET, + Math.min( + ARCHITECTURE_CONTEXT_MAX_TOKEN_BUDGET, + Math.floor(input.tokenBudget ?? ARCHITECTURE_CONTEXT_DEFAULT_TOKEN_BUDGET), + ), + ); + const projectRoot = sources.projectRoot; + const sourceSymbols = sources.sourceSymbols ?? []; + const focusedSymbols = sources.focusedSymbols ?? []; + const graphCoverage = sources.graphCoverage; + const fileCache = new Map(); + const queryRequested = Boolean(input.query?.trim()); + + const communityResult = buildCommunityModules( + communities, + focusedSymbols, + sourceSymbols, + input, + depth, + projectRoot, + fileCache, + ); + const sourceFallback = communityResult.modules.length === 0 + && (!queryRequested || focusedSymbols.length > 0); + const moduleCandidates = sourceFallback + ? buildDirectoryModules(sourceSymbols, focusedSymbols, input, depth, projectRoot, fileCache) + : communityResult.modules; + const graphModuleIds = new Map( + communityResult.modules + .filter((module) => module.communityId !== undefined) + .map((module) => [module.communityId as number, module.id]), + ); + const labels = new Map(communities.map((member) => [member.communityId, member.communityLabel])); + const selectedCommunityIds = new Set( + communityResult.modules + .map((module) => module.communityId) + .filter((communityId): communityId is number => communityId !== undefined), + ); + + const boundaryCandidates: ArchitectureBoundaryCandidate[] = couplings.flatMap((coupling) => { + if (!selectedCommunityIds.has(coupling.communityA) || !selectedCommunityIds.has(coupling.communityB)) return []; + const relationships = (coupling.relationships ?? coupling.representativeRelationships ?? []) + .filter((edge) => + isArchitecturePathInDirectory(edge.fromFilePath, input.directory, projectRoot) + && isArchitecturePathInDirectory(edge.toFilePath, input.directory, projectRoot) + ) + .slice() + .sort((left, right) => compare(left.fromSymbolId, right.fromSymbolId) || compare(left.toSymbolId, right.toSymbolId)); + if (relationships.length === 0) return []; + return [{ + fromId: graphModuleIds.get(coupling.communityA) ?? `community-${coupling.communityA}`, + toId: graphModuleIds.get(coupling.communityB) ?? `community-${coupling.communityB}`, + fromModule: labels.get(coupling.communityA) ?? `Community ${coupling.communityA}`, + toModule: labels.get(coupling.communityB) ?? `Community ${coupling.communityB}`, + connections: input.directory ? relationships.length : coupling.count, + evidence: relationships.slice(0, depth).map((edge) => ({ + fromSymbol: edge.fromSymbolName, + fromFilePath: displayPath(edge.fromFilePath, projectRoot), + toSymbol: edge.toSymbolName, + toFilePath: displayPath(edge.toFilePath, projectRoot), + })), + }]; + }).sort((left, right) => + right.connections - left.connections + || compare(left.fromModule, right.fromModule) + || compare(left.toModule, right.toModule) + ).slice(0, depth * 2); + + const communityBySymbolId = new Map(communities.map((member) => [member.symbolId, member.communityId])); + const hubCandidates = centrality + .filter((item) => + isArchitecturePathInDirectory(item.filePath, input.directory, projectRoot) + && (!queryRequested || selectedCommunityIds.has(communityBySymbolId.get(item.symbolId) ?? -1)) + ) + .slice() + .sort((left, right) => right.totalConnections - left.totalConnections || compare(left.symbolId, right.symbolId)) + .slice(0, depth * 2) + .map((item) => ({ + symbol: item.symbolName, + filePath: displayPath(item.filePath, projectRoot), + connections: item.totalConnections, + })); + + const scopedSymbolCount = sourceFallback + ? (queryRequested ? focusedSymbols : sourceSymbols).filter((symbol) => + isArchitecturePathInDirectory(symbol.filePath, input.directory, projectRoot) + ).length + : communityResult.scopedMembers.length; + const graphSparse = sourceFallback || boundaryCandidates.length === 0; + const resolutionNote = graphCoverage + ? ` ${graphCoverage.resolvedEdges}/${graphCoverage.totalEdges} observed call edges are resolved in scope.` + : ""; + const note = queryRequested && focusedSymbols.length === 0 + ? "No indexed symbols matched the requested query and scope. No global architecture is substituted." + : moduleCandidates.length === 0 + ? "No graph or readable source symbols matched the requested scope. No architectural relationship is inferred." + : sourceFallback + ? `Community graph data was unavailable in this scope. Modules are grouped only by source directory, and no relationship is inferred.${resolutionNote}` + : graphSparse + ? `No resolved cross-module coupling was available in this scope. Treat module boundaries as incomplete.${resolutionNote}` + : `Module relationships are grounded in resolved representative call relationships.${resolutionNote}`; + const coverage: ArchitectureContextResult["coverage"] = { + symbols: scopedSymbolCount, + communities: moduleCandidates.length, + scoped: Boolean(input.directory || input.query), + graphSparse, + sourceFallback, + note, + }; + const recommendationCandidates = recommendationsFor(input, moduleCandidates, graphSparse); + const recentActivityCandidates = (sources.recentActivity ?? []).map((activity) => ({ + ...activity, + filePaths: activity.filePaths.map((filePath) => displayPath(filePath, projectRoot)), + })); + const recentActivityUnavailable = input.includeRecentActivity === true && recentActivityCandidates.length === 0; + + const modules: ArchitectureContextModule[] = []; + const boundaries: ArchitectureContextResult["boundaries"] = []; + const hubs: ArchitectureContextResult["hubs"] = []; + const recentActivity: ArchitectureRecentActivity[] = []; + const recommendations = [recommendationCandidates[0] ?? "codebase_context with a narrower query or directory"]; + const render = (): string => renderArchitectureText( + input, + modules, + boundaries, + hubs, + recentActivity, + coverage, + recommendations, + recentActivityUnavailable, + ); + const fits = (): boolean => estimateTokens(render()) <= tokenBudget; + + for (const candidate of moduleCandidates) { + const selected: ArchitectureContextModule = { ...candidate, evidence: [] }; + modules.push(selected); + for (const evidence of candidate.evidence) { + selected.evidence.push(evidence); + if (!fits()) selected.evidence.pop(); + } + if (selected.evidence.length === 0 || !fits()) modules.pop(); + } + + const renderedModuleIds = new Set(modules.map((module) => module.id)); + for (const candidate of boundaryCandidates) { + if (!renderedModuleIds.has(candidate.fromId) || !renderedModuleIds.has(candidate.toId)) continue; + const boundary: ArchitectureContextResult["boundaries"][number] = { + fromModule: candidate.fromModule, + toModule: candidate.toModule, + connections: candidate.connections, + evidence: candidate.evidence, + }; + boundaries.push(boundary); + if (!fits()) boundaries.pop(); + } + for (const hub of hubCandidates) { + hubs.push(hub); + if (!fits()) hubs.pop(); + } + for (const activity of recentActivityCandidates) { + recentActivity.push(activity); + if (!fits()) recentActivity.pop(); + } + for (const recommendation of recommendationCandidates.slice(1)) { + recommendations.push(recommendation); + if (!fits()) recommendations.pop(); + } + + const text = render(); + return { + modules, + boundaries, + hubs, + recentActivity, + coverage, + recommendations, + tokenBudget, + tokenEstimate: estimateTokens(text), + omitted: { + modules: moduleCandidates.length - modules.length, + boundaries: boundaryCandidates.length - boundaries.length, + hubs: hubCandidates.length - hubs.length, + recentActivity: recentActivityCandidates.length - recentActivity.length, + }, + text, + }; } diff --git a/src/tools/operations.ts b/src/tools/operations.ts index 9e3648bd..28320242 100644 --- a/src/tools/operations.ts +++ b/src/tools/operations.ts @@ -7,8 +7,12 @@ import type { CallEdgeData, PathHopData, SymbolData } from "../native/index.js"; import { Indexer } from "../indexer/index.js"; import { findKnowledgeBasePathIndex, hasMatchingKnowledgeBasePath, resolveKnowledgeBasePath } from "./knowledge-base-paths.js"; import { buildCodeCommunitiesResult } from "./format-communities.js"; -import { buildArchitectureContext } from "./architecture-context.js"; -import { attachRecentActivity } from "./visualize/activity.js"; +import { + buildArchitectureContext, + isArchitecturePathInDirectory, + selectArchitectureFocusedSymbols, +} from "./architecture-context.js"; +import { getRecentGitActivity } from "./visualize/activity.js"; import { transformForVisualization } from "./visualize/transform.js"; import { CODE_COMMUNITIES_DEFAULT_COUPLING_LIMIT, @@ -914,6 +918,14 @@ export async function getArchitectureContext( ): Promise { await ensureAutoIndexReadyForRetrieval(projectRoot, host); const indexer = getIndexerForProject(projectRoot, host); + return getArchitectureContextForIndexer(indexer, getProjectRoot(projectRoot, host), params); +} + +export async function getArchitectureContextForIndexer( + indexer: Indexer, + projectRoot: string, + params: SharedArchitectureContextArgs, +): Promise { const [communities, centrality, couplings, visualization] = await Promise.all([ indexer.detectCommunities(), indexer.computeCentrality(), @@ -927,15 +939,53 @@ export async function getArchitectureContext( directory: params.directory ?? undefined, prioritizeSourcePaths: true, }); - const paths = [...new Set(results.map((result) => result.filePath))]; - focusedSymbols = paths.length > 0 ? await indexer.getSymbolsForFiles(paths) : []; + focusedSymbols = selectArchitectureFocusedSymbols( + results, + visualization.symbols, + params.depth, + ); } + + const queryRequested = Boolean(params.query?.trim()); + const focusIds = new Set(focusedSymbols.map((symbol) => symbol.id)); + const focusedCommunityIds = new Set( + communities.filter((member) => focusIds.has(member.symbolId)).map((member) => member.communityId), + ); + const communityBySymbolId = new Map(communities.map((member) => [member.symbolId, member.communityId])); + const scopedSymbols = visualization.symbols.filter((symbol) => + isArchitecturePathInDirectory(symbol.filePath, params.directory, projectRoot) + && (!queryRequested + || focusIds.has(symbol.id) + || focusedCommunityIds.has(communityBySymbolId.get(symbol.id) ?? -1)) + ); + const scopedSymbolIds = new Set(scopedSymbols.map((symbol) => symbol.id)); + const scopedEdges = visualization.edges.filter((edge) => scopedSymbolIds.has(edge.fromSymbolId)); const recentActivity = params.includeRecentActivity - ? attachRecentActivity(transformForVisualization(visualization.symbols, visualization.edges, { directory: params.directory ?? undefined }), getProjectRoot(projectRoot, host)).changes - ?.slice(0, 3).map((change) => `${change.title}: ${change.summary}`) ?? [] + ? getRecentGitActivity( + transformForVisualization(scopedSymbols, visualization.edges, { + directory: params.directory ?? undefined, + includeOrphans: true, + }), + projectRoot, + ).slice(0, 3).map((change) => ({ + title: change.title, + date: change.when, + commit: change.source.replace(/^commit\s+/, ""), + summary: change.summary, + filePaths: change.filePaths, + })) : []; - return buildArchitectureContext(params, communities, centrality, couplings, focusedSymbols, { - totalEdges: visualization.edges.length, - resolvedEdges: visualization.edges.filter((edge) => edge.isResolved).length, - }, recentActivity); + + return buildArchitectureContext(params, communities, centrality, couplings, { + projectRoot, + sourceSymbols: visualization.symbols, + focusedSymbols, + graphCoverage: { + totalEdges: scopedEdges.length, + resolvedEdges: scopedEdges.filter((edge) => + edge.isResolved && edge.toSymbolId !== undefined && scopedSymbolIds.has(edge.toSymbolId) + ).length, + }, + recentActivity, + }); } diff --git a/src/tools/visualize/activity.ts b/src/tools/visualize/activity.ts index aa35bca4..b5b1665f 100644 --- a/src/tools/visualize/activity.ts +++ b/src/tools/visualize/activity.ts @@ -22,10 +22,8 @@ interface ModuleActivity { } export function attachRecentActivity(data: VisualizationData, projectRoot: string): VisualizationData { - const activity = readGitActivity(projectRoot); - const changes = activity.size > 0 - ? buildGitChanges(data, activity, projectRoot) - : buildGraphChanges(data); + const gitChanges = getRecentGitActivity(data, projectRoot); + const changes = gitChanges.length > 0 ? gitChanges : buildGraphChanges(data); return { ...data, @@ -33,6 +31,11 @@ export function attachRecentActivity(data: VisualizationData, projectRoot: strin }; } +export function getRecentGitActivity(data: VisualizationData, projectRoot: string): VisualizationChange[] { + const activity = readGitActivity(projectRoot); + return activity.size > 0 ? buildGitChanges(data, activity, projectRoot) : []; +} + function readGitActivity(projectRoot: string): Map { try { const output = execFileSync( diff --git a/tests/architecture-context.test.ts b/tests/architecture-context.test.ts index 4ba123a0..9e756a56 100644 --- a/tests/architecture-context.test.ts +++ b/tests/architecture-context.test.ts @@ -1,33 +1,224 @@ -import { describe, expect, it } from "vitest"; +import type { SymbolData } from "../src/native/index.js"; -import { buildArchitectureContext } from "../src/tools/architecture-context.js"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + buildArchitectureContext, + selectArchitectureFocusedSymbols, +} from "../src/tools/architecture-context.js"; +import { getRecentGitActivity } from "../src/tools/visualize/activity.js"; +import { transformForVisualization } from "../src/tools/visualize/transform.js"; +import { estimateTokens } from "../src/utils/cost.js"; + +function symbol(id: string, name: string, filePath: string, startLine = 2): SymbolData { + return { + id, + name, + filePath, + kind: "function", + startLine, + startCol: 0, + endLine: startLine + 2, + endCol: 0, + language: "typescript", + }; +} describe("architecture_context", () => { - const communities = [ - { symbolId: "a", symbolName: "Api", filePath: "src/api.ts", communityId: 1, communityLabel: "API", crossCommunityConnections: 1 }, - { symbolId: "b", symbolName: "Store", filePath: "src/store.ts", communityId: 2, communityLabel: "Storage", crossCommunityConnections: 1 }, - { symbolId: "c", symbolName: "Fixture", filePath: "tests/fixture.ts", communityId: 3, communityLabel: "Tests", crossCommunityConnections: 0 }, - ]; - const centrality = [ - { symbolId: "a", symbolName: "Api", filePath: "src/api.ts", callerCount: 2, calleeCount: 1, totalConnections: 3 }, - { symbolId: "b", symbolName: "Store", filePath: "src/store.ts", callerCount: 1, calleeCount: 2, totalConnections: 3 }, - ]; - const couplings = [{ communityA: 1, communityB: 2, count: 2, relationships: [{ fromSymbolId: "a", fromSymbolName: "Api", fromFilePath: "src/api.ts", toSymbolId: "b", toSymbolName: "Store", toFilePath: "src/store.ts" }] }]; - - it("is deterministic and cites every architectural claim", () => { - const first = buildArchitectureContext({}, communities, centrality, couplings); - const second = buildArchitectureContext({}, communities, centrality, couplings); + let tempDir: string; + let apiPath: string; + let storePath: string; + let fixturePath: string; + let symbols: SymbolData[]; + let communities: Array<{ + symbolId: string; + symbolName: string; + filePath: string; + communityId: number; + communityLabel: string; + crossCommunityConnections: number; + }>; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(os.tmpdir(), "architecture-context-")); + mkdirSync(path.join(tempDir, "src"), { recursive: true }); + mkdirSync(path.join(tempDir, "tests"), { recursive: true }); + apiPath = path.join(tempDir, "src", "api.ts"); + storePath = path.join(tempDir, "src", "store.ts"); + fixturePath = path.join(tempDir, "tests", "fixture.ts"); + writeFileSync(apiPath, "// Validates API tokens before requests enter the application.\nexport function Api(token: string) { return token.length > 0; }\n"); + writeFileSync(storePath, "// Persists validated records in the local store.\nexport function Store() { return new Map(); }\n"); + writeFileSync(fixturePath, "// Supplies isolated test data.\nexport function Fixture() { return {}; }\n"); + symbols = [ + symbol("a", "Api", apiPath), + symbol("b", "Store", storePath), + symbol("c", "Fixture", fixturePath), + ]; + communities = [ + { symbolId: "a", symbolName: "Api", filePath: apiPath, communityId: 1, communityLabel: "API", crossCommunityConnections: 1 }, + { symbolId: "b", symbolName: "Store", filePath: storePath, communityId: 2, communityLabel: "Storage", crossCommunityConnections: 1 }, + { symbolId: "c", symbolName: "Fixture", filePath: fixturePath, communityId: 3, communityLabel: "Tests", crossCommunityConnections: 0 }, + ]; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function build(input: Parameters[0] = {}) { + return buildArchitectureContext( + input, + communities, + [ + { symbolId: "a", symbolName: "Api", filePath: apiPath, callerCount: 2, calleeCount: 1, totalConnections: 3 }, + { symbolId: "b", symbolName: "Store", filePath: storePath, callerCount: 1, calleeCount: 2, totalConnections: 3 }, + ], + [{ + communityA: 1, + communityB: 2, + count: 2, + relationships: [{ + fromSymbolId: "a", + fromSymbolName: "Api", + fromFilePath: apiPath, + toSymbolId: "b", + toSymbolName: "Store", + toFilePath: storePath, + }], + }], + { + projectRoot: tempDir, + sourceSymbols: symbols, + graphCoverage: { totalEdges: 2, resolvedEdges: 1 }, + }, + ); + } + + it("is deterministic and derives responsibility evidence from cited source", () => { + const first = build(); + const second = build(); + expect(first).toEqual(second); - expect(first.text).toContain("Api (src/api.ts)"); + expect(first.text).toContain("Source-backed responsibility: Validates API tokens before requests enter the application."); + expect(first.text).toContain("Api at src/api.ts:2"); expect(first.text).toContain("Api (src/api.ts) -> Store (src/store.ts)"); - expect(first.text).toContain("Recommended next steps"); - expect(buildArchitectureContext({ includeRecentActivity: true }, communities, centrality, couplings, [], undefined, ["API moved recently: source-backed activity"]).text).toContain("Recent activity:"); + expect(first.text).toContain("implementation_lookup"); + expect(first.modules.every((module) => module.evidence.length > 0)).toBe(true); + expect(first.modules.every((module) => module.evidence.some((evidence) => evidence.excerpt))).toBe(true); }); - it("keeps a directory scope strict and reports sparse graph uncertainty", () => { - const result = buildArchitectureContext({ directory: "src/missing" }, communities, centrality, couplings); - expect(result.modules).toEqual([]); - expect(result.text).toContain("No graph symbols matched the requested scope"); + it("keeps relative directory scope strict against absolute indexed paths", () => { + const result = build({ directory: "src" }); + + expect(result.modules.map((module) => module.label)).toEqual(["API", "Storage"]); expect(result.text).not.toContain("Fixture"); + expect(result.boundaries).toHaveLength(1); + expect(result.boundaries[0]?.connections).toBe(1); + }); + + it("uses query evidence to exclude unrelated modules and does not substitute a global map on a miss", () => { + const focused = buildArchitectureContext( + { query: "token validation" }, + communities, + [], + [], + { projectRoot: tempDir, sourceSymbols: symbols, focusedSymbols: [symbols[0]] }, + ); + expect(focused.modules.map((module) => module.label)).toEqual(["API"]); + expect(focused.text).not.toContain("Store"); + expect(focused.text).not.toContain("Fixture"); + + const missed = buildArchitectureContext( + { query: "nonexistent subsystem" }, + communities, + [], + [], + { projectRoot: tempDir, sourceSymbols: symbols, focusedSymbols: [] }, + ); + expect(missed.modules).toEqual([]); + expect(missed.coverage.note).toContain("No global architecture is substituted"); + }); + + it("falls back to source-directory modules without inventing relationships when graph data is sparse", () => { + const result = buildArchitectureContext( + {}, + [], + [], + [], + { projectRoot: tempDir, sourceSymbols: symbols, graphCoverage: { totalEdges: 0, resolvedEdges: 0 } }, + ); + + expect(result.modules.length).toBeGreaterThan(0); + expect(result.modules.every((module) => module.source === "directory")).toBe(true); + expect(result.coverage.sourceFallback).toBe(true); + expect(result.coverage.graphSparse).toBe(true); + expect(result.boundaries).toEqual([]); + expect(result.text).toContain("no relationship is inferred"); + }); + + it("enforces the requested response token budget while preserving whole cited claims", () => { + writeFileSync(apiPath, `// ${"long responsibility evidence ".repeat(80)}\nexport function Api() { return true; }\n`); + const result = build({ tokenBudget: 256, depth: 3, includeRecentActivity: true }); + + expect(result.tokenBudget).toBe(256); + expect(result.tokenEstimate).toBe(estimateTokens(result.text)); + expect(result.tokenEstimate).toBeLessThanOrEqual(256); + expect(result.text).toContain("Recommended next steps:"); + }); + + it("renders optional recent activity with commit, date, summary, and files", () => { + const result = buildArchitectureContext( + { includeRecentActivity: true }, + communities, + [], + [], + { + projectRoot: tempDir, + sourceSymbols: symbols, + recentActivity: [{ + title: "API moved recently", + date: "2026-08-25", + commit: "abc1234", + summary: "12 changed lines across 1 indexed file.", + filePaths: [apiPath], + }], + }, + ); + + expect(result.text).toContain("[commit abc1234, 2026-08-25]"); + expect(result.text).toContain("Files: src/api.ts"); + expect(result.recentActivity[0]?.commit).toBe("abc1234"); + }); + + it("selects exact or overlapping symbols from high-ranked query evidence", () => { + const nested = symbol("nested", "nestedHelper", apiPath, 3); + const selected = selectArchitectureFocusedSymbols([ + { filePath: apiPath, startLine: 2, endLine: 2, score: 0.9, name: "Api" }, + { filePath: storePath, startLine: 2, endLine: 3, score: 0.8 }, + { filePath: fixturePath, startLine: 2, endLine: 3, score: 0.1 }, + ], [...symbols, nested], 2); + + expect(selected.map((item) => item.id)).toEqual(["a", "b"]); + }); + + it("reads actual Git activity and returns no graph-derived substitute", () => { + execFileSync("git", ["init", "-q", tempDir]); + execFileSync("git", ["-C", tempDir, "config", "user.email", "architecture@example.com"]); + execFileSync("git", ["-C", tempDir, "config", "user.name", "Architecture Test"]); + execFileSync("git", ["-C", tempDir, "add", "src/api.ts"]); + execFileSync("git", ["-C", tempDir, "commit", "-q", "-m", "feat: update API validation"]); + + const data = transformForVisualization([symbols[0]], [], { includeOrphans: true }); + const activity = getRecentGitActivity(data, tempDir); + + expect(activity).toHaveLength(1); + expect(activity[0]?.source).toMatch(/^commit [0-9a-f]+$/); + expect(activity[0]?.summary).toContain("Latest: feat: update API validation"); + expect(getRecentGitActivity(data, path.join(tempDir, "missing"))).toEqual([]); }); }); From fa0e43f9ae13208835ddc09e9c2260d50944fb29 Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 15:57:01 +0200 Subject: [PATCH 07/11] feat: evaluate architecture context quality and cost --- benchmarks/budgets/architecture-context.json | 16 +++ benchmarks/golden/architecture-context.json | 131 +++++++++++++++++++ package.json | 2 + src/eval/metrics.ts | 17 +++ src/eval/reports.ts | 12 ++ src/eval/runner.ts | 94 ++++++++++++- src/eval/schema.ts | 14 +- src/eval/types.ts | 7 +- tests/eval-metrics.test.ts | 30 ++++- tests/eval-reports.test.ts | 2 + tests/eval-runner.test.ts | 52 ++++++++ tests/eval-schema.test.ts | 50 ++++++- 12 files changed, 417 insertions(+), 10 deletions(-) create mode 100644 benchmarks/budgets/architecture-context.json create mode 100644 benchmarks/golden/architecture-context.json diff --git a/benchmarks/budgets/architecture-context.json b/benchmarks/budgets/architecture-context.json new file mode 100644 index 00000000..4fed1a2a --- /dev/null +++ b/benchmarks/budgets/architecture-context.json @@ -0,0 +1,16 @@ +{ + "name": "architecture-context-eval-budget", + "failOnMissingBaseline": false, + "thresholds": { + "p95LatencyMaxAbsoluteMs": 5000, + "minHitAt5": 0.75, + "minMrrAt10": 0.5, + "maxContextResponseTokensAverage": 1000, + "maxContextResponseTokensP95": 1000, + "maxContextResponseTokensMax": 1000, + "maxContextDuplicateCandidateRatio": 0.5, + "minContextSelectedFileRatio": 0.3, + "minContextHitAt5Per1kResponseTokens": 0.75, + "minContextMrrAt10Per1kResponseTokens": 0.5 + } +} diff --git a/benchmarks/golden/architecture-context.json b/benchmarks/golden/architecture-context.json new file mode 100644 index 00000000..cf187200 --- /dev/null +++ b/benchmarks/golden/architecture-context.json @@ -0,0 +1,131 @@ +{ + "version": "1.0.0", + "name": "architecture-context", + "description": "Repository planning queries evaluated through source-backed architecture_context evidence and response token cost.", + "queries": [ + { + "id": "architecture-portable-tool", + "query": "map the portable architecture_context operation and how host adapters execute it", + "queryType": "architecture", + "retrievalMode": "architecture", + "difficulty": "medium", + "args": { + "directory": "src", + "depth": 3, + "tokenBudget": 1000 + }, + "tags": ["architecture", "planning", "portable-tools"], + "expected": { + "gradedEvidence": [ + { + "path": "src/tools/architecture-context.ts", + "relevance": 3 + }, + { + "path": "src/tools/operations.ts", + "relevance": 3 + }, + { + "path": "src/tools/execute-common.ts", + "relevance": 2 + }, + { + "path": "src/adapters/mcp/register-tools.ts", + "relevance": 1 + } + ] + } + }, + { + "id": "architecture-evaluation-pipeline", + "query": "plan a change to evaluation routing, golden dataset parsing, relevance metrics, and token budgets", + "queryType": "architecture", + "retrievalMode": "architecture", + "difficulty": "hard", + "args": { + "directory": "src/eval", + "depth": 3, + "tokenBudget": 1000 + }, + "tags": ["architecture", "planning", "evaluation"], + "expected": { + "gradedEvidence": [ + { + "path": "src/eval/runner.ts", + "relevance": 3 + }, + { + "path": "src/eval/schema.ts", + "relevance": 2 + }, + { + "path": "src/eval/metrics.ts", + "relevance": 2 + }, + { + "path": "src/eval/budget.ts", + "relevance": 1 + } + ] + } + }, + { + "id": "architecture-mcp-boundary", + "query": "understand MCP server tool registration and shared execution boundaries before adding a portable tool", + "queryType": "architecture", + "retrievalMode": "architecture", + "difficulty": "medium", + "args": { + "directory": "src/adapters/mcp", + "depth": 2, + "tokenBudget": 900 + }, + "tags": ["architecture", "planning", "mcp"], + "expected": { + "gradedEvidence": [ + { + "path": "src/adapters/mcp/register-tools.ts", + "relevance": 3 + }, + { + "path": "src/adapters/mcp/server.ts", + "relevance": 2 + }, + { + "path": "src/adapters/mcp/shared.ts", + "relevance": 1 + } + ] + } + }, + { + "id": "architecture-indexing-boundaries", + "query": "map indexing orchestration, embedding batches, and native persistence boundaries before changing indexing", + "queryType": "architecture", + "retrievalMode": "architecture", + "difficulty": "hard", + "args": { + "directory": "src/indexer", + "depth": 3, + "tokenBudget": 1000 + }, + "tags": ["architecture", "planning", "indexing"], + "expected": { + "gradedEvidence": [ + { + "path": "src/indexer/index.ts", + "relevance": 3 + }, + { + "path": "src/indexer/embedding-batches.ts", + "relevance": 2 + }, + { + "path": "src/indexer/search-ranking.ts", + "relevance": 1 + } + ] + } + } + ] +} diff --git a/package.json b/package.json index 47330954..c26e39f4 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "dev": "tsup --watch", "eval": "npx tsx src/cli.ts eval run", "eval:agent": "npx tsx src/cli.ts eval run --dataset benchmarks/golden/agent-context.json", + "eval:architecture": "npx tsx src/cli.ts eval run --dataset benchmarks/golden/architecture-context.json", + "eval:architecture:ci": "npx tsx src/cli.ts eval run --config .github/eval-config.json --reindex --dataset benchmarks/golden/architecture-context.json --ci --budget benchmarks/budgets/architecture-context.json", "eval:pre-edit": "npx tsx src/cli.ts eval run --dataset benchmarks/golden/pre-edit-context.json", "eval:representative": "npx tsx src/cli.ts eval run --dataset benchmarks/golden/representative.json", "eval:representative:ollama": "npx tsx src/cli.ts eval run --config .github/eval-ollama-full-config.json --reindex --dataset benchmarks/golden/representative.json --budget benchmarks/budgets/representative.json", diff --git a/src/eval/metrics.ts b/src/eval/metrics.ts index 61ecf50d..09d05543 100644 --- a/src/eval/metrics.ts +++ b/src/eval/metrics.ts @@ -8,6 +8,7 @@ import type { GoldenGradedEvidence, GoldenQuery, PerQueryEvalResult, + GoldenRetrievalMode, } from "./types.js"; function percentile(values: number[], p: number): number { @@ -435,6 +436,12 @@ export function computeEvalMetrics( hitAt10: 0, mrrAt10: 0, ndcgAt10: 0, + retrievalModeCounts: { + search: 0, + context: 0, + "edit-context": 0, + architecture: 0, + } satisfies Partial>, distinctTop3Ratio: 0, rawDistinctTop3Ratio: 0, }; @@ -462,6 +469,15 @@ export function computeEvalMetrics( let graphNeighborExpectedCount = 0; for (const query of perQuery) { + if ( + query.retrievalMode === "search" + || query.retrievalMode === "context" + || query.retrievalMode === "edit-context" + || query.retrievalMode === "architecture" + ) { + sum.retrievalModeCounts[query.retrievalMode] += 1; + } + if (positiveQueryIds.has(query.id)) { if (query.hitAt1) sum.hitAt1 += 1; if (query.hitAt3) sum.hitAt3 += 1; @@ -505,6 +521,7 @@ export function computeEvalMetrics( hitAt10: safePositiveDiv(sum.hitAt10), mrrAt10: safePositiveDiv(sum.mrrAt10), ndcgAt10: safePositiveDiv(sum.ndcgAt10), + retrievalModeCounts: sum.retrievalModeCounts, routeAccuracy: routeExpectedCount === 0 ? 0 : routeMatchedCount / routeExpectedCount, outcomeAccuracy: outcomeExpectedCount === 0 ? 0 : outcomeMatchedCount / outcomeExpectedCount, recoveryAccuracy: recoveryExpectedCount === 0 ? 0 : recoveryMatchedCount / recoveryExpectedCount, diff --git a/src/eval/reports.ts b/src/eval/reports.ts index 88ef3d29..dba1cb47 100644 --- a/src/eval/reports.ts +++ b/src/eval/reports.ts @@ -69,6 +69,18 @@ export function createSummaryMarkdown( ); lines.push(""); + if (summary.metrics.retrievalModeCounts) { + lines.push("## Retrieval Mode Distribution"); + lines.push(""); + lines.push("| Mode | Count |"); + lines.push("|---|---:|"); + for (const mode of ["search", "context", "edit-context", "architecture"] as const) { + const count = summary.metrics.retrievalModeCounts[mode] ?? 0; + lines.push(`| ${mode} | ${count} |`); + } + lines.push(""); + } + lines.push("## Metrics"); lines.push(""); lines.push("| Metric | Value |"); diff --git a/src/eval/runner.ts b/src/eval/runner.ts index 9d04dc1b..383c2896 100644 --- a/src/eval/runner.ts +++ b/src/eval/runner.ts @@ -10,6 +10,7 @@ import { resolveSearchContext } from "../tools/context.js"; import { resolveCodebaseEditContextWithDependencies } from "../tools/edit-context.js"; import { getCallGraphDataForIndexer, + getArchitectureContextForIndexer, type CallGraphDataResult, type CallGraphSymbolResolution, } from "../tools/operations.js"; @@ -141,6 +142,91 @@ function calleeResult(edge: CallEdgeData, symbols: SymbolData[]): EvalSearchResu }; } +function architectureEvidenceResults( + result: Awaited>, +): { results: EvalSearchResult[]; candidateCount: number } { + const candidates: EvalSearchResult[] = []; + for (const module of result.modules) { + for (const evidence of module.evidence) { + candidates.push({ + filePath: evidence.filePath, + startLine: evidence.line || undefined, + endLine: evidence.line || undefined, + score: 1, + chunkType: "architecture-module", + name: evidence.symbol, + }); + } + } + for (const boundary of result.boundaries) { + for (const evidence of boundary.evidence) { + candidates.push({ + filePath: evidence.fromFilePath, + score: 0.75, + chunkType: "architecture-boundary", + name: evidence.fromSymbol, + }); + candidates.push({ + filePath: evidence.toFilePath, + score: 0.75, + chunkType: "architecture-boundary", + name: evidence.toSymbol, + }); + } + } + for (const hub of result.hubs) { + candidates.push({ + filePath: hub.filePath, + score: 0.5, + chunkType: "architecture-hub", + name: hub.symbol, + }); + } + + const seen = new Set(); + const results = candidates.filter((candidate) => { + const key = `${normalizedPath(candidate.filePath)}::${candidate.name ?? ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return { results, candidateCount: candidates.length }; +} + +async function runArchitectureContextQuery( + indexer: Indexer, + projectRoot: string, + query: GoldenQuery, +): Promise<{ + results: EvalSearchResult[]; + context: { + tokenBudget: number; + responseTokens: number; + candidateCount: number; + deduplicatedCount: number; + omittedCount: number; + }; +}> { + const architecture = await getArchitectureContextForIndexer(indexer, projectRoot, { + query: query.query, + directory: query.args?.directory, + depth: query.args?.depth, + tokenBudget: query.args?.tokenBudget, + includeRecentActivity: false, + }); + const evidence = architectureEvidenceResults(architecture); + return { + results: evidence.results, + context: { + tokenBudget: architecture.tokenBudget, + responseTokens: architecture.tokenEstimate, + candidateCount: evidence.candidateCount, + deduplicatedCount: evidence.results.length, + omittedCount: Object.values(architecture.omitted).reduce((total, value) => total + value, 0), + }, + }; +} + async function runEditContextQuery( indexer: Indexer, projectRoot: string, @@ -291,6 +377,9 @@ export async function runEvaluation(options: EvalRunOptions): Promise 3) { + throw new Error(`${path}.depth must be at most 3`); + } const tokenBudget = parsePositiveIntegerOrUndefined(value.tokenBudget, `${path}.tokenBudget`); return { ...(symbol !== undefined ? { symbol } : {}), @@ -134,6 +139,7 @@ function parseQueryArgs(value: unknown, path: string): GoldenQueryArgs | undefin ...(directory !== undefined ? { directory } : {}), ...(callerLimit !== undefined ? { callerLimit } : {}), ...(calleeLimit !== undefined ? { calleeLimit } : {}), + ...(depth !== undefined ? { depth } : {}), ...(tokenBudget !== undefined ? { tokenBudget } : {}), }; } @@ -162,8 +168,8 @@ function parseSemanticVersion(value: unknown, path: string): string { function parseRetrievalMode(value: unknown, path: string): GoldenRetrievalMode { if (value === undefined || value === "search") return "search"; - if (value === "context" || value === "edit-context") return value; - throw new Error(`${path} must be one of: search, context, edit-context`); + if (value === "context" || value === "edit-context" || value === "architecture") return value; + throw new Error(`${path} must be one of: search, context, edit-context, architecture`); } function parseStringOrUndefined(value: unknown, path: string): string | undefined { diff --git a/src/eval/types.ts b/src/eval/types.ts index 0aba2ffd..9e371305 100644 --- a/src/eval/types.ts +++ b/src/eval/types.ts @@ -5,12 +5,13 @@ export type GoldenQueryType = | "implementation-intent" | "similarity" | "keyword-heavy" - | "conceptual"; + | "conceptual" + | "architecture"; export type GoldenQueryDifficulty = "easy" | "medium" | "hard"; export type GoldenQueryExpectedOutcome = "results" | "no-results"; export type GoldenQueryRecoveryExpectation = "none" | "filter-relaxed"; -export type GoldenRetrievalMode = "search" | "context" | "edit-context"; +export type GoldenRetrievalMode = "search" | "context" | "edit-context" | "architecture"; export type EvalResolvedRoute = "search" | "definition"; export type GoldenExpectedRoute = "search" | "definition"; export type GoldenGraphNeighborDirection = "caller" | "callee"; @@ -28,6 +29,7 @@ export interface GoldenQueryArgs { directory?: string; callerLimit?: number; calleeLimit?: number; + depth?: number; tokenBudget?: number; } @@ -169,6 +171,7 @@ export interface EvalMetrics { outcomeAccuracy: number; recoveryAccuracy: number; graphNeighborRecall?: number; + retrievalModeCounts?: Partial>; distinctTop3Ratio: number; rawDistinctTop3Ratio: number; latencyMs: { diff --git a/tests/eval-metrics.test.ts b/tests/eval-metrics.test.ts index d5f6a5be..de97cb8a 100644 --- a/tests/eval-metrics.test.ts +++ b/tests/eval-metrics.test.ts @@ -526,15 +526,43 @@ describe("eval metrics", () => { 10, 10, ), + buildPerQueryResult( + query({ id: "q-architecture", queryType: "architecture", retrievalMode: "architecture" }), + [{ + filePath: "/repo/src/indexer/index.ts", + startLine: 1, + endLine: 2, + score: 1, + chunkType: "architecture-module", + name: "rankHybridResults", + }], + 10, + 10, + undefined, + { + tokenBudget: 800, + responseTokens: 320, + candidateCount: 2, + deduplicatedCount: 1, + omittedCount: 1, + }, + ), ]; - const metrics = computeEvalMetrics([query({ id: "q-search" }), query({ id: "q-context", retrievalMode: "context" }), query({ id: "q-edit", retrievalMode: "edit-context" })], perQuery, 0, 0, 0); + const metrics = computeEvalMetrics([ + query({ id: "q-search" }), + query({ id: "q-context", retrievalMode: "context" }), + query({ id: "q-edit", retrievalMode: "edit-context" }), + query({ id: "q-architecture", queryType: "architecture", retrievalMode: "architecture" }), + ], perQuery, 0, 0, 0); expect(metrics.retrievalModeCounts).toEqual({ search: 1, context: 1, "edit-context": 1, + architecture: 1, }); + expect(perQuery.at(-1)).toMatchObject({ tokenBudget: 800, responseTokens: 320 }); }); it("measures context response tokens, candidate compression, and quality per token", () => { diff --git a/tests/eval-reports.test.ts b/tests/eval-reports.test.ts index 6141243d..6af8c414 100644 --- a/tests/eval-reports.test.ts +++ b/tests/eval-reports.test.ts @@ -48,6 +48,7 @@ describe("eval reports", () => { search: 30, context: 7, "edit-context": 3, + architecture: 4, }; const summaryPath = path.join(tempDir, "summary.json"); writeFileSync(summaryPath, JSON.stringify(source), "utf-8"); @@ -58,5 +59,6 @@ describe("eval reports", () => { expect(createSummaryMarkdown(summary)).toContain("| search | 30 |"); expect(createSummaryMarkdown(summary)).toContain("| context | 7 |"); expect(createSummaryMarkdown(summary)).toContain("| edit-context | 3 |"); + expect(createSummaryMarkdown(summary)).toContain("| architecture | 4 |"); }); }); diff --git a/tests/eval-runner.test.ts b/tests/eval-runner.test.ts index 2bd8eb21..615f7931 100644 --- a/tests/eval-runner.test.ts +++ b/tests/eval-runner.test.ts @@ -150,6 +150,58 @@ describe("eval runner", () => { expect(repeatRun.summary.datasetFingerprint).toBe(result.summary.datasetFingerprint); }); + it("executes architecture_context queries and measures cited evidence relevance and response tokens", async () => { + writeFileSync( + path.join(tempDir, "benchmarks", "golden", "architecture.json"), + JSON.stringify({ + version: "1.0.0", + name: "architecture", + queries: [{ + id: "architecture-indexer", + query: "map the indexer architecture before changing result ranking", + queryType: "architecture", + retrievalMode: "architecture", + args: { directory: "src/indexer", depth: 2, tokenBudget: 512 }, + expected: { + gradedEvidence: [{ + path: "src/indexer/index.ts", + symbol: "rankHybridResults", + relevance: 3, + }], + }, + }], + }, null, 2), + "utf-8", + ); + + const runtimeCacheSpy = vi.spyOn(operationRuntime, "getIndexerForProject"); + const result = await runEvaluation({ + projectRoot: tempDir, + datasetPath: "benchmarks/golden/architecture.json", + outputRoot: "benchmarks/results", + ciMode: false, + reindex: false, + }); + + expect(runtimeCacheSpy).not.toHaveBeenCalled(); + runtimeCacheSpy.mockRestore(); + expect(result.perQuery[0]).toMatchObject({ + retrievalMode: "architecture", + queryType: "architecture", + hitAt1: true, + tokenBudget: 512, + }); + expect(result.perQuery[0]?.responseTokens).toBeGreaterThan(0); + expect(result.perQuery[0]?.responseTokens).toBeLessThanOrEqual(512); + expect(result.perQuery[0]?.results[0]).toMatchObject({ + filePath: "src/indexer/index.ts", + name: "rankHybridResults", + chunkType: "architecture-module", + }); + expect(result.summary.metrics.retrievalModeCounts?.architecture).toBe(1); + expect(result.summary.metrics.contextEfficiency.responseTokens.max).toBeLessThanOrEqual(512); + }); + it("evaluates edit-context targets and only scores published graph neighbors when expected", async () => { writeFileSync( path.join(tempDir, "src", "indexer", "index.ts"), diff --git a/tests/eval-schema.test.ts b/tests/eval-schema.test.ts index d7da9b94..4e53bbc3 100644 --- a/tests/eval-schema.test.ts +++ b/tests/eval-schema.test.ts @@ -76,6 +76,54 @@ describe("eval schema", () => { expect(dataset.queries[0]?.retrievalMode).toBe("context"); }); + it("parses architecture planning queries with bounded depth and token cost", () => { + const dataset = parseGoldenDataset( + { + version: "1.0.0", + name: "architecture-context", + queries: [{ + id: "architecture-map", + query: "map the indexing and retrieval architecture before planning a change", + queryType: "architecture", + retrievalMode: "architecture", + args: { + directory: "src/indexer", + depth: 3, + tokenBudget: 900, + }, + expected: { + gradedEvidence: [{ path: "src/indexer/index.ts", symbol: "Indexer", relevance: 3 }], + }, + }], + }, + "dataset.json", + ); + + expect(dataset.queries[0]).toMatchObject({ + queryType: "architecture", + retrievalMode: "architecture", + args: { directory: "src/indexer", depth: 3, tokenBudget: 900 }, + }); + }); + + it("rejects architecture depth above the public tool limit", () => { + expect(() => parseGoldenDataset( + { + version: "1.0.0", + name: "invalid-architecture", + queries: [{ + id: "architecture-map", + query: "map the architecture", + queryType: "architecture", + retrievalMode: "architecture", + args: { depth: 4 }, + expected: { filePath: "src/indexer/index.ts" }, + }], + }, + "dataset.json", + )).toThrow(/args\.depth must be at most 3/); + }); + it("parses edit-context queries with a direct graph-neighbor expectation", () => { const dataset = parseGoldenDataset( { @@ -163,7 +211,7 @@ describe("eval schema", () => { ], }, "dataset.json", - )).toThrow(/retrievalMode.*search, context, edit-context/); + )).toThrow(/retrievalMode.*search, context, edit-context, architecture/); }); it("rejects dataset with missing expected path", () => { From 88356b92e3dffcd6ec60a8ae425d1b50dd8699f1 Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 15:57:10 +0200 Subject: [PATCH 08/11] test: execute architecture context across hosts --- src/adapters/pi/extension.ts | 6 +- tests/architecture-context-adapters.test.ts | 120 ++++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 tests/architecture-context-adapters.test.ts diff --git a/src/adapters/pi/extension.ts b/src/adapters/pi/extension.ts index 2a1498e7..af79e646 100644 --- a/src/adapters/pi/extension.ts +++ b/src/adapters/pi/extension.ts @@ -6,6 +6,7 @@ import { loadMergedConfig } from "../../config/merger.js"; import { formatCostEstimate, formatDryRunEstimate } from "../../utils/cost.js"; import { formatPrImpact } from "../../tools/format-pr-impact.js"; import { formatCodeCommunities } from "../../tools/format-communities.js"; +import { executeArchitectureContext } from "../../tools/execute-common.js"; import { addKnowledgeBase, findSimilarCode, @@ -15,7 +16,6 @@ import { getPrImpact, getIndexerForProject, getCodeCommunities, - getArchitectureContext, implementationLookup, isExactSymbolQuery, listKnowledgeBases, @@ -416,8 +416,8 @@ export default function codebaseIndexPiExtension(pi: ExtensionAPI): void { tokenBudget: Type.Optional(Type.Integer({ minimum: 128, maximum: 4000, default: 1200 })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const result = await getArchitectureContext(projectRoot(ctx), HOST, params); - return text(result.text, result); + const result = await executeArchitectureContext(projectRoot(ctx), HOST, params); + return text(result.text, result.details); }, }); diff --git a/tests/architecture-context-adapters.test.ts b/tests/architecture-context-adapters.test.ts new file mode 100644 index 00000000..d1b48fcd --- /dev/null +++ b/tests/architecture-context-adapters.test.ts @@ -0,0 +1,120 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const executeArchitectureContext = vi.hoisted(() => vi.fn()); + +vi.mock("../src/tools/execute-common.js", async (importOriginal) => ({ + ...await importOriginal(), + executeArchitectureContext, +})); + +import codebaseIndexPiExtension from "../src/pi-extension.js"; +import { registerMcpTools } from "../src/adapters/mcp/register-tools.js"; +import { architecture_context } from "../src/adapters/opencode/tools.js"; +import { TOOL_NAME } from "../src/tools/tool-names.js"; + +interface RegisteredPiTool { + name: string; + execute: ( + toolCallId: string, + params: Record, + signal: AbortSignal, + onUpdate: () => void, + context: { cwd?: string }, + ) => Promise<{ content: Array<{ type: string; text: string }>; details?: unknown }>; +} + +const args = { + query: "authentication architecture", + directory: "src/auth", + depth: 3, + includeRecentActivity: true, + tokenBudget: 900, +}; + +const details = { + modules: [{ + id: "community-1", + label: "Auth", + symbolCount: 1, + source: "community", + evidence: [{ + symbolId: "auth", + symbol: "validateToken", + filePath: "src/auth/token.ts", + line: 4, + excerpt: "Validates authentication tokens.", + }], + }], + boundaries: [], + hubs: [], + recentActivity: [], + coverage: { + symbols: 1, + communities: 1, + scoped: true, + graphSparse: true, + sourceFallback: false, + note: "No resolved cross-module coupling was available in this scope.", + }, + recommendations: ["implementation_lookup"], + tokenBudget: 900, + tokenEstimate: 120, + omitted: { modules: 0, boundaries: 0, hubs: 0, recentActivity: 0 }, +}; + +describe("architecture_context host execution contracts", () => { + beforeEach(() => { + executeArchitectureContext.mockReset().mockResolvedValue({ + text: "source-backed architecture response", + details, + }); + }); + + it("executes the same portable contract through OpenCode, MCP, and Pi", async () => { + const openCodeResult = await architecture_context.execute( + args, + { worktree: "/repo/opencode" } as Parameters[1], + ); + expect(openCodeResult).toBe("source-backed architecture response"); + expect(executeArchitectureContext).toHaveBeenNthCalledWith(1, "/repo/opencode", "opencode", args); + + const mcpServer = new McpServer({ name: "architecture-contract", version: "1.0.0" }); + registerMcpTools(mcpServer, { projectRoot: "/repo/mcp", host: "codex" }); + const client = new Client({ name: "architecture-contract-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + try { + const mcpResult = await client.callTool({ name: TOOL_NAME.ARCHITECTURE_CONTEXT, arguments: args }); + expect(mcpResult.content).toEqual([{ type: "text", text: "source-backed architecture response" }]); + } finally { + await client.close(); + await mcpServer.close(); + } + expect(executeArchitectureContext).toHaveBeenNthCalledWith(2, "/repo/mcp", "codex", args); + + const piTools = new Map(); + codebaseIndexPiExtension({ + registerTool(tool) { + piTools.set(tool.name, tool as unknown as RegisteredPiTool); + }, + on() {}, + } as Pick); + const piResult = await piTools.get(TOOL_NAME.ARCHITECTURE_CONTEXT)!.execute( + "architecture-call", + args, + new AbortController().signal, + () => {}, + { cwd: "/repo/pi" }, + ); + + expect(piResult.content).toEqual([{ type: "text", text: "source-backed architecture response" }]); + expect(piResult.details).toEqual(details); + expect(executeArchitectureContext).toHaveBeenNthCalledWith(3, "/repo/pi", "pi", args); + }); +}); From 05cc7c3315ae1722ef6c20cdf9f418d76ed0e459 Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 15:57:46 +0200 Subject: [PATCH 09/11] docs: explain source-backed architecture context --- CHANGELOG.md | 4 ++++ docs/tools.md | 25 +++++++++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3959d5..531d5418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Source-backed architecture context**: Added the portable `architecture_context` tool across OpenCode, MCP, and Pi. It produces deterministic, token-bounded repository maps with source-derived responsibility excerpts, cited community and boundary evidence, strict query and directory focus, graph-sparse source-directory fallback, uncertainty notes, precise follow-up tool calls, and optional Git-backed recent activity. Architecture planning evaluation now measures graded evidence relevance and actual response token cost. + ## [0.25.1] - 2026-08-23 ### Fixed diff --git a/docs/tools.md b/docs/tools.md index ee1fa263..ba7e4c97 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -73,21 +73,30 @@ Pi does not expose the shared knowledge-base names. It registers equivalent tool - `knowledge_base_add` - `knowledge_base_remove` -Pi exposes all 15 portable tools, including `call_graph` and `call_graph_path`, plus its three host-specific knowledge-base aliases. +Pi exposes all 16 portable tools, including `architecture_context`, `call_graph`, and `call_graph_path`, plus its three host-specific knowledge-base aliases. ## Recommended selection order 1. `index_status` when index readiness is unknown. -2. `codebase_context` for a repository question that may require discovery, a definition, or a dependency path. -3. `codebase_edit_context` optionally when a broad change request already has a known or suspected target symbol, for compact pre-edit source plus caller/callee context. -4. `codebase_peek` for direct low-token location discovery. -5. `implementation_lookup` for a known symbol or definition question. -6. `codebase_search` when full matching source content is required. -7. `grep` for exact identifiers or exhaustive text matches. -8. `call_graph` or `call_graph_path` for graph-specific questions. +2. `architecture_context` before repository-scale planning when module responsibilities and boundaries are not yet known. +3. `codebase_context` for a repository question that may require discovery, a definition, or a dependency path. +4. `codebase_edit_context` optionally when a broad change request already has a known or suspected target symbol, for compact pre-edit source plus caller/callee context. +5. `codebase_peek` for direct low-token location discovery. +6. `implementation_lookup` for a known symbol or definition question. +7. `codebase_search` when full matching source content is required. +8. `grep` for exact identifiers or exhaustive text matches. +9. `call_graph` or `call_graph_path` for graph-specific questions. ## Core retrieval tools +### `architecture_context` + +Use `architecture_context` when an agent needs a concise repository map before focused retrieval or edits. Each module includes a responsibility excerpt derived from readable source and exact symbol/file/line citations. Cross-module boundaries include representative source and target symbols, while missing or sparse graph coverage is reported explicitly instead of inferred. + +`query` and `directory` constrain which modules can consume the response budget. `depth` controls detail from 1 to 3, and `tokenBudget` is enforced from 128 to 4000 estimated tokens without cutting claims or citations mid-entry. When community data is unavailable, the tool can still group matching indexed symbols by source directory, but it labels that fallback and does not invent relationships. + +Set `includeRecentActivity: true` to include matching Git activity from the last 90 days with commit, date, summary, and file provenance. If no matching Git history exists, the tool reports that directly and does not substitute graph importance as recent activity. + ### `codebase_context` Preferred entry point for general repository questions. It returns a bounded, deduplicated, file-diverse evidence pack. It can also route explicit symbol definitions or `from`/`to` dependency-path requests. From a0b0320b6990e4d17dae3c94417715a45ad07096 Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 16:02:07 +0200 Subject: [PATCH 10/11] fix: bound architecture focus input cost --- src/tools/architecture-context.ts | 31 +++++++++++++++++++++++------- tests/architecture-context.test.ts | 18 +++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/tools/architecture-context.ts b/src/tools/architecture-context.ts index 38734c96..ae7b5b02 100644 --- a/src/tools/architecture-context.ts +++ b/src/tools/architecture-context.ts @@ -113,6 +113,12 @@ function normalizePath(value: string): string { return value.trim().replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""); } +function compactText(value: string, maxLength: number): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, Math.max(1, maxLength - 1))}…`; +} + function displayPath(filePath: string, projectRoot?: string): string { const normalizedFilePath = normalizePath(filePath); if (!projectRoot) return normalizedFilePath; @@ -422,9 +428,10 @@ function renderArchitectureText( recentActivityUnavailable: boolean, ): string { const lines = ["→ Architecture context"]; - if (input.query?.trim()) lines.push(`Focus: ${input.query.trim()}`); + const minimumBudget = (input.tokenBudget ?? ARCHITECTURE_CONTEXT_DEFAULT_TOKEN_BUDGET) <= 160; + if (input.query?.trim()) lines.push(`Focus: ${compactText(input.query, minimumBudget ? 32 : 64)}`); lines.push( - `Coverage: ${coverage.symbols} scoped symbols across ${coverage.communities} modules${input.directory ? ` in ${normalizePath(input.directory)}` : ""}.`, + `Coverage: ${coverage.symbols} scoped symbols across ${coverage.communities} modules${input.directory ? ` in ${compactText(normalizePath(input.directory), minimumBudget ? 24 : 48)}` : ""}.`, `Uncertainty: ${coverage.note}`, ); @@ -481,19 +488,29 @@ function recommendationsFor( graphSparse: boolean, ): string[] { const evidence = modules[0]?.evidence[0]; - const directory = input.directory?.trim() || undefined; + const maxArgumentLength = (input.tokenBudget ?? ARCHITECTURE_CONTEXT_DEFAULT_TOKEN_BUDGET) <= 160 ? 24 : 160; + const directory = input.directory?.trim() + ? compactText(input.directory, maxArgumentLength) + : undefined; const recommendations: string[] = []; if (evidence) { recommendations.push(`implementation_lookup ${JSON.stringify({ - query: evidence.symbol, - directory: path.posix.dirname(evidence.filePath), + query: compactText(evidence.symbol, maxArgumentLength), + directory: compactText(path.posix.dirname(evidence.filePath), maxArgumentLength), })}`); if (!graphSparse) { - recommendations.push(`call_graph ${JSON.stringify({ name: evidence.symbol, filePath: evidence.filePath, direction: "callees" })}`); + recommendations.push(`call_graph ${JSON.stringify({ + name: compactText(evidence.symbol, maxArgumentLength), + filePath: compactText(evidence.filePath, maxArgumentLength), + direction: "callees", + })}`); } } recommendations.push(`codebase_context ${JSON.stringify({ - query: input.query?.trim() || (evidence ? `Understand ${evidence.symbol} and its module` : "Locate the repository subsystem to inspect"), + query: compactText( + input.query?.trim() || (evidence ? `Understand ${evidence.symbol} and its module` : "Locate the repository subsystem to inspect"), + maxArgumentLength, + ), ...(directory ? { directory } : {}), tokenBudget: Math.min(1200, input.tokenBudget ?? ARCHITECTURE_CONTEXT_DEFAULT_TOKEN_BUDGET), })}`); diff --git a/tests/architecture-context.test.ts b/tests/architecture-context.test.ts index 9e756a56..2bffd2ba 100644 --- a/tests/architecture-context.test.ts +++ b/tests/architecture-context.test.ts @@ -171,6 +171,24 @@ describe("architecture_context", () => { expect(result.text).toContain("Recommended next steps:"); }); + it("keeps minimum token budgets bounded for adversarially long focus inputs", () => { + const result = buildArchitectureContext( + { + query: "architecture focus ".repeat(100), + directory: `src/${"nested/".repeat(100)}`, + tokenBudget: 128, + includeRecentActivity: true, + }, + [], + [], + [], + ); + + expect(result.tokenEstimate).toBeLessThanOrEqual(128); + expect(result.text).toContain("…"); + expect(result.text).toContain("No global architecture is substituted"); + }); + it("renders optional recent activity with commit, date, summary, and files", () => { const result = buildArchitectureContext( { includeRecentActivity: true }, From 6c44c5e64d739dcf8c2e4e2b42c6aae6bd0369e6 Mon Sep 17 00:00:00 2001 From: Helweg Date: Tue, 25 Aug 2026 16:20:17 +0200 Subject: [PATCH 11/11] fix: resolve architecture source excerpts from project root --- src/tools/architecture-context.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tools/architecture-context.ts b/src/tools/architecture-context.ts index ae7b5b02..b34ab3a7 100644 --- a/src/tools/architecture-context.ts +++ b/src/tools/architecture-context.ts @@ -273,7 +273,10 @@ function sourceEvidence( let lines = fileCache.get(symbol.filePath); if (lines === undefined) { try { - lines = readFileSync(symbol.filePath, "utf8").split(/\r?\n/); + const sourcePath = projectRoot && !path.isAbsolute(symbol.filePath) + ? path.resolve(projectRoot, symbol.filePath) + : symbol.filePath; + lines = readFileSync(sourcePath, "utf8").split(/\r?\n/); } catch { lines = null; }