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/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/docs/tools.md b/docs/tools.md index 04f1e204..ba7e4c97 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: @@ -72,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. 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/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.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/src/adapters/opencode/tools.ts b/src/adapters/opencode/tools.ts index 0c3b158a..f769b9ec 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("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) { + 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..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, @@ -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 executeArchitectureContext(projectRoot(ctx), HOST, params); + return text(result.text, result.details); + }, + }); + pi.registerTool({ name: TOOL_NAME.CODE_COMMUNITIES, label: "Code Communities", 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/src/tools/architecture-context.ts b/src/tools/architecture-context.ts new file mode 100644 index 00000000..b34ab3a7 --- /dev/null +++ b/src/tools/architecture-context.ts @@ -0,0 +1,721 @@ +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; +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 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: 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 }>; + 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; +} + +interface ArchitectureModuleCandidate extends ArchitectureContextModule { + communityId?: number; +} + +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 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; + 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 { + const sourcePath = projectRoot && !path.isAbsolute(symbol.filePath) + ? path.resolve(projectRoot, symbol.filePath) + : symbol.filePath; + lines = readFileSync(sourcePath, "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[], + 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 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 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, + 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"]; + 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 ${compactText(normalizePath(input.directory), minimumBudget ? 24 : 48)}` : ""}.`, + `Uncertainty: ${coverage.note}`, + ); + + for (const module of modules) { + 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} 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`); + } + } + + 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 lines.join("\n"); +} + +function recommendationsFor( + input: ArchitectureContextInput, + modules: ArchitectureModuleCandidate[], + graphSparse: boolean, +): string[] { + const evidence = modules[0]?.evidence[0]; + 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: compactText(evidence.symbol, maxArgumentLength), + directory: compactText(path.posix.dirname(evidence.filePath), maxArgumentLength), + })}`); + if (!graphSparse) { + 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: 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), + })}`); + 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/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..28320242 100644 --- a/src/tools/operations.ts +++ b/src/tools/operations.ts @@ -7,6 +7,13 @@ 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, + isArchitecturePathInDirectory, + selectArchitectureFocusedSymbols, +} from "./architecture-context.js"; +import { getRecentGitActivity } from "./visualize/activity.js"; +import { transformForVisualization } from "./visualize/transform.js"; import { CODE_COMMUNITIES_DEFAULT_COUPLING_LIMIT, CODE_COMMUNITIES_DEFAULT_HUB_THRESHOLD, @@ -16,7 +23,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 +910,82 @@ export { getSharedIndexer, formatStatus, }; + +export async function getArchitectureContext( + projectRoot: string | undefined, + host: HostMode, + params: SharedArchitectureContextArgs, +): 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(), + indexer.detectCommunityCouplings(), + indexer.getVisualizationData({ directory: params.directory ?? undefined }), + ]); + let focusedSymbols: SymbolData[] = []; + if (params.query?.trim()) { + const results = await indexer.search(params.query.trim(), 24, { + metadataOnly: true, + directory: params.directory ?? undefined, + prioritizeSourcePaths: true, + }); + 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 + ? 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, { + 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/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/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-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); + }); +}); diff --git a/tests/architecture-context.test.ts b/tests/architecture-context.test.ts new file mode 100644 index 00000000..2bffd2ba --- /dev/null +++ b/tests/architecture-context.test.ts @@ -0,0 +1,242 @@ +import type { SymbolData } from "../src/native/index.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", () => { + 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("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("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 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("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 }, + 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([]); + }); +}); diff --git a/tests/eval-metrics.test.ts b/tests/eval-metrics.test.ts index e2c6437a..de97cb8a 100644 --- a/tests/eval-metrics.test.ts +++ b/tests/eval-metrics.test.ts @@ -485,6 +485,86 @@ 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, + ), + 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" }), + 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", () => { const queries: GoldenQuery[] = [ query({ id: "q1", retrievalMode: "context" }), diff --git a/tests/eval-reports.test.ts b/tests/eval-reports.test.ts index fa7fdfc8..6af8c414 100644 --- a/tests/eval-reports.test.ts +++ b/tests/eval-reports.test.ts @@ -44,10 +44,21 @@ 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, + architecture: 4, + }; 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 |"); + 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", () => { 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,