diff --git a/src/discovery/architecture-bundle.test.ts b/src/discovery/architecture-bundle.test.ts new file mode 100644 index 0000000..bd44e21 --- /dev/null +++ b/src/discovery/architecture-bundle.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { loadArchitectureBundle } from './architecture-bundle.js'; + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'architecture-bundle-test-')); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +async function writeYaml(relativePath: string, content: string): Promise { + const fullPath = path.join(tempDir, relativePath); + await mkdir(path.dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content, 'utf8'); +} + +async function writeRequiredBundle(options: { includeArchitectureGraph?: boolean } = {}): Promise { + await writeYaml('adrs/index/architecture-index.yaml', ` +schema_version: '1.1' +type: architecture_index +architecture_namespace: sample-runtime +generated_at: '2026-03-19T00:00:00Z' +entity_registry_path: adrs/index/entity-registry.yaml +relationship_registry_path: adrs/index/relationship-registry.yaml +unresolved_registry_path: adrs/index/unresolved-registry.yaml +decision_registry_path: adrs/index/decision-registry.yaml +`); + await writeYaml('adrs/manifest.yaml', ` +schema_version: '1.0' +type: manifest +generated_date: '2026-03-19T00:00:00Z' +adrs: + - id: ADR-L-0013 +`); + await writeYaml('adrs/index/entity-registry.yaml', ` +entities: + - entity_id: CAP-0001 +`); + await writeYaml('adrs/index/relationship-registry.yaml', ` +relationships: + - relationship_id: REL-0001 +`); + await writeYaml('adrs/index/unresolved-registry.yaml', ` +unresolved: [] +`); + await writeYaml('adrs/index/decision-registry.yaml', ` +decisions: + - decision_id: DEC-0001 +`); + + if (options.includeArchitectureGraph !== false) { + await writeYaml('adrs/index/architecture-graph.yaml', ` +nodes: + - id: CAP-0001 +edges: [] +`); + } +} + +describe('loadArchitectureBundle', () => { + it('loads the required bundle and reports a valid status when additive artifacts are present', async () => { + await writeRequiredBundle(); + + const result = await loadArchitectureBundle(tempDir); + + expect(result.status).toBe('valid'); + expect(result.index.architectureNamespace).toBe('sample-runtime'); + expect(result.manifest.generatedDate).toBe('2026-03-19T00:00:00Z'); + expect(result.manifest.adrCount).toBe(1); + expect(result.requiredArtifacts.entityRegistry.exists).toBe(true); + expect(result.additiveArtifacts.architectureGraph.exists).toBe(true); + }); + + it('returns invalid when a required registry is missing', async () => { + await writeRequiredBundle(); + await rm(path.join(tempDir, 'adrs/index/relationship-registry.yaml')); + + const result = await loadArchitectureBundle(tempDir); + + expect(result.status).toBe('invalid'); + expect(result.errors.some((error) => error.includes('relationship-registry.yaml'))).toBe(true); + }); + + it('returns degraded when the additive architecture graph is unavailable', async () => { + await writeRequiredBundle({ includeArchitectureGraph: false }); + + const result = await loadArchitectureBundle(tempDir); + + expect(result.status).toBe('degraded'); + expect(result.errors).toEqual([]); + expect(result.warnings.some((warning) => warning.includes('Additive architecture graph unavailable'))).toBe(true); + }); + + it('does not consult the legacy compatibility registry', async () => { + await writeRequiredBundle(); + await writeYaml('adrs/entities/registry.yaml', 'this: [is: not-valid-yaml'); + + const result = await loadArchitectureBundle(tempDir); + + expect(result.status).toBe('valid'); + expect(result.errors).toEqual([]); + expect(result.warnings.some((warning) => warning.includes('intentionally not consulted'))).toBe(true); + }); +}); diff --git a/src/discovery/architecture-bundle.ts b/src/discovery/architecture-bundle.ts new file mode 100644 index 0000000..0355960 --- /dev/null +++ b/src/discovery/architecture-bundle.ts @@ -0,0 +1,260 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import yaml from 'js-yaml'; + +export type ArchitectureBundleStatus = 'valid' | 'degraded' | 'invalid'; + +export interface ArchitectureBundleArtifact { + path: string; + exists: boolean; + data?: T; + error?: string; +} + +export interface ArchitectureBundleManifestSummary { + schemaVersion?: string; + generatedDate?: string; + adrCount?: number; +} + +export interface ArchitectureBundleIndexSummary { + schemaVersion?: string; + architectureNamespace?: string; + generatedAt?: string; +} + +export interface ArchitectureBundleResult { + status: ArchitectureBundleStatus; + scopeRoot: string; + requiredArtifacts: { + architectureIndex: ArchitectureBundleArtifact>; + manifest: ArchitectureBundleArtifact>; + entityRegistry: ArchitectureBundleArtifact; + relationshipRegistry: ArchitectureBundleArtifact; + unresolvedRegistry: ArchitectureBundleArtifact; + }; + additiveArtifacts: { + architectureGraph: ArchitectureBundleArtifact; + subsetRegistries: ArchitectureBundleArtifact[]; + }; + manifest: ArchitectureBundleManifestSummary; + index: ArchitectureBundleIndexSummary; + warnings: string[]; + errors: string[]; +} + +const REQUIRED_INDEX_KEYS = { + entityRegistry: 'entity_registry_path', + relationshipRegistry: 'relationship_registry_path', + unresolvedRegistry: 'unresolved_registry_path', +} as const; + +const REQUIRED_DEFAULT_PATHS = { + entityRegistry: 'adrs/index/entity-registry.yaml', + relationshipRegistry: 'adrs/index/relationship-registry.yaml', + unresolvedRegistry: 'adrs/index/unresolved-registry.yaml', +} as const; + +const ARCHITECTURE_INDEX_PATH = 'adrs/index/architecture-index.yaml'; +const MANIFEST_PATH = 'adrs/manifest.yaml'; +const ARCHITECTURE_GRAPH_PATH = 'adrs/index/architecture-graph.yaml'; +const LEGACY_ENTITY_REGISTRY_PATH = 'adrs/entities/registry.yaml'; + +async function readYamlArtifact(absolutePath: string): Promise> { + try { + const raw = await fs.readFile(absolutePath, 'utf8'); + const data = yaml.load(raw); + if (data === undefined) { + return { + path: absolutePath, + exists: true, + error: 'YAML document is empty', + }; + } + return { + path: absolutePath, + exists: true, + data: data as T, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ENOENT') { + return { + path: absolutePath, + exists: false, + error: 'File not found', + }; + } + return { + path: absolutePath, + exists: true, + error: message, + }; + } +} + +function getStringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function normalizeArtifactPath(scopeRoot: string, relativePath: string): string { + return path.resolve(scopeRoot, relativePath); +} + +function resolveRequiredPath( + scopeRoot: string, + indexData: Record | undefined, + indexKey: string, + fallbackPath: string, + warnings: string[], +): string { + const configuredPath = indexData ? getStringField(indexData, indexKey) : undefined; + if (configuredPath) { + return normalizeArtifactPath(scopeRoot, configuredPath); + } + warnings.push(`Architecture index did not declare ${indexKey}; falling back to ${fallbackPath}.`); + return normalizeArtifactPath(scopeRoot, fallbackPath); +} + +function summarizeManifest(manifestData: Record | undefined): ArchitectureBundleManifestSummary { + const adrs = manifestData?.adrs; + return { + schemaVersion: manifestData ? getStringField(manifestData, 'schema_version') : undefined, + generatedDate: manifestData ? getStringField(manifestData, 'generated_date') : undefined, + adrCount: Array.isArray(adrs) ? adrs.length : undefined, + }; +} + +function summarizeIndex(indexData: Record | undefined): ArchitectureBundleIndexSummary { + return { + schemaVersion: indexData ? getStringField(indexData, 'schema_version') : undefined, + architectureNamespace: indexData ? getStringField(indexData, 'architecture_namespace') : undefined, + generatedAt: indexData ? getStringField(indexData, 'generated_at') : undefined, + }; +} + +export async function loadArchitectureBundle(scopeRoot: string): Promise { + const resolvedRoot = path.resolve(scopeRoot); + const warnings: string[] = []; + const errors: string[] = []; + + const architectureIndex = await readYamlArtifact>( + normalizeArtifactPath(resolvedRoot, ARCHITECTURE_INDEX_PATH), + ); + const manifest = await readYamlArtifact>( + normalizeArtifactPath(resolvedRoot, MANIFEST_PATH), + ); + + const indexData = + architectureIndex.data && typeof architectureIndex.data === 'object' && !Array.isArray(architectureIndex.data) + ? architectureIndex.data + : undefined; + + if (architectureIndex.error) { + errors.push(`Required architecture index is unavailable: ${architectureIndex.error}.`); + } else if (!indexData) { + errors.push('Required architecture index is malformed.'); + } + + if (manifest.error) { + errors.push(`Required manifest is unavailable: ${manifest.error}.`); + } else if (!manifest.data || typeof manifest.data !== 'object' || Array.isArray(manifest.data)) { + errors.push('Required manifest is malformed.'); + } + + const entityRegistryPath = resolveRequiredPath( + resolvedRoot, + indexData, + REQUIRED_INDEX_KEYS.entityRegistry, + REQUIRED_DEFAULT_PATHS.entityRegistry, + warnings, + ); + const relationshipRegistryPath = resolveRequiredPath( + resolvedRoot, + indexData, + REQUIRED_INDEX_KEYS.relationshipRegistry, + REQUIRED_DEFAULT_PATHS.relationshipRegistry, + warnings, + ); + const unresolvedRegistryPath = resolveRequiredPath( + resolvedRoot, + indexData, + REQUIRED_INDEX_KEYS.unresolvedRegistry, + REQUIRED_DEFAULT_PATHS.unresolvedRegistry, + warnings, + ); + + const [entityRegistry, relationshipRegistry, unresolvedRegistry, architectureGraph] = await Promise.all([ + readYamlArtifact(entityRegistryPath), + readYamlArtifact(relationshipRegistryPath), + readYamlArtifact(unresolvedRegistryPath), + readYamlArtifact(normalizeArtifactPath(resolvedRoot, ARCHITECTURE_GRAPH_PATH)), + ]); + + const subsetRegistryArtifacts: ArchitectureBundleArtifact[] = []; + if (indexData) { + for (const [key, value] of Object.entries(indexData)) { + if (!key.endsWith('_registry_path')) continue; + if (Object.values(REQUIRED_INDEX_KEYS).includes(key as (typeof REQUIRED_INDEX_KEYS)[keyof typeof REQUIRED_INDEX_KEYS])) { + continue; + } + if (typeof value !== 'string' || !value.trim()) { + warnings.push(`Architecture index declared ${key} without a usable path.`); + continue; + } + subsetRegistryArtifacts.push(await readYamlArtifact(normalizeArtifactPath(resolvedRoot, value))); + } + } + + const requiredArtifacts = [entityRegistry, relationshipRegistry, unresolvedRegistry]; + for (const artifact of requiredArtifacts) { + if (artifact.error) { + errors.push(`Required bundle artifact ${path.relative(resolvedRoot, artifact.path)} is unavailable: ${artifact.error}.`); + } + } + + if (architectureGraph.error) { + warnings.push(`Additive architecture graph unavailable: ${architectureGraph.error}.`); + } + for (const artifact of subsetRegistryArtifacts) { + if (artifact.error) { + warnings.push(`Additive subset registry ${path.relative(resolvedRoot, artifact.path)} unavailable: ${artifact.error}.`); + } + } + + const legacyRegistryPath = normalizeArtifactPath(resolvedRoot, LEGACY_ENTITY_REGISTRY_PATH); + try { + await fs.access(legacyRegistryPath); + warnings.push('Legacy compatibility registry detected at adrs/entities/registry.yaml but intentionally not consulted.'); + } catch { + // Compatibility surface absent; nothing to report. + } + + const status: ArchitectureBundleStatus = + errors.length > 0 ? 'invalid' : warnings.some((warning) => warning.startsWith('Additive ')) ? 'degraded' : 'valid'; + + return { + status, + scopeRoot: resolvedRoot, + requiredArtifacts: { + architectureIndex, + manifest, + entityRegistry, + relationshipRegistry, + unresolvedRegistry, + }, + additiveArtifacts: { + architectureGraph, + subsetRegistries: subsetRegistryArtifacts, + }, + manifest: summarizeManifest( + manifest.data && typeof manifest.data === 'object' && !Array.isArray(manifest.data) ? manifest.data : undefined, + ), + index: summarizeIndex(indexData), + warnings, + errors, + }; +} diff --git a/src/discovery/index.ts b/src/discovery/index.ts index 3927a56..3a8f017 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -14,5 +14,14 @@ export { type ProjectStructure } from './project-discovery.js'; +export { + loadArchitectureBundle, + type ArchitectureBundleArtifact, + type ArchitectureBundleIndexSummary, + type ArchitectureBundleManifestSummary, + type ArchitectureBundleResult, + type ArchitectureBundleStatus, +} from './architecture-bundle.js'; + diff --git a/src/index.ts b/src/index.ts index 6e636e5..21d0f87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,4 +102,17 @@ export { type ConversationalResponse, type QueryIntent, type NodeSummary, -} from './rss/conversational-query.js'; \ No newline at end of file +} from './rss/conversational-query.js'; + +// ============================================================================ +// Architecture Bundle Discovery +// ============================================================================ + +export { + loadArchitectureBundle, + type ArchitectureBundleArtifact, + type ArchitectureBundleIndexSummary, + type ArchitectureBundleManifestSummary, + type ArchitectureBundleResult, + type ArchitectureBundleStatus, +} from './discovery/architecture-bundle.js'; diff --git a/src/mcp/mcp-server.ts b/src/mcp/mcp-server.ts index 3906ee7..888350a 100644 --- a/src/mcp/mcp-server.ts +++ b/src/mcp/mcp-server.ts @@ -332,13 +332,17 @@ export class McpServer { case 'overview': { const ctx = this.getContextForScope(scope); - result = await optimizedTools.overview(ctx, toolArgs as any); + result = await optimizedTools.overview(ctx, toolArgs as any, { + projectRoot: this.options.projectRoot, + }); break; } case 'diagnose': { const ctx = this.getContextForScope(scope); - result = await optimizedTools.diagnose(ctx, toolArgs as any); + result = await optimizedTools.diagnose(ctx, toolArgs as any, { + projectRoot: this.options.projectRoot, + }); break; } diff --git a/src/mcp/tools-optimized-architecture.test.ts b/src/mcp/tools-optimized-architecture.test.ts new file mode 100644 index 0000000..010dd5b --- /dev/null +++ b/src/mcp/tools-optimized-architecture.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import type { AidocGraph, AidocNode } from '../rss/graph-loader.js'; +import type { RssContext } from '../rss/rss-operations.js'; +import { diagnose, overview } from './tools-optimized.js'; + +let tempDir: string; +let ctx: RssContext; + +beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'tools-optimized-architecture-test-')); + const graph: AidocGraph = new Map(); + + const node: AidocNode = { + key: 'api/function/handler', + domain: 'api', + type: 'function', + id: 'handler', + path: 'src/api/handler.ts', + tags: ['exported'], + sourceFiles: ['src/api/handler.ts'], + references: [], + referencedBy: [], + element: { name: 'handler' }, + }; + graph.set(node.key, node); + + ctx = { + graph, + graphVersion: 'test-graph', + stateRoot: tempDir, + }; +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +async function writeYaml(relativePath: string, content: string): Promise { + const fullPath = path.join(tempDir, relativePath); + await mkdir(path.dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content, 'utf8'); +} + +async function writeArchitectureBundle(): Promise { + await writeYaml('adrs/index/architecture-index.yaml', ` +schema_version: '1.1' +architecture_namespace: test-runtime +generated_at: '2026-03-19T00:00:00Z' +entity_registry_path: adrs/index/entity-registry.yaml +relationship_registry_path: adrs/index/relationship-registry.yaml +unresolved_registry_path: adrs/index/unresolved-registry.yaml +`); + await writeYaml('adrs/manifest.yaml', ` +schema_version: '1.0' +generated_date: '2026-03-19T00:00:00Z' +adrs: + - id: ADR-L-0013 +`); + await writeYaml('adrs/index/entity-registry.yaml', 'entities: []\n'); + await writeYaml('adrs/index/relationship-registry.yaml', 'relationships: []\n'); + await writeYaml('adrs/index/unresolved-registry.yaml', 'unresolved: []\n'); +} + +describe('optimized MCP architecture bundle reporting', () => { + it('adds architecture bundle status to overview when projectRoot is provided', async () => { + await writeArchitectureBundle(); + + const result = await overview(ctx, {}, { projectRoot: tempDir }); + + expect(result.architectureBundle?.status).toBe('degraded'); + expect(result.architectureBundle?.index.architectureNamespace).toBe('test-runtime'); + }); + + it('adds architecture bundle status to diagnose details when projectRoot is provided', async () => { + await writeArchitectureBundle(); + + const result = await diagnose(ctx, { mode: 'health' }, { projectRoot: tempDir }); + const architectureBundle = result.details.architectureBundle as { status: string; manifest: { adrCount?: number } }; + + expect(architectureBundle.status).toBe('degraded'); + expect(architectureBundle.manifest.adrCount).toBe(1); + }); +}); diff --git a/src/mcp/tools-optimized.ts b/src/mcp/tools-optimized.ts index 43ec837..098d669 100644 --- a/src/mcp/tools-optimized.ts +++ b/src/mcp/tools-optimized.ts @@ -17,6 +17,7 @@ */ import type { AidocNode } from '../rss/graph-loader.js'; +import { loadArchitectureBundle, type ArchitectureBundleResult } from '../discovery/architecture-bundle.js'; import { type RssContext, search, @@ -536,9 +537,14 @@ export interface OverviewResult { architecture: string; keyComponents: string[]; totalNodes: number; + architectureBundle?: ArchitectureBundleResult; meta: ResponseMeta; } +export interface ArchitectureToolOptions { + projectRoot?: string; +} + /** * overview - Codebase Orientation * @@ -546,7 +552,8 @@ export interface OverviewResult { */ export async function overview( ctx: RssContext, - args: OverviewArgs + args: OverviewArgs, + options: ArchitectureToolOptions = {} ): Promise { const startTime = performance.now(); const { focus } = args; @@ -610,12 +617,17 @@ export async function overview( } else if (Object.keys(domains).length > 3) { architecture = 'Multi-domain'; } + + const architectureBundle = options.projectRoot + ? await loadArchitectureBundle(options.projectRoot) + : undefined; return { domains, architecture, keyComponents, totalNodes: stats.totalNodes, + architectureBundle, meta: createMeta( startTime, ctx.graph.size, @@ -655,13 +667,17 @@ export interface DiagnoseResult { */ export async function diagnose( ctx: RssContext, - args: DiagnoseArgs + args: DiagnoseArgs, + options: ArchitectureToolOptions = {} ): Promise { const startTime = performance.now(); const { target, mode = 'health' } = args; const stats = getGraphStats(ctx); const validation = validateGraphHealth(ctx); + const architectureBundle = options.projectRoot + ? await loadArchitectureBundle(options.projectRoot) + : undefined; if (mode === 'health') { const healthy = validation.brokenEdges.length === 0 && @@ -678,6 +694,7 @@ export async function diagnose( brokenEdges: validation.brokenEdges.length, inconsistencies: validation.bidirectionalInconsistencies.length, graphVersion: ctx.graphVersion, + architectureBundle, }, meta: createMeta(startTime, stats.totalNodes, 0, 0, ctx.graphVersion), }; @@ -703,6 +720,7 @@ export async function diagnose( byType, filesIndexed: files.size, graphVersion: ctx.graphVersion, + architectureBundle, }, meta: createMeta(startTime, stats.totalNodes, files.size, 0, ctx.graphVersion), }; @@ -729,6 +747,7 @@ export async function diagnose( edgeCount: stats.totalEdges, graphVersion: ctx.graphVersion, advantage: `Semantic search examined ${stats.totalNodes} nodes vs grep scanning entire codebase.`, + architectureBundle, }, meta: createMeta(startTime, stats.totalNodes, 0, 0, ctx.graphVersion), }; @@ -761,6 +780,7 @@ export async function diagnose( hasSource: !!node.source, references: node.references.length, referencedBy: node.referencedBy.length, + architectureBundle, }, meta: createMeta(startTime, 1, 1, 0, ctx.graphVersion), }; @@ -769,7 +789,10 @@ export async function diagnose( return { healthy: true, summary: `Graph: ${stats.totalNodes} nodes, ${stats.totalEdges} edges.`, - details: stats, + details: { + ...stats, + architectureBundle, + }, meta: createMeta(startTime, stats.totalNodes, 0, 0, ctx.graphVersion), }; }