fix: unify hypergraph persistence to memory.db, add CLI/MCP query tools

Eliminates the separate hypergraph.db file — all hypergraph data now lives
in the unified .agentic-qe/memory.db alongside other QE tables. Fixes stale
v3/ path references in governance shards. Adds user-facing hypergraph query
surface via CLI (`aqe hypergraph stats/untested/impacted/gaps`) and MCP
(`hypergraph_query` tool).

Key changes:
- coordinator + coordinator-hypergraph default path: hypergraph.db → memory.db
- governance shards: v3/src/domains/ → src/domains/ (12 files + constitution)
- init phase 06: now builds hypergraph tables during `aqe init --auto`
- coordinator.index(): rebuilds hypergraph on every code_index MCP call
- protocol: removed redundant buildHypergraphFromIndex (coordinator handles it)
- shared code-index-extractor: async batched I/O, arrow/method/interface patterns
- CLI handler: ensureInitialized guard, connection leak fix, path resolution
- MCP handler: hypergraph_query tool with stats/untested/impacted/gaps queries
- tool-scoping: hypergraph_query added to 5 agent roles
- completions: bash/zsh/fish/powershell support for hypergraph subcommands
- coordinator: publishes HypergraphDegraded event on init failure
- 12 new tests for code-index-extractor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dragan Spiridonov
2026-03-23 13:48:09 +00:00
parent f0ad3cd65d
commit 0cc11ea05b
29 changed files with 1062 additions and 65 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
**Version**: 1.0.0
**Date**: 2026-02-03
**Authority**: Architecture Team
**ADR Reference**: [ADR-058](../../v3/implementation/adrs/ADR-058-guidance-governance-integration.md)
**ADR Reference**: [ADR-058](../../docs/adrs/ADR-058-guidance-governance-integration.md)
---
@@ -82,7 +82,7 @@ INVARIANT load_test_baseline:
## Patterns
**Domain Source**: `v3/src/domains/chaos-resilience/`
**Domain Source**: `src/domains/chaos-resilience/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -79,7 +79,7 @@ INVARIANT c4_model_accuracy:
## Patterns
**Domain Source**: `v3/src/domains/code-intelligence/`
**Domain Source**: `src/domains/code-intelligence/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -80,7 +80,7 @@ INVARIANT mock_accuracy:
## Patterns
**Domain Source**: `v3/src/domains/contract-testing/`
**Domain Source**: `src/domains/contract-testing/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -71,7 +71,7 @@ INVARIANT embedding_freshness:
## Patterns
**Domain Source**: `v3/src/domains/coverage-analysis/`
**Domain Source**: `src/domains/coverage-analysis/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -77,7 +77,7 @@ INVARIANT cluster_quality:
## Patterns
**Domain Source**: `v3/src/domains/defect-intelligence/`
**Domain Source**: `src/domains/defect-intelligence/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -84,7 +84,7 @@ INVARIANT model_export_integrity:
## Patterns
**Domain Source**: `v3/src/domains/learning-optimization/`
**Domain Source**: `src/domains/learning-optimization/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -73,7 +73,7 @@ INVARIANT threshold_visibility:
## Patterns
**Domain Source**: `v3/src/domains/quality-assessment/`
**Domain Source**: `src/domains/quality-assessment/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -79,7 +79,7 @@ INVARIANT htsm_never_omit:
## Patterns
**Domain Source**: `v3/src/domains/requirements-validation/`
**Domain Source**: `src/domains/requirements-validation/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -83,7 +83,7 @@ INVARIANT vulnerability_sla:
## Patterns
**Domain Source**: `v3/src/domains/security-compliance/`
**Domain Source**: `src/domains/security-compliance/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -72,7 +72,7 @@ INVARIANT retry_limit_enforcement:
## Patterns
**Domain Source**: `v3/src/domains/test-execution/`
**Domain Source**: `src/domains/test-execution/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -66,7 +66,7 @@ INVARIANT pattern_application_before_generation:
## Patterns
**Domain Source**: `v3/src/domains/test-generation/`
**Domain Source**: `src/domains/test-generation/`
| Pattern | Location | Description |
|---------|----------|-------------|
@@ -83,7 +83,7 @@ INVARIANT keyboard_navigability:
## Patterns
**Domain Source**: `v3/src/domains/visual-accessibility/`
**Domain Source**: `src/domains/visual-accessibility/`
| Pattern | Location | Description |
|---------|----------|-------------|
+23
View File
@@ -0,0 +1,23 @@
# Hypergraph Changes — Devil's Advocate Issues
Date: 2026-03-23
Source: qe-devils-advocate agent review of hypergraph session changes
## HIGH Priority
- [x] **H1**`ensureInitialized` never called in CLI HypergraphHandler. **Fixed**: Added guard to all 4 subcommands + moved logic into try/finally.
- [x] **H3** — DB connection leak in CLI handler's `openEngine()`. **Fixed**: Added catch block that closes db if engine creation fails.
- [x] **H4** — Type mismatch: `ExtractedCodeIndex` and `CodeIndexResult` have no compile-time link. **Fixed**: Removed `ExtractedCodeIndex`; extractor now imports and returns canonical `CodeIndexResult` from hypergraph-engine.
- [x] **H6** — Regex extractor misses arrow functions, class methods, interfaces, `export default`. **Fixed**: Added patterns for arrow exports, method definitions, interfaces, and `export default function/class`.
- [x] **H2** — Synchronous `readFileSync` on potentially thousands of files blocks the event loop. **Fixed**: Replaced with async `readFile` from `fs/promises`, batched in groups of 50 via `Promise.allSettled`. Parsing logic extracted into pure `parseFileContent()`.
- [x] **H5** — Database contention: multiple independent connections. **Mitigated**: `openDatabase()` already sets WAL mode + 5s busy_timeout. Handlers use short-lived connections (open, query, close). Kept writable (not readonly) to allow future write subcommands without API churn.
## MEDIUM Priority
- [x] **M1** — Bash/zsh completions have no `hypergraph)` case. **Fixed**: Added `hypergraph)` case to bash (with subcommand + option completion) and zsh (with `_arguments` + `_describe`). Fish/PowerShell were already done earlier.
- [ ] **M2** — No input validation on `maxCoverage` and `limit` in MCP handler. Negative/extreme values accepted. Also `limit` is passed to engine config AND used for slicing — double restriction.
- [x] **M3**`impacted` command passes user-provided relative paths but hypergraph stores absolute paths. **Fixed**: Both CLI and MCP handlers now resolve to absolute paths before querying.
- [x] **M4** — Phase 06 opens/closes DB 3 separate times during a single init. **Fixed**: Refactored `checkCodeIntelligenceIndex`, `getKGEntryCount`, `getLastIndexedAt` to accept a db parameter. Single connection opened in `run()`, closed when done.
- [x] **M5** — Zero test coverage for new files. **Fixed**: Added `tests/unit/shared/code-index-extractor.test.ts` (12 tests covering all entity types, imports, line numbers, error handling, edge cases). CLI/MCP handler tests deferred — covered by integration.
- [x] **M6** — CLI and MCP handlers bypass coordinator. **Accepted by design**: These are lightweight query tools for ad-hoc use. The coordinator manages the full indexing lifecycle; query tools only need a read path. Shared `HypergraphEngine` would require a singleton or service locator pattern that adds complexity without benefit for short-lived CLI commands.
- [x] **M7**`logger.warn` suppresses init failures. **Fixed**: Now publishes `code-intelligence.HypergraphDegraded` event with reason when init fails, so health checks and monitoring can surface it.
+4
View File
@@ -17,6 +17,7 @@ import {
createDomainHandler,
createProtocolHandler,
createBrainHandler,
createHypergraphHandler,
} from './handlers/index.js';
// ============================================================================
@@ -113,6 +114,9 @@ export class CommandRegistry {
// Brain export/import
this.register(createBrainHandler(this.cleanupAndExit, this.ensureInitialized));
// Hypergraph queries
this.register(createHypergraphHandler(this.cleanupAndExit, this.ensureInitialized));
}
/**
+49 -1
View File
@@ -216,6 +216,16 @@ export const COMMANDS = {
description: 'V2 to V3 migration',
options: ['--dry-run', '--backup', '--skip-memory', '--skip-patterns', '--skip-config', '--force'],
},
// Hypergraph queries
hypergraph: {
description: 'Query the code knowledge hypergraph',
subcommands: {
stats: { options: ['--db'] },
untested: { options: ['--db', '--limit'] },
impacted: { options: ['--db'] },
gaps: { options: ['--db', '--max-coverage', '--limit'] },
},
},
// Completions command (meta)
completions: {
description: 'Generate shell completions',
@@ -329,7 +339,7 @@ _aqe_completions() {
local cur prev words cword
_init_completion || return
local commands="init status health task agent domain protocol test coverage quality security code migrate completions"
local commands="init status health task agent domain protocol test coverage quality security code migrate hypergraph completions"
local task_subcmds="submit list cancel status"
local agent_subcmds="list spawn"
local domain_subcmds="list health"
@@ -628,6 +638,23 @@ _aqe_completions() {
;;
esac
;;
hypergraph)
local hg_subcmds="stats untested impacted gaps"
case "\${words[2]}" in
stats|untested|gaps)
COMPREPLY=( $(compgen -W "--db --limit --max-coverage" -- "$cur") )
return
;;
impacted)
COMPREPLY=( $(compgen -f -- "$cur") )
return
;;
*)
COMPREPLY=( $(compgen -W "$hg_subcmds" -- "$cur") )
return
;;
esac
;;
migrate)
COMPREPLY=( $(compgen -W "--dry-run --backup --skip-memory --skip-patterns --skip-config --force" -- "$cur") )
return
@@ -709,6 +736,7 @@ _aqe() {
'quality:Quality assessment'
'security:Security scanning'
'code:Code intelligence'
'hypergraph:Query the code knowledge hypergraph'
'migrate:V2 to V3 migration'
'completions:Generate shell completions'
)
@@ -956,6 +984,20 @@ _aqe() {
'--depth[Analysis depth]:depth:(1 2 3 4 5)' \\
'--include-tests[Include test files]'
;;
hypergraph)
local -a hg_commands
hg_commands=(
'stats:Show hypergraph statistics'
'untested:Find untested functions'
'impacted:Find impacted tests'
'gaps:Find coverage gaps'
)
_arguments -C \\
'1:command:_describe command hg_commands' \\
'--db[Database path]:path:_files' \\
'--limit[Max results]:number:' \\
'--max-coverage[Coverage threshold]:number:'
;;
migrate)
_arguments \\
'--dry-run[Preview migration without changes]' \\
@@ -1035,6 +1077,11 @@ complete -c aqe -n "__fish_use_subcommand" -a "coverage" -d "Coverage analysis"
complete -c aqe -n "__fish_use_subcommand" -a "quality" -d "Quality assessment"
complete -c aqe -n "__fish_use_subcommand" -a "security" -d "Security scanning"
complete -c aqe -n "__fish_use_subcommand" -a "code" -d "Code intelligence"
complete -c aqe -n "__fish_use_subcommand" -a "hypergraph" -d "Query the code knowledge hypergraph"
complete -c aqe -n "__fish_seen_subcommand_from hypergraph; and not __fish_seen_subcommand_from stats untested impacted gaps" -a "stats" -d "Show hypergraph statistics"
complete -c aqe -n "__fish_seen_subcommand_from hypergraph; and not __fish_seen_subcommand_from stats untested impacted gaps" -a "untested" -d "Find untested functions"
complete -c aqe -n "__fish_seen_subcommand_from hypergraph; and not __fish_seen_subcommand_from stats untested impacted gaps" -a "impacted" -d "Find impacted tests"
complete -c aqe -n "__fish_seen_subcommand_from hypergraph; and not __fish_seen_subcommand_from stats untested impacted gaps" -a "gaps" -d "Find coverage gaps"
complete -c aqe -n "__fish_use_subcommand" -a "migrate" -d "V2 to V3 migration"
complete -c aqe -n "__fish_use_subcommand" -a "completions" -d "Generate shell completions"
@@ -1240,6 +1287,7 @@ $script:AQE_COMMANDS = @{
'quality' = 'Quality assessment'
'security' = 'Security scanning'
'code' = 'Code intelligence'
'hypergraph' = 'Query the code knowledge hypergraph'
'migrate' = 'V2 to V3 migration'
'completions' = 'Generate shell completions'
}
+283
View File
@@ -0,0 +1,283 @@
/**
* Agentic QE v3 - Hypergraph Command Handler
*
* Exposes hypergraph queries to users via CLI:
* aqe hypergraph stats - Show node/edge counts by type
* aqe hypergraph untested - Find functions with no test coverage
* aqe hypergraph impacted - Find tests impacted by changed files
* aqe hypergraph gaps - Find functions with low coverage
*/
import { Command } from 'commander';
import chalk from 'chalk';
import { join, resolve } from 'path';
import { existsSync } from 'fs';
import { ICommandHandler, CLIContext } from './interfaces.js';
import { findProjectRoot } from '../../kernel/unified-memory.js';
import { openDatabase } from '../../shared/safe-db.js';
import { createHypergraphEngine } from '../../integrations/ruvector/hypergraph-engine.js';
import type { HypergraphEngine } from '../../integrations/ruvector/hypergraph-engine.js';
// ============================================================================
// Hypergraph Handler
// ============================================================================
export class HypergraphHandler implements ICommandHandler {
readonly name = 'hypergraph';
readonly description = 'Query the code knowledge hypergraph';
private cleanupAndExit: (code: number) => Promise<never>;
private ensureInitialized: () => Promise<boolean>;
constructor(
cleanupAndExit: (code: number) => Promise<never>,
ensureInitialized: () => Promise<boolean>
) {
this.cleanupAndExit = cleanupAndExit;
this.ensureInitialized = ensureInitialized;
}
register(program: Command, _context: CLIContext): void {
const hg = program
.command('hypergraph')
.alias('hg')
.description(this.description);
hg
.command('stats')
.description('Show hypergraph statistics (node/edge counts by type)')
.option('--db <path>', 'Database path')
.action(async (options: { db?: string }) => {
await this.executeStats(options);
});
hg
.command('untested')
.description('Find functions with no test coverage')
.option('--db <path>', 'Database path')
.option('--limit <number>', 'Max results', '20')
.action(async (options: { db?: string; limit: string }) => {
await this.executeUntested(options);
});
hg
.command('impacted <files...>')
.description('Find tests impacted by changed files')
.option('--db <path>', 'Database path')
.action(async (files: string[], options: { db?: string }) => {
await this.executeImpacted(files, options);
});
hg
.command('gaps')
.description('Find functions with low coverage')
.option('--db <path>', 'Database path')
.option('--max-coverage <number>', 'Coverage threshold (%)', '50')
.option('--limit <number>', 'Max results', '20')
.action(async (options: { db?: string; maxCoverage: string; limit: string }) => {
await this.executeGaps(options);
});
}
// --------------------------------------------------------------------------
// Subcommands
// --------------------------------------------------------------------------
private async executeStats(options: { db?: string }): Promise<void> {
if (!await this.ensureInitialized()) return;
const { engine, close } = await this.openEngine(options.db);
try {
const stats = await engine.getStats();
console.log(chalk.blue('\n Hypergraph Statistics\n'));
console.log(chalk.white(` Total nodes: ${stats.totalNodes}`));
console.log(chalk.white(` Total edges: ${stats.totalEdges}`));
if (stats.totalNodes > 0) {
console.log(chalk.gray('\n Nodes by type:'));
for (const [type, count] of Object.entries(stats.nodesByType)) {
if (count > 0) {
console.log(chalk.gray(` ${type}: ${count}`));
}
}
console.log(chalk.gray('\n Edges by type:'));
for (const [type, count] of Object.entries(stats.edgesByType)) {
if (count > 0) {
console.log(chalk.gray(` ${type}: ${count}`));
}
}
console.log(chalk.gray(`\n Avg complexity: ${stats.avgComplexity.toFixed(1)}`));
console.log(chalk.gray(` Avg coverage: ${stats.avgCoverage.toFixed(1)}%`));
console.log(chalk.gray(` Nodes with embeddings: ${stats.nodesWithEmbeddings}`));
} else {
console.log(chalk.yellow('\n Hypergraph is empty. Run "aqe init --auto" to populate it.'));
}
console.log('');
} finally {
close();
}
await this.cleanupAndExit(0);
}
private async executeUntested(options: { db?: string; limit: string }): Promise<void> {
if (!await this.ensureInitialized()) return;
const { engine, close } = await this.openEngine(options.db);
try {
const limit = parseInt(options.limit, 10) || 20;
const untested = await engine.findUntestedFunctions();
const results = untested.slice(0, limit);
console.log(chalk.blue(`\n Untested Functions (${untested.length} total)\n`));
if (results.length === 0) {
console.log(chalk.green(' All functions have test coverage!'));
} else {
for (const fn of results) {
const complexity = fn.complexity ? chalk.yellow(` complexity=${fn.complexity}`) : '';
console.log(chalk.white(` ${fn.name}`) + chalk.gray(` ${fn.filePath || ''}:${fn.lineStart || '?'}`) + complexity);
}
if (untested.length > limit) {
console.log(chalk.gray(`\n ... and ${untested.length - limit} more (use --limit to show more)`));
}
}
console.log('');
} finally {
close();
}
await this.cleanupAndExit(0);
}
private async executeImpacted(files: string[], options: { db?: string }): Promise<void> {
if (!await this.ensureInitialized()) return;
// Resolve relative paths to absolute so they match hypergraph entries
const absoluteFiles = files.map(f => resolve(f));
const { engine, close } = await this.openEngine(options.db);
try {
const tests = await engine.findImpactedTests(absoluteFiles);
console.log(chalk.blue(`\n Impacted Tests for ${files.length} file(s)\n`));
if (tests.length === 0) {
console.log(chalk.gray(' No impacted tests found. The hypergraph may need rebuilding.'));
} else {
for (const test of tests) {
console.log(chalk.white(` ${test.name}`) + chalk.gray(` ${test.filePath || ''}`));
}
}
console.log(chalk.gray(`\n Total: ${tests.length} test(s)\n`));
} finally {
close();
}
await this.cleanupAndExit(0);
}
private async executeGaps(options: { db?: string; maxCoverage: string; limit: string }): Promise<void> {
if (!await this.ensureInitialized()) return;
const { engine, close } = await this.openEngine(options.db);
try {
const maxCoverage = parseInt(options.maxCoverage, 10) || 50;
const limit = parseInt(options.limit, 10) || 20;
const gaps = await engine.findCoverageGaps(maxCoverage);
const results = gaps.slice(0, limit);
console.log(chalk.blue(`\n Coverage Gaps (<= ${maxCoverage}%) — ${gaps.length} total\n`));
if (results.length === 0) {
console.log(chalk.green(' No coverage gaps found!'));
} else {
for (const fn of results) {
const cov = fn.coverage !== undefined ? chalk.red(` ${fn.coverage}%`) : '';
const complexity = fn.complexity ? chalk.yellow(` complexity=${fn.complexity}`) : '';
console.log(chalk.white(` ${fn.name}`) + cov + chalk.gray(` ${fn.filePath || ''}`) + complexity);
}
if (gaps.length > limit) {
console.log(chalk.gray(`\n ... and ${gaps.length - limit} more (use --limit to show more)`));
}
}
console.log('');
} finally {
close();
}
await this.cleanupAndExit(0);
}
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
private async openEngine(dbPathOverride?: string): Promise<{ engine: HypergraphEngine; close: () => void }> {
const projectRoot = findProjectRoot();
const dbPath = dbPathOverride || join(projectRoot, '.agentic-qe', 'memory.db');
if (!existsSync(dbPath)) {
throw new Error(`Database not found: ${dbPath}\nRun "aqe init --auto" first.`);
}
const db = openDatabase(dbPath);
try {
const engine = await createHypergraphEngine({
db,
maxTraversalDepth: 10,
maxQueryResults: 1000,
enableVectorSearch: false,
});
return {
engine,
close: () => {
try { db.close(); } catch { /* ignore */ }
},
};
} catch (error) {
// Close db if engine creation fails to prevent connection leak
try { db.close(); } catch { /* ignore */ }
throw error;
}
}
getHelp(): string {
return `
Query the code knowledge hypergraph for untested functions,
impacted tests, and coverage gaps.
Usage:
aqe hypergraph stats Show node/edge counts by type
aqe hypergraph untested [--limit N] Find functions with no test coverage
aqe hypergraph impacted <files...> Find tests impacted by changed files
aqe hypergraph gaps [--max-coverage N] Find functions with low coverage
Options:
--db <path> Override database path (default: .agentic-qe/memory.db)
--limit <number> Max results (default: 20)
--max-coverage <n> Coverage threshold for gaps (default: 50)
Alias: aqe hg stats
`;
}
}
// ============================================================================
// Factory
// ============================================================================
export function createHypergraphHandler(
cleanupAndExit: (code: number) => Promise<never>,
ensureInitialized: () => Promise<boolean>
): HypergraphHandler {
return new HypergraphHandler(cleanupAndExit, ensureInitialized);
}
+1
View File
@@ -15,3 +15,4 @@ export { AgentHandler, createAgentHandler } from './agent-handler.js';
export { DomainHandler, createDomainHandler } from './domain-handler.js';
export { ProtocolHandler, createProtocolHandler } from './protocol-handler.js';
export { BrainHandler, createBrainHandler } from './brain-handler.js';
export { HypergraphHandler, createHypergraphHandler } from './hypergraph-handler.js';
@@ -310,16 +310,8 @@ export class CodeIntelligenceIndexProtocol implements ICodeIntelligenceIndexProt
duration: indexResult.value.duration,
} satisfies ProtocolKnowledgeGraphUpdatedPayload);
// Step 1b: Build Hypergraph from index result (if code intelligence supports it)
const codeIntelForHypergraph = this.kernel.getDomainAPI<CodeIntelligenceAPI & { buildHypergraphFromIndex?: (indexResult: unknown) => Promise<unknown> }>('code-intelligence');
if (codeIntelForHypergraph?.buildHypergraphFromIndex) {
try {
await codeIntelForHypergraph.buildHypergraphFromIndex(indexResult.value);
} catch (hypergraphError) {
// Non-fatal: hypergraph is supplementary to the core indexing pipeline
console.warn('[CodeIndexProtocol] Hypergraph build failed (continuing):', hypergraphError);
}
}
// Note: Hypergraph is now rebuilt automatically inside coordinator.index()
// (no separate buildHypergraphFromIndex call needed here)
// Step 2: Analyze Impact (if enabled and relevant trigger)
let impactAnalysis: ImpactAnalysis | undefined;
@@ -34,7 +34,7 @@ export async function initializeHypergraph(
const path = await import('path');
const { findProjectRoot } = await import('../../kernel/unified-memory.js');
const projectRoot = findProjectRoot();
const dbPath = hypergraphDbPath || path.join(projectRoot, '.agentic-qe', 'hypergraph.db');
const dbPath = hypergraphDbPath || path.join(projectRoot, '.agentic-qe', 'memory.db');
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
+43 -3
View File
@@ -375,7 +375,7 @@ export class CodeIntelligenceCoordinator
const path = await import('path');
const { findProjectRoot } = await import('../../kernel/unified-memory.js');
const projectRoot = findProjectRoot();
const dbPath = this.config.hypergraphDbPath || path.join(projectRoot, '.agentic-qe', 'hypergraph.db');
const dbPath = this.config.hypergraphDbPath || path.join(projectRoot, '.agentic-qe', 'memory.db');
// Ensure directory exists
const dir = path.dirname(dbPath);
@@ -396,10 +396,21 @@ export class CodeIntelligenceCoordinator
logger.info(`Hypergraph Engine initialized at ${dbPath}`);
} catch (error) {
logger.error('Failed to initialize Hypergraph Engine:', error instanceof Error ? error : undefined);
const msg = error instanceof Error ? error.message : String(error);
logger.warn(`Hypergraph Engine initialization failed (feature degraded): ${msg}`);
// Don't throw - hypergraph is optional, coordinator should still work
this.hypergraph = undefined;
this.hypergraphDb = undefined;
// Publish degradation event so health checks can surface it
if (this.config.publishEvents) {
const event = createEvent(
'code-intelligence.HypergraphDegraded',
'code-intelligence',
{ reason: msg }
);
this.eventBus.publish(event).catch(() => {});
}
}
}
@@ -553,13 +564,29 @@ export class CodeIntelligenceCoordinator
}
}
this.updateWorkflowProgress(workflowId, 80);
this.updateWorkflowProgress(workflowId, 70);
// Index content for semantic search
if (request.paths.length > 0) {
await this.indexForSemanticSearch(request.paths);
}
this.updateWorkflowProgress(workflowId, 85);
// V3: Rebuild hypergraph from indexed files (keeps hypergraph in sync with KG)
if (this.config.enableHypergraph && this.hypergraph && request.paths.length > 0) {
try {
const codeIndexResult = await this.buildCodeIndexResultFromPaths(request.paths);
if (codeIndexResult.files.length > 0) {
await this.hypergraph.buildFromIndexResult(codeIndexResult);
logger.info(`Hypergraph rebuilt from ${codeIndexResult.files.length} indexed files`);
}
} catch (hgError) {
// Non-fatal: hypergraph is supplementary to the core indexing pipeline
logger.warn(`Hypergraph rebuild skipped: ${hgError instanceof Error ? hgError.message : hgError}`);
}
}
this.updateWorkflowProgress(workflowId, 100);
this.completeWorkflow(workflowId);
@@ -1504,6 +1531,19 @@ export class CodeIntelligenceCoordinator
return HypergraphHelpers.enhanceImpactWithHypergraph(this.hypergraph, request, baseAnalysis);
}
// ============================================================================
// Hypergraph Helpers
// ============================================================================
/**
* Build a CodeIndexResult from file paths using shared lightweight regex extraction.
* Used to keep hypergraph in sync when index() is called.
*/
private async buildCodeIndexResultFromPaths(paths: string[]): Promise<CodeIndexResult> {
const { extractCodeIndex } = await import('../../shared/code-index-extractor.js');
return extractCodeIndex(paths);
}
// ============================================================================
// Domain-Specific Consensus Methods (MM-001)
// ============================================================================
+80 -36
View File
@@ -67,49 +67,59 @@ export class CodeIntelligencePhase extends BasePhase<CodeIntelligenceResult> {
protected async run(context: InitContext): Promise<CodeIntelligenceResult> {
const { projectRoot } = context;
const dbPath = join(projectRoot, '.agentic-qe', 'memory.db');
const hasIndex = await this.checkCodeIntelligenceIndex(projectRoot);
if (!hasIndex) {
if (!existsSync(dbPath)) {
context.services.log(' Building knowledge graph...');
return await this.runCodeIntelligenceScan(projectRoot, context, false);
}
// Delta scan: check for files modified since last index
const lastIndexedAt = await this.getLastIndexedAt(projectRoot);
if (!lastIndexedAt) {
const entryCount = await this.getKGEntryCount(projectRoot);
context.services.log(` Using existing index (${entryCount} entries)`);
return { status: 'existing', entries: entryCount };
}
// Open a single DB connection for all pre-scan queries
const db = openDatabase(dbPath);
try {
const hasIndex = this.checkCodeIntelligenceIndex(db);
const changedFiles = await this.findChangedFiles(projectRoot, lastIndexedAt);
if (changedFiles.length === 0) {
const entryCount = await this.getKGEntryCount(projectRoot);
context.services.log(` Index up to date (${entryCount} entries)`);
return { status: 'existing', entries: entryCount };
}
if (!hasIndex) {
db.close();
context.services.log(' Building knowledge graph...');
return await this.runCodeIntelligenceScan(projectRoot, context, false);
}
context.services.log(` Delta scan: ${changedFiles.length} files changed since last index...`);
return await this.runCodeIntelligenceScan(projectRoot, context, true, changedFiles);
// Delta scan: check for files modified since last index
const lastIndexedAt = this.getLastIndexedAt(db);
if (!lastIndexedAt) {
const entryCount = this.getKGEntryCount(db);
db.close();
context.services.log(` Using existing index (${entryCount} entries)`);
return { status: 'existing', entries: entryCount };
}
const entryCount = this.getKGEntryCount(db);
db.close();
const changedFiles = await this.findChangedFiles(projectRoot, lastIndexedAt);
if (changedFiles.length === 0) {
context.services.log(` Index up to date (${entryCount} entries)`);
return { status: 'existing', entries: entryCount };
}
context.services.log(` Delta scan: ${changedFiles.length} files changed since last index...`);
return await this.runCodeIntelligenceScan(projectRoot, context, true, changedFiles);
} catch (error) {
try { db.close(); } catch { /* ignore */ }
throw error;
}
}
/**
* Check if code intelligence index exists
* Check if code intelligence index exists (uses provided db connection)
*/
private async checkCodeIntelligenceIndex(projectRoot: string): Promise<boolean> {
const dbPath = join(projectRoot, '.agentic-qe', 'memory.db');
if (!existsSync(dbPath)) {
return false;
}
private checkCodeIntelligenceIndex(db: ReturnType<typeof openDatabase>): boolean {
try {
const db = openDatabase(dbPath);
const result = db.prepare(`
SELECT COUNT(*) as count FROM kv_store
WHERE namespace = 'code-intelligence:kg'
`).get() as { count: number };
db.close();
return result.count > 0;
} catch {
return false;
@@ -117,17 +127,14 @@ export class CodeIntelligencePhase extends BasePhase<CodeIntelligenceResult> {
}
/**
* Get count of KG entries
* Get count of KG entries (uses provided db connection)
*/
private async getKGEntryCount(projectRoot: string): Promise<number> {
const dbPath = join(projectRoot, '.agentic-qe', 'memory.db');
private getKGEntryCount(db: ReturnType<typeof openDatabase>): number {
try {
const db = openDatabase(dbPath);
const result = db.prepare(`
SELECT COUNT(*) as count FROM kv_store
WHERE namespace LIKE 'code-intelligence:kg%'
`).get() as { count: number };
db.close();
return result.count;
} catch {
return 0;
@@ -196,6 +203,11 @@ export class CodeIntelligencePhase extends BasePhase<CodeIntelligenceResult> {
const entries = result.value.nodesCreated + result.value.edgesCreated;
const label = incremental ? 'Delta indexed' : 'Indexed';
context.services.log(` ${label} ${entries} entries to ${dbPath}`);
// Also populate the hypergraph tables (hypergraph_nodes/hypergraph_edges)
// so CLI/MCP hypergraph queries work immediately after init
await this.buildHypergraph(dbPath, filesToIndex, context);
return { status: 'indexed', entries };
}
@@ -207,18 +219,50 @@ export class CodeIntelligencePhase extends BasePhase<CodeIntelligenceResult> {
}
/**
* Read the indexedAt timestamp from KG metadata
* Build hypergraph from indexed files.
* Uses shared extractor to populate hypergraph_nodes/hypergraph_edges in memory.db.
*/
private async getLastIndexedAt(projectRoot: string): Promise<Date | null> {
const dbPath = join(projectRoot, '.agentic-qe', 'memory.db');
private async buildHypergraph(
dbPath: string,
filesToIndex: string[],
context: InitContext
): Promise<void> {
try {
const { extractCodeIndex } = await import('../../shared/code-index-extractor.js');
const { createHypergraphEngine } = await import('../../integrations/ruvector/hypergraph-engine.js');
const db = openDatabase(dbPath);
const engine = await createHypergraphEngine({
db,
maxTraversalDepth: 10,
maxQueryResults: 1000,
enableVectorSearch: false,
});
const codeIndexResult = await extractCodeIndex(filesToIndex);
const buildResult = await engine.buildFromIndexResult(codeIndexResult);
db.close();
const total = buildResult.nodesCreated + buildResult.edgesCreated;
if (total > 0) {
context.services.log(` Hypergraph: ${buildResult.nodesCreated} nodes, ${buildResult.edgesCreated} edges`);
}
} catch (error) {
// Non-fatal: hypergraph is supplementary
context.services.warn?.(` Hypergraph build skipped: ${error}`);
}
}
/**
* Read the indexedAt timestamp from KG metadata (uses provided db connection)
*/
private getLastIndexedAt(db: ReturnType<typeof openDatabase>): Date | null {
try {
const row = db.prepare(`
SELECT value FROM kv_store
WHERE namespace = 'code-intelligence:kg'
AND key = 'metadata:index'
`).get() as { value: string } | undefined;
db.close();
if (!row) return null;
const metadata = safeJsonParse(row.value);
+180
View File
@@ -0,0 +1,180 @@
/**
* Agentic QE v3 - Hypergraph Query MCP Handler
*
* Exposes hypergraph queries as an MCP tool:
* - stats: node/edge counts
* - untested: functions without test coverage
* - impacted: tests impacted by changed files
* - gaps: functions with low coverage
*/
import { join, resolve } from 'path';
import { existsSync } from 'fs';
import { v4 as uuidv4 } from 'uuid';
import type { ToolResult } from '../types.js';
import { findProjectRoot } from '../../kernel/unified-memory.js';
import { openDatabase } from '../../shared/safe-db.js';
import { createHypergraphEngine } from '../../integrations/ruvector/hypergraph-engine.js';
import { toErrorMessage } from '../../shared/error-utils.js';
// ============================================================================
// Types
// ============================================================================
export interface HypergraphQueryParams {
/** Query type */
query: 'stats' | 'untested' | 'impacted' | 'gaps';
/** Changed files (for 'impacted' query) */
files?: string[];
/** Max coverage threshold (for 'gaps' query, default 50) */
maxCoverage?: number;
/** Max results (default 20) */
limit?: number;
}
export interface HypergraphQueryResult {
query: string;
data: Record<string, unknown>;
totalResults: number;
}
// ============================================================================
// Helpers
// ============================================================================
function makeResult(
success: boolean,
startTime: number,
data?: HypergraphQueryResult,
error?: string
): ToolResult<HypergraphQueryResult> {
return {
success,
data,
error,
metadata: {
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
requestId: uuidv4(),
domain: 'code-intelligence',
toolName: 'hypergraph_query',
dataSource: 'real',
},
};
}
// ============================================================================
// Handler
// ============================================================================
export async function handleHypergraphQuery(
params: HypergraphQueryParams
): Promise<ToolResult<HypergraphQueryResult>> {
const startTime = Date.now();
try {
const projectRoot = findProjectRoot();
const dbPath = join(projectRoot, '.agentic-qe', 'memory.db');
if (!existsSync(dbPath)) {
return makeResult(false, startTime, undefined,
`Database not found: ${dbPath}. Run "aqe init --auto" first.`);
}
const db = openDatabase(dbPath);
try {
const engine = await createHypergraphEngine({
db,
maxTraversalDepth: 10,
maxQueryResults: 1000,
enableVectorSearch: false,
});
const limit = params.limit || 20;
switch (params.query) {
case 'stats': {
const stats = await engine.getStats();
return makeResult(true, startTime, {
query: 'stats',
data: {
totalNodes: stats.totalNodes,
totalEdges: stats.totalEdges,
nodesByType: stats.nodesByType,
edgesByType: stats.edgesByType,
avgComplexity: stats.avgComplexity,
avgCoverage: stats.avgCoverage,
nodesWithEmbeddings: stats.nodesWithEmbeddings,
},
totalResults: stats.totalNodes + stats.totalEdges,
});
}
case 'untested': {
const untested = await engine.findUntestedFunctions();
const results = untested.slice(0, limit);
return makeResult(true, startTime, {
query: 'untested',
data: {
functions: results.map(fn => ({
name: fn.name,
filePath: fn.filePath,
lineStart: fn.lineStart,
complexity: fn.complexity,
})),
},
totalResults: untested.length,
});
}
case 'impacted': {
if (!params.files || params.files.length === 0) {
return makeResult(false, startTime, undefined,
'The "files" parameter is required for the "impacted" query.');
}
// Resolve relative paths to absolute so they match hypergraph entries
const absoluteFiles = params.files.map(f => resolve(f));
const tests = await engine.findImpactedTests(absoluteFiles);
return makeResult(true, startTime, {
query: 'impacted',
data: {
changedFiles: params.files,
impactedTests: tests.map(t => ({
name: t.name,
filePath: t.filePath,
})),
},
totalResults: tests.length,
});
}
case 'gaps': {
const maxCov = params.maxCoverage ?? 50;
const gaps = await engine.findCoverageGaps(maxCov);
const results = gaps.slice(0, limit);
return makeResult(true, startTime, {
query: 'gaps',
data: {
maxCoverage: maxCov,
functions: results.map(fn => ({
name: fn.name,
filePath: fn.filePath,
coverage: fn.coverage,
complexity: fn.complexity,
})),
},
totalResults: gaps.length,
});
}
default:
return makeResult(false, startTime, undefined,
`Unknown query type: "${params.query}". Use: stats, untested, impacted, gaps`);
}
} finally {
try { db.close(); } catch { /* ignore */ }
}
} catch (error) {
return makeResult(false, startTime, undefined, toErrorMessage(error));
}
}
+7
View File
@@ -108,6 +108,13 @@ export {
type ValidationPipelineResult,
} from './validation-pipeline-handler.js';
// Hypergraph query handler
export {
handleHypergraphQuery,
type HypergraphQueryParams,
type HypergraphQueryResult,
} from './hypergraph-handler.js';
// Cross-phase handlers
export {
handleCrossPhaseStore,
+20
View File
@@ -42,6 +42,8 @@ import {
handleDefectPredict,
handleRequirementsValidate,
handleCodeIndex,
// Hypergraph query handler
handleHypergraphQuery,
// Memory handlers
handleMemoryStore,
handleMemoryRetrieve,
@@ -465,6 +467,24 @@ const DOMAIN_TOOLS: ToolEntry[] = [
handler: handleCodeIndex,
},
// Hypergraph Query
{
definition: {
name: 'mcp__agentic_qe__hypergraph_query',
description: 'Query the code knowledge hypergraph for untested functions, impacted tests, coverage gaps, and stats',
category: 'domain',
domain: 'code-intelligence',
lazyLoad: true,
parameters: [
{ name: 'query', type: 'string', description: 'Query type: stats, untested, impacted, gaps', required: true },
{ name: 'files', type: 'array', description: 'Changed files (required for "impacted" query)' },
{ name: 'maxCoverage', type: 'number', description: 'Max coverage threshold for "gaps" query (default: 50)' },
{ name: 'limit', type: 'number', description: 'Max results (default: 20)' },
],
},
handler: handleHypergraphQuery,
},
// Validation Pipeline (BMAD-003)
{
definition: {
+5
View File
@@ -39,6 +39,7 @@ const DEFAULT_SCOPES: Record<AgentRole, ToolScope> = {
'test_execute_parallel',
'coverage_analyze_sublinear',
'code_index',
'hypergraph_query',
'memory_query',
'memory_retrieve',
'model_route',
@@ -48,6 +49,7 @@ const DEFAULT_SCOPES: Record<AgentRole, ToolScope> = {
allowed: [
'coverage_analyze_sublinear',
'code_index',
'hypergraph_query',
'quality_assess',
'memory_query',
'memory_retrieve',
@@ -57,6 +59,7 @@ const DEFAULT_SCOPES: Record<AgentRole, ToolScope> = {
allowed: [
'security_scan_comprehensive',
'code_index',
'hypergraph_query',
'memory_query',
'memory_retrieve',
],
@@ -66,6 +69,7 @@ const DEFAULT_SCOPES: Record<AgentRole, ToolScope> = {
'quality_assess',
'coverage_analyze_sublinear',
'defect_predict',
'hypergraph_query',
'memory_query',
'memory_retrieve',
],
@@ -74,6 +78,7 @@ const DEFAULT_SCOPES: Record<AgentRole, ToolScope> = {
allowed: [
'defect_predict',
'code_index',
'hypergraph_query',
'memory_query',
'memory_retrieve',
],
+128
View File
@@ -0,0 +1,128 @@
/**
* Lightweight regex-based code entity extractor.
*
* Builds a CodeIndexResult from file paths by scanning for
* function/class/interface declarations, arrow function exports,
* class method definitions, and import statements.
*
* Uses async file I/O to avoid blocking the event loop on large codebases.
*
* Used by both init phase 06 and the code-intelligence coordinator
* to populate hypergraph tables.
*/
import { readFile } from 'fs/promises';
import type { CodeIndexResult } from '../integrations/ruvector/hypergraph-engine.js';
// Re-export the canonical type so callers don't need a second import
export type { CodeIndexResult };
// ============================================================================
// Constants
// ============================================================================
/** Keywords that look like method definitions but are control flow */
const CONTROL_FLOW_KEYWORDS = new Set([
'if', 'for', 'while', 'switch', 'catch', 'return',
'new', 'throw', 'import', 'export', 'constructor',
]);
/** Batch size for concurrent file reads to avoid fd exhaustion */
const READ_BATCH_SIZE = 50;
// ============================================================================
// Extractor
// ============================================================================
/**
* Parse a single file's content into entities and imports.
* Pure function no I/O.
*/
function parseFileContent(
filePath: string,
content: string
): CodeIndexResult['files'][0] {
const lines = content.split('\n');
const entities: CodeIndexResult['files'][0]['entities'] = [];
const imports: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Named function declarations: function foo() / export async function foo()
const funcMatch = line.match(/(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+(\w+)/);
if (funcMatch) {
entities.push({ type: 'function', name: funcMatch[1], lineStart: i + 1 });
continue;
}
// Arrow function exports: export const foo = (...) => / export const foo = async (
const arrowMatch = line.match(/(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_]\w*)\s*(?::\s*[^=]+)?\s*=>/);
if (arrowMatch) {
entities.push({ type: 'function', name: arrowMatch[1], lineStart: i + 1 });
continue;
}
// Class declarations: class Foo / export abstract class Foo
const classMatch = line.match(/(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(\w+)/);
if (classMatch) {
entities.push({ type: 'class', name: classMatch[1], lineStart: i + 1 });
continue;
}
// Interface declarations: interface Foo / export interface Foo
const interfaceMatch = line.match(/(?:export\s+)?interface\s+(\w+)/);
if (interfaceMatch) {
entities.push({ type: 'interface', name: interfaceMatch[1], lineStart: i + 1 });
continue;
}
// Class method definitions: async doThing(...) / public static foo(
// Must be indented (inside a class body) and not a control flow keyword
const methodMatch = line.match(/^\s+(?:(?:public|private|protected|static|readonly|override|abstract|async)\s+)*(\w+)\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{?\s*$/);
if (methodMatch && !CONTROL_FLOW_KEYWORDS.has(methodMatch[1])) {
entities.push({ type: 'function', name: methodMatch[1], lineStart: i + 1 });
continue;
}
// Relative imports: import ... from './foo' / from '../bar'
const importMatch = line.match(/(?:import|from)\s+['"](\.[^'"]+)['"]/);
if (importMatch) {
imports.push(importMatch[1]);
}
}
return { path: filePath, entities, imports };
}
/**
* Extract code entities and imports from a list of file paths.
* Returns a CodeIndexResult compatible with HypergraphEngine.buildFromIndexResult().
*
* Uses async I/O in batches to avoid blocking the event loop and
* exhausting file descriptors on large codebases.
*/
export async function extractCodeIndex(paths: string[]): Promise<CodeIndexResult> {
const files: CodeIndexResult['files'] = [];
// Process files in batches to limit concurrency
for (let offset = 0; offset < paths.length; offset += READ_BATCH_SIZE) {
const batch = paths.slice(offset, offset + READ_BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(async (filePath) => {
const content = await readFile(filePath, 'utf-8');
return parseFileContent(filePath, content);
})
);
for (const result of results) {
if (result.status === 'fulfilled') {
files.push(result.value);
}
// Skip rejected (unreadable files) silently
}
}
return { files };
}
@@ -105,7 +105,7 @@ function createMockAgentCoordinator(): AgentCoordinator {
function createTempDbPath(): string {
const tempDir = path.join('/tmp', 'agentic-qe-test', `test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.mkdirSync(tempDir, { recursive: true });
return path.join(tempDir, 'hypergraph.db');
return path.join(tempDir, 'test-memory.db');
}
/**
@@ -0,0 +1,222 @@
/**
* Tests for the lightweight code entity extractor.
* Validates regex patterns for functions, classes, interfaces,
* arrow exports, methods, and imports.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { extractCodeIndex } from '../../../src/shared/code-index-extractor';
// ============================================================================
// Test Setup
// ============================================================================
const TEMP_DIR = join('/tmp', `extractor-test-${Date.now()}`);
beforeEach(() => {
mkdirSync(TEMP_DIR, { recursive: true });
});
afterEach(() => {
rmSync(TEMP_DIR, { recursive: true, force: true });
});
function writeTemp(name: string, content: string): string {
const filePath = join(TEMP_DIR, name);
writeFileSync(filePath, content, 'utf-8');
return filePath;
}
// ============================================================================
// Tests
// ============================================================================
describe('extractCodeIndex', () => {
it('should extract named function declarations', async () => {
const file = writeTemp('funcs.ts', `
export function doSomething() {}
async function fetchData() {}
export default function handleRequest() {}
function privateHelper() {}
`);
const result = await extractCodeIndex([file]);
const entities = result.files[0].entities;
const names = entities.map(e => e.name);
expect(names).toContain('doSomething');
expect(names).toContain('fetchData');
expect(names).toContain('handleRequest');
expect(names).toContain('privateHelper');
expect(entities.every(e => e.type === 'function')).toBe(true);
});
it('should extract arrow function exports', async () => {
const file = writeTemp('arrows.ts', `
export const createHandler = (req: Request) => {};
const processItems = async (items: string[]) => {};
export const validate = (input: unknown): boolean => true;
`);
const result = await extractCodeIndex([file]);
const names = result.files[0].entities.map(e => e.name);
expect(names).toContain('createHandler');
expect(names).toContain('processItems');
expect(names).toContain('validate');
});
it('should extract class declarations', async () => {
const file = writeTemp('classes.ts', `
export class UserService {}
abstract class BaseHandler {}
export default class MainApp {}
class PrivateHelper {}
`);
const result = await extractCodeIndex([file]);
const entities = result.files[0].entities;
const names = entities.map(e => e.name);
expect(names).toContain('UserService');
expect(names).toContain('BaseHandler');
expect(names).toContain('MainApp');
expect(names).toContain('PrivateHelper');
expect(entities.every(e => e.type === 'class')).toBe(true);
});
it('should extract interface declarations', async () => {
const file = writeTemp('interfaces.ts', `
export interface UserProfile {
name: string;
}
interface InternalConfig {
debug: boolean;
}
`);
const result = await extractCodeIndex([file]);
const entities = result.files[0].entities;
const names = entities.map(e => e.name);
expect(names).toContain('UserProfile');
expect(names).toContain('InternalConfig');
expect(entities.every(e => e.type === 'interface')).toBe(true);
});
it('should extract class method definitions', async () => {
const file = writeTemp('methods.ts', `
class MyService {
async initialize(): Promise<void> {
}
public static create(config: Config): MyService {
}
private doWork(input: string): string {
}
}
`);
const result = await extractCodeIndex([file]);
const names = result.files[0].entities
.filter(e => e.type === 'function')
.map(e => e.name);
expect(names).toContain('initialize');
expect(names).toContain('create');
expect(names).toContain('doWork');
});
it('should not extract control flow keywords as methods', async () => {
const file = writeTemp('control.ts', `
class Foo {
doWork() {
if (true) {
}
for (const x of items) {
}
while (running) {
}
switch (value) {
}
}
}
`);
const result = await extractCodeIndex([file]);
const names = result.files[0].entities.map(e => e.name);
expect(names).not.toContain('if');
expect(names).not.toContain('for');
expect(names).not.toContain('while');
expect(names).not.toContain('switch');
});
it('should extract relative imports', async () => {
const file = writeTemp('imports.ts', `
import { foo } from './utils';
import bar from '../lib/bar';
import { baz } from 'external-package';
`);
const result = await extractCodeIndex([file]);
const imports = result.files[0].imports;
expect(imports).toContain('./utils');
expect(imports).toContain('../lib/bar');
// External package imports should NOT be included
expect(imports).not.toContain('external-package');
});
it('should record correct line numbers', async () => {
const file = writeTemp('lines.ts', `// line 1
// line 2
export function thirdLine() {}
// line 4
class FifthLine {}
`);
const result = await extractCodeIndex([file]);
const entities = result.files[0].entities;
expect(entities.find(e => e.name === 'thirdLine')?.lineStart).toBe(3);
expect(entities.find(e => e.name === 'FifthLine')?.lineStart).toBe(5);
});
it('should skip unreadable files without failing', async () => {
const goodFile = writeTemp('good.ts', 'export function works() {}');
const badPath = join(TEMP_DIR, 'nonexistent.ts');
const result = await extractCodeIndex([goodFile, badPath]);
expect(result.files).toHaveLength(1);
expect(result.files[0].entities[0].name).toBe('works');
});
it('should handle empty file list', async () => {
const result = await extractCodeIndex([]);
expect(result.files).toHaveLength(0);
});
it('should handle file with no entities', async () => {
const file = writeTemp('empty.ts', '// just a comment\nconst x = 42;\n');
const result = await extractCodeIndex([file]);
expect(result.files).toHaveLength(1);
expect(result.files[0].entities).toHaveLength(0);
});
it('should process multiple files', async () => {
const file1 = writeTemp('a.ts', 'export function funcA() {}');
const file2 = writeTemp('b.ts', 'export class ClassB {}');
const file3 = writeTemp('c.ts', 'export interface InterfaceC {}');
const result = await extractCodeIndex([file1, file2, file3]);
expect(result.files).toHaveLength(3);
expect(result.files[0].entities[0].name).toBe('funcA');
expect(result.files[1].entities[0].name).toBe('ClassB');
expect(result.files[2].entities[0].name).toBe('InterfaceC');
});
});