mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
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:
@@ -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 |
|
||||
|---------|----------|-------------|
|
||||
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user